npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vibemonetize/seo

v0.0.8

Published

SEO kit: <SeoPage>, generateSeoPages(), sitemap/robots/OG-image/paywalled-content-JSON-LD helpers. Framework-agnostic root entry (Next + Vite); thin Next.js route-handler adapters behind ./next; a Node-only Vite static-export/prerender helper behind ./pre

Readme

@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

  • apps/store (RFC 005) wants this package for its category/OG pages (STATUS follow-up #29) — not wired up yet since that's apps/store's directory, not this package's.
  • Per RFC 016: packages/cli's vibemonetize init should scaffold the Next sitemap/robots/OG-route files and a Vite postbuild prerender script automatically; a dashboard SEO tab and a platform-hosted OG-image endpoint for no-backend apps both need new apps/api/DB support and are scoped there, not built yet.