@vibemonetize/seo
SEO kit for vibe-coded apps (PLAN.md §5.3): <SeoPage>, generateSeoPages(), sitemap/robots
helpers, an OG-image URL builder + SVG renderer, a paywalled-content JSON-LD helper, and a
static-export helper for prerendering per-route <head> tags in Vite SPAs.
The root entry (@vibemonetize/seo) is framework-agnostic — no next/* or node:*
imports anywhere in it, mirroring how @vibemonetize/react handles Next-vs-Vite
compatibility. Thin Next.js App Router adapters (which import next for its types only)
live behind the @vibemonetize/seo/next subpath, so a Vite app never pulls in next. A
Node-only static-export/prerender helper (uses node:fs/node:path, meant for a build
script, not application code) lives behind @vibemonetize/seo/prerender, so a browser
bundle never pulls in Node built-ins either.
Gating content behind <Paywall>? Read "Paywalled content: don't tank your rankings"
below before you ship — a naive client-side gate looks like thin or cloaked content to
search engines, and the fix is mostly about page structure, not code.
Install
pnpm add @vibemonetize/seo
<SeoPage> — React component
A plain function component (no hooks, no "use client") that renders <title>/<meta>/
<link> tags. In Next.js App Router, any of those tags rendered by a component are
automatically hoisted into <head> — so <SeoPage> works from a server component with no
next/head. In a Vite SPA (or any client-only render tree without that hoisting), pair it
with a head-management library (e.g. react-helmet-async) or use toHeadHtml/toMetaTags
instead (see below).
// app/pricing/page.tsx (Next) — or any route component in Vite.
import { SeoPage } from "@vibemonetize/seo";
export default function PricingPage() {
return (
<>
<SeoPage
title="Pricing — Acme"
description="Simple, transparent pricing for Acme."
canonical="https://acme.dev/pricing"
siteName="Acme"
image="https://acme.dev/api/og?title=Pricing"
/>
<PricingContent />
</>
);
}
SeoMetaInput
All of the helpers below (<SeoPage>, toMetadataObject, toMetaTags/toHeadHtml,
generateSeoPages) share one input shape:
interface SeoMetaInput {
title: string;
description: string;
canonical?: string; // absolute URL
siteName?: string;
image?: string; // absolute OG/twitter share-image URL — see buildOgImageUrl
imageAlt?: string;
type?: "website" | "article"; // default "website"
twitterCard?: "summary" | "summary_large_image"; // default: summary_large_image iff image is set
noindex?: boolean;
locale?: string;
}
toMetadataObject / toNextMetadata — for generateMetadata
Prefer this over <SeoPage> when the whole route is already a server component and you'd
rather return metadata than render tags:
// app/pricing/page.tsx
import { toMetadataObject } from "@vibemonetize/seo"; // framework-agnostic object
// or: import { toNextMetadata } from "@vibemonetize/seo/next"; // typed as next's `Metadata`
import type { Metadata } from "next";
export function generateMetadata(): Metadata {
return toMetadataObject({
title: "Pricing — Acme",
description: "Simple, transparent pricing for Acme.",
canonical: "https://acme.dev/pricing",
});
}
toMetaTags / toHeadHtml — non-React consumers
For Vite index.html templating, edge functions, or any document head that isn't rendered
by React:
import { toHeadHtml } from "@vibemonetize/seo";
const headHtml = toHeadHtml({ title: "Acme", description: "…" });
// => `<title>Acme</title>\n<meta name="description" content="…">\n…`
toMetaTags returns the same tags as a flat, ordered array of { tag, attrs?, content? }
descriptors if you need to render them yourself in some other templating system.
generateSeoPages() — programmatic page generation
Template + data rows → page descriptors, each with ready-to-use SEO metadata and sitemap fields:
import { generateSeoPages } from "@vibemonetize/seo";
const guidePages = generateSeoPages(
{
path: (g) => `/guides/${g.slug}`,
title: (g) => `${g.name} — VibeMonetize guide`,
description: (g) => g.summary,
changeFrequency: () => "monthly",
},
guides, // Guide[]
{ baseUrl: "https://docs.vibemonetize.dev" }, // makes seo.canonical / sitemap URLs absolute
);
// guidePages[0] => { path, seo: SeoMetaInput, sitemap: {changeFrequency, priority?, lastModified?}, data: Guide }
Feed guidePages into your router (e.g. Next's generateStaticParams) for the pages
themselves, and into sitemapEntriesFromPages (below) for sitemap.xml.
Sitemap
import { renderSitemapXml, sitemapEntriesFromPages } from "@vibemonetize/seo";
const entries = [
...sitemapEntriesFromPages(guidePages, "https://docs.vibemonetize.dev"),
{ url: "https://docs.vibemonetize.dev", changeFrequency: "weekly", priority: 1 },
];
const xml = renderSitemapXml(entries); // pure string — write this yourself, or see the Next adapter
Next App Router adapter (app/sitemap.ts supports returning MetadataRoute.Sitemap
directly instead of XML — no route handler needed):
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { toNextSitemapFromPages } from "@vibemonetize/seo/next";
import { guidePages } from "@/lib/seo-pages";
export default function sitemap(): MetadataRoute.Sitemap {
return toNextSitemapFromPages(guidePages, "https://docs.vibemonetize.dev");
}
Robots
import { defaultRobots, renderRobotsTxt } from "@vibemonetize/seo";
const txt = renderRobotsTxt(defaultRobots("https://acme.dev/sitemap.xml"));
Next adapter for app/robots.ts:
// app/robots.ts
import type { MetadataRoute } from "next";
import { toNextRobots } from "@vibemonetize/seo/next";
import { defaultRobots } from "@vibemonetize/seo";
export default function robots(): MetadataRoute.Robots {
return toNextRobots(defaultRobots("https://acme.dev/sitemap.xml"));
}
OG-image helper
buildOgImageUrl builds a URL pointing at an OG-image endpoint (query-string driven);
renderOgImageSvg renders the actual 1200×630 image as an SVG string — no headless
browser or canvas dependency.
import { buildOgImageUrl } from "@vibemonetize/seo";
const image = buildOgImageUrl({
endpoint: "https://acme.dev/api/og",
title: "Pricing",
subtitle: "Plans for every team",
siteName: "Acme",
});
Wire up the endpoint itself with the Next adapter:
// app/api/og/route.ts
import { createOgImageRouteHandler } from "@vibemonetize/seo/next";
export const { GET } = createOgImageRouteHandler({ siteName: "Acme" });
Backend-less apps can use VibeMonetize's hosted renderer. The appId must name an
active registered app; title is required and bounded to 120 characters:
const image = buildOgImageUrl({
endpoint: `https://api.vibemonetize.dev/v1/seo/og?appId=${encodeURIComponent(import.meta.env.VITE_VIBEMONETIZE_APP_ID)}`,
title: "Pricing",
subtitle: "Plans for every team",
});
The public route is rate-limited and uses the registered app name as the site name.
For any other runtime (Vite/Node/edge function), call parseOgImageParams on your own
URLSearchParams and renderOgImageSvg on the result, then return it with
content-type: image/svg+xml.
Paywalled content: don't tank your rankings
<Paywall> (@vibemonetize/react) gates whatever you put inside it. If you put your
entire article/page inside a <Paywall>, a crawler that never resolves entitlements sees
an empty shell — that reads as thin content to search engines, and if you instead serve
crawlers the full gated body while showing a real logged-out visitor a paywall, that's
cloaking (a manual-action risk, not just a ranking one). Google's documented mechanism for
this is flexible sampling: render the free excerpt as plain HTML outside the
<Paywall> boundary — everyone, crawlers included, sees the same free preview — and mark
the page with CreativeWork/Article structured data so Google knows the rest is
legitimately gated rather than missing.
buildPaywalledContentJsonLd builds exactly that structured data:
import { JsonLd, buildPaywalledContentJsonLd } from "@vibemonetize/seo";
import { Paywall } from "@vibemonetize/react";
const jsonLd = buildPaywalledContentJsonLd({
headline: "How we cut cold-start latency by 80%",
url: "https://acme.dev/blog/cold-start",
description: "A deep dive into our cold-start optimization.",
datePublished: "2026-01-15",
cssSelector: ".paywalled-section", // the DOM node Paywall's children render into
});
export default function BlogPost() {
return (
<article>
<JsonLd data={jsonLd} />
<p>Free excerpt — rendered for everyone, crawlers included...</p>
<div className="paywalled-section">
<Paywall feature="premium_content" fallback={<UpgradeCta />}>
<p>The rest of the article...</p>
</Paywall>
</div>
</article>
);
}
cssSelector should point at the same DOM node <Paywall> gates — it's how Google's
crawler, which only ever sees the free excerpt, knows where the gated part begins. This
doesn't change <Paywall>'s behavior; it's purely the structured-data signal. See
renderJsonLdScript below for the non-React (Vite index.html) equivalent, and
RFC 016 for the full
writeup (flexible sampling and why this matters).
Product JSON-LD (RFC 018: named sell links)
buildProductJsonLd builds schema.org Product/Offer structured data for an
indexable sell page — apps/store's /buy/[handle]/[slug] (RFC 018) renders this
alongside <SellPage> so a search engine understands the page as a product with real
price(s), not just generic HTML:
import { JsonLd, buildProductJsonLd } from "@vibemonetize/seo";
<JsonLd
data={buildProductJsonLd({
name: "Acme Pro",
url: "https://store.vibemonetize.dev/buy/acme/pro",
offers: [{ priceCents: 900, currency: "usd" }],
})}
/>;
Pass one offers entry per sellable plan — buildProductJsonLd emits a single Offer
object for one plan, or an array of them for a multi-plan product (both are valid
schema.org shapes).
JSON-LD
<JsonLd data={...} /> renders any JSON-LD document (not just
buildPaywalledContentJsonLd's output) as a <script type="application/ld+json"> tag —
same plain-function-component shape as <SeoPage>, so it works from a Next Server
Component with no extra setup. renderJsonLdScript is the string equivalent for Vite
index.html templating or the static-export helper below. Both escape < so a value can
never prematurely close the surrounding <script> tag.
import { renderJsonLdScript } from "@vibemonetize/seo";
const script = renderJsonLdScript({ "@context": "https://schema.org", "@type": "Organization", name: "Acme" });
Static export / prerendering for Vite SPAs
A pure client-rendered Vite app serves one index.html for every route, so without extra
work every route shares identical <title>/OG tags — invisible to the many crawlers
(nearly all social/link-preview bots, and unreliably some search engines) that don't
execute JS before reading the document. This is a real ceiling on a CSR-only app's SEO, not
something a metadata library alone fixes — see
RFC 016 §2
for the honest version of this tradeoff (and when to just use Next.js instead).
prerenderStaticSeo (from the Node-only @vibemonetize/seo/prerender subpath) is a
partial mitigation: run it as a postbuild script after vite build, and it writes one
static dist/<route>/index.html per route with that route's meta tags (and optional
JSON-LD) baked in, using a head marker comment you add to your index.html source. Your
app's JS bundle and client-side router are untouched — real users still get the SPA; a
crawler that fetches /pricing directly now sees correct tags without executing anything.
<!-- index.html -->
<head>
<meta charset="UTF-8" />
<!--vibemonetize-seo-->
<!-- ^ prerenderStaticSeo replaces this comment per-route -->
</head>
// scripts/prerender-seo.ts — wire up as "postbuild": "tsx scripts/prerender-seo.ts"
import { prerenderStaticSeo } from "@vibemonetize/seo/prerender";
prerenderStaticSeo(
[
{ path: "/", seo: { title: "Acme", description: "..." } },
{ path: "/pricing", seo: { title: "Pricing — Acme", description: "..." } },
],
{ templatePath: "dist/index.html" },
);
Public API
| Export | From | What |
|---|---|---|
SeoPage |
. |
React component rendering title/meta/link tags |
toMetadataObject |
. |
SeoMetaInput → Next-Metadata-shaped plain object |
toMetaTags / toHeadHtml |
. |
SeoMetaInput → tag descriptors / raw HTML string |
generateSeoPages |
. |
template + rows → page descriptors (seo + sitemap fields) |
sitemapEntriesFromPages / renderSitemapXml |
. |
build + render sitemap.xml |
defaultRobots / renderRobotsTxt |
. |
build + render robots.txt |
buildOgImageUrl / parseOgImageParams / renderOgImageSvg |
. |
OG-image URL + SVG renderer |
buildPaywalledContentJsonLd |
. |
gated-page input → Google's Paywalled Content CreativeWork JSON-LD |
buildProductJsonLd |
. |
sellable plan(s) → schema.org Product/Offer JSON-LD (RFC 018) |
JsonLd |
. |
React component rendering any JSON-LD document as a <script> tag |
stringifyJsonLd / renderJsonLdScript |
. |
JSON-LD → escaped JSON string / <script> tag string |
toNextMetadata |
./next |
SeoMetaInput → next's Metadata type |
toNextSitemap / toNextSitemapFromPages |
./next |
→ next's MetadataRoute.Sitemap |
toNextRobots |
./next |
→ next's MetadataRoute.Robots |
createOgImageRouteHandler |
./next |
GET handler for app/api/og/route.ts |
prerenderStaticSeo / renderPrerenderedHtml |
./prerender |
Node-only: bake per-route <head> tags into a built Vite app's static HTML |
All types (SeoMetaInput, SitemapEntry, RobotsConfig, RobotsRule, OgImageParams,
SeoTagDescriptor, SeoPageTemplate, GeneratedSeoPage, WebPageElement,
PaywalledContentInput, ProductJsonLd, ProductJsonLdInput, ProductOfferInput) are
exported from the root entry; PrerenderRoute / PrerenderStaticSeoOptions are exported
from ./prerender.
Follow-ups
apps/store(RFC 005) wants this package for its category/OG pages (STATUS follow-up #29) — not wired up yet since that'sapps/store's directory, not this package's.- Per RFC 016:
packages/cli'svibemonetize initshould scaffold the Next sitemap/robots/OG-route files and a Vitepostbuildprerender script automatically; a dashboard SEO tab and a platform-hosted OG-image endpoint for no-backend apps both need newapps/api/DB support and are scoped there, not built yet.