@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 SeoMetaInputnext'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