Get your app indexed — including the paywalled parts
Flexible sampling + paywalled-content JSON-LD so gating doesn't tank rankings, and the honest Vite-vs-Next SEO tradeoff.
@vibemonetize/seo gives your app the mechanics of good SEO — meta/OG tags, a sitemap, a robots.txt, an OG-image endpoint. None of that matters if the two things below aren't true: your gated content doesn't look broken to a crawler, and your framework actually ships per-route tags to whatever reads them. This guide covers both, plus what a plain <Paywall> integration gets wrong by default.
1. The paywalled-content problem
<Paywall feature="..."> (from @vibemonetize/react) renders nothing useful until entitlements resolve, then renders children or fallback. If you wrap your entire article or page in it, a crawler that never resolves entitlements sees an empty shell — exactly what search engines flag as thin content. Serving crawlers the full gated body while showing a real logged-out visitor a paywall is worse: that's cloaking, a manual-action risk.
Google's documented fix is called flexible sampling: render a free excerpt as plain HTML for everyone — crawlers included — and gate only what comes after it. Concretely, that means putting real content outside the <Paywall> boundary, not just relying on the SDK:
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", // same node <Paywall> gates
});
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>
);
}buildPaywalledContentJsonLd emits Google's documented CreativeWork structured data — isAccessibleForFree: "False" plus a hasPart/cssSelector pointing at the gated DOM node — so Google knows the page is legitimately paywalled instead of just incomplete. cssSelector should point at the same element <Paywall> gates; nothing here changes how <Paywall> itself behaves, it's purely the structured-data signal riding alongside your existing gate.
What this doesn't do: it won't make a genuinely empty free tier rank — you still need a real excerpt worth indexing. And it's not a substitute for actually serving that excerpt server-rendered (or, for a Vite SPA, prerendered — see below); JSON-LD alone can't fix a page a crawler can't read in the first place.
2. Vite SPA vs. Next.js — the honest version
Next.js (App Router): already the easy case. Every route is server-rendered or statically generated, so any crawler — JS-executing or not — sees final HTML. Wire up @vibemonetize/seo/next's toNextMetadata/toNextSitemapFromPages/toNextRobots/createOgImageRouteHandler (this docs site does exactly that — see apps/docs/src/app/{layout,sitemap,robots}.ts in the repo) and you're done.
Vite SPA (client-only, no server): here's the part most vibe-coded apps skip. A pure client-rendered app builds one index.html shared by every route. Without extra work, /, /pricing, and /blog/my-post all serve the exact same<title>/OG tags — whatever's hardcoded in index.html. Setting document.title after mount doesn't help: it happens after first paint, and most link-preview crawlers (Slack, Discord, iMessage, X) never execute JS at all. Googlebot does eventually execute JS, but not reliably enough to depend on for OG previews. A CSR-only app cannot get distinct, correct per-route previews or full-text indexing without some form of prerendering. That's a structural limit, not a missing config flag — say so to yourself before you ship a Vite marketing page and wonder why it never gets indexed.
The practical mitigation, if your product is a tool (not a content site) and a full SSR migration is overkill: @vibemonetize/seo/prerender's prerenderStaticSeo writes a static dist/<route>/index.html per route with that route's tags baked in, run as a postbuild step:
<!-- index.html -->
<head>
<meta charset="UTF-8" />
<!--vibemonetize-seo-->
<!-- prerenderStaticSeo replaces this comment per-route -->
</head>// scripts/prerender-seo.ts — "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" },
);A static host (Vercel, Netlify) then serves the per-route HTML directly for a crawler request to /pricing, while your SPA router still takes over on click-navigation exactly as before — nothing about runtime behavior changes for real users. It's a partial answer: it gets known routes' tags right, not full-text indexing of highly dynamic per-user content. If your product is content (a blog, a directory, anything where organic search is the growth channel), use Next.js — that's the complete fix, not a workaround.
3. The rest of the kit
<SeoPage>/toMetadataObject/toHeadHtml for meta/OG tags, generateSeoPages for programmatic pages (one per blog post/comparison row/guide), renderSitemapXml/defaultRobots for sitemap/robots, and buildOgImageUrl/renderOgImageSvg for a dependency-free OG-image endpoint — all framework-agnostic, all documented with copy-pasteable examples in the @vibemonetize/seo reference (generated straight from the package's own README, so it can't drift).
Dashboard audit and hosted images
Each app's SEO dashboard tab reads its registered domain's /sitemap.xml on demand, inventories up to 20 same-domain pages, and flags missing title, description, canonical, and Open Graph metadata. Audits are bounded and are not stored. Fully backend-less apps can point their image metadata at https://api.vibemonetize.dev/v1/seo/og?appId=...&title=...; the public, rate-limited endpoint renders the same dependency-free image and uses the app's registered name as branding. See RFC 016 for the design and crawler limitations.