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

tanstack-plugin-seo

v0.5.0

Published

SEO tooling for any website, with route-declared SEO and first-class TanStack Router support.

Readme

tanstack-plugin-seo

Route-declared SEO for TanStack Router. Each route declares its SEO policy once, in staticData.seo — the sitemap, robots.txt, breadcrumbs, JSON-LD, cross-links, and the CI check all derive from that one declaration. There is no second place to update, so nothing drifts. A public page that ships without a declaration fails the build.

flowchart LR
  subgraph declare["declare once"]
    route["staticData.seo<br/>+ head()"]
  end
  route --> seograph["SeoGraph<br/>(nodes + edges)"]
  seograph --> sitemap["sitemap.xml"]
  seograph --> robots["robots.txt"]
  seograph --> check["seo check · CI gate"]
  route --> head["&lt;head&gt; meta · canonical<br/>Breadcrumbs · JSON-LD"]
  gate["seoRouteConfig (vite)"] -. "fails undeclared pages" .-> route

Install

bun add tanstack-plugin-seo
bun add -D effect @effect/platform-bun   # only if you use the CLI
bun add -D lighthouse                    # only for `seo audit` performance evidence

| Entry | Exports | Peers | | ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------- | | tanstack-plugin-seo | buildSeoGraph, renderSitemap, renderRobots, contentSignal, checkGraph, inspectHtml | — | | tanstack-plugin-seo/react | createSeoseoHead, Breadcrumbs, JSON-LD generators | react, @tanstack/react-router | | tanstack-plugin-seo/vite | seoRouteConfig coverage gate | vite | | tanstack-plugin-seo/config | defineSeoConfig, viteGraphLoader | vite | | tanstack-plugin-seo/audit | Audit services, scanner protocol, rules, and report schemas | effect | | seo bin | CLI over the same graph | effect, @effect/platform-bun (optional) |

The core and React entries have zero runtime dependencies — everything above is a peer, and only the entries you import need theirs installed.

Quick start

1. Bind your site identity once. Route files never see an origin or a brand name.

// lib/seo.ts
import { createSeo } from "tanstack-plugin-seo/react";

export const { seoHead } = createSeo({
  origin: "https://example.com",
  site: {
    name: "Example",
    logo: "/logo.png",
    publisherLogo: "/publisher.png",
    defaultImage: "/og.png",
    defaultAuthor: { name: "Example Team" },
  },
  organization: {
    legalName: "Example, Inc.",
    description: "What the company does.",
    sameAs: ["https://github.com/example"],
    contactPoint: { contactType: "support", email: "[email protected]" },
    address: {
      streetAddress: "123 Example Street",
      addressLocality: "Example City",
      addressRegion: "CA",
      postalCode: "94105",
      addressCountry: "US",
    },
  },
  website: { searchPath: "/docs?q={search_term_string}" },
});

2. Declare on the route. Policy in staticData.seo, per-page content in head().

export const Route = createFileRoute("/pricing")({
  staticData: {
    seo: {
      kind: "page",
      crumb: "Pricing",
      sitemap: { priority: 0.9, changeFrequency: "weekly" },
      related: ["/features", "/docs"],
      link: { title: "Pricing", description: "Simple volume pricing." },
    },
  },
  head: (ctx) =>
    seoHead(ctx, {
      title: "Pricing — Example",
      description: "Simple volume pricing.",
    }),
});

seoHead returns the meta + canonical links TanStack renders into <head> — title, description, og/twitter cards, robots, and the JSON-LD each page warrants (BreadcrumbList always; Article, FAQPage, Service, ItemList when the instance declares them).

Extensible JSON-LD

The built-in generators are conveniences, not a closed schema registry. Define any schema-dts entity, link it to the plugin's stable site identities, and compose one site graph:

import {
  createSeo,
  defineJsonLd,
  extendJsonLd,
  jsonLdRef,
} from "tanstack-plugin-seo/react";

export const seo = createSeo({
  // site, organization, and website as above
  jsonLd: {
    site: (ids) => [
      defineJsonLd({
        "@type": "SoftwareApplication",
        "@id": "https://example.com/#product",
        name: "Example",
        applicationCategory: "DeveloperApplication",
        operatingSystem: "Web",
        provider: jsonLdRef(ids.organization),
      }),
    ],
    transform: (entry) => {
      if (entry.kind === "organization") {
        return extendJsonLd(entry.document, {
          slogan: "Ship with confidence.",
        });
      }
      return entry.document;
    },
  },
});

const siteGraph = seo.generateSiteGraphSchema();

generateSiteGraphSchema() includes Organization, WebSite, and configured site entities in one @graph. Organization and WebSite use stable #organization and #website IDs; articles and services provided by the site reference the same Organization. Existing individual generators remain available and are not transformed.

Add page-specific entities through seoHead:

head: (ctx) =>
  seo.seoHead(ctx, {
    title: "Example for developers",
    description: "A focused description of the product.",
    jsonLd: [
      defineJsonLd({
        "@type": "SoftwareApplication",
        name: "Example",
        url: "https://example.com/product",
      }),
    ],
  });

The transform sees discriminated generated and custom entries plus origin, canonical, and entityIds. Return the document to keep it, no value (or false) to suppress it, or an array to expand it. Output preserves caller order and never silently deduplicates entities.

3. Serve the projections. Build the graph from your route tree, render strings.

// lib/seo/graph.ts — also the module the CLI loads
import { buildSeoGraph } from "tanstack-plugin-seo";

export const loadSeoGraph = () =>
  buildSeoGraph({ routeTree, collections: [blogCollection] });
// routes/sitemap[.]xml.ts — robots[.]txt.ts is symmetric
import { renderRobots, renderSitemap } from "tanstack-plugin-seo";

renderSitemap(loadSeoGraph(), { origin, indexable: true });
renderRobots(loadSeoGraph(), {
  origin,
  indexable: true,
  disallow: routeConfig.robotsExclusions,
  contentSignal: "search=yes, ai-input=yes, ai-train=yes",
});

renderRobots does not invent a Content-Signal. Pass contentSignal for the value (the plugin prefixes Content-Signal: ), directives for extra group lines, and transform if you need to wrap or replace the whole file. Preview hosts (indexable: false) drop contentSignal and directives; transform still runs.

4. Gate CI. The same graph, the same rules, exit 1 on structural violations.

seo check

Typed paths

Augment Register (the same pattern TanStack Router uses) and related / redirectTo are typed against your real route tree — a renamed route becomes a compile error, not a dead link:

declare module "tanstack-plugin-seo" {
  interface Register {
    paths: FileRouteTypes["fullPaths"];
    kinds: "page" | "article" | "hub";
  }
}

The graph

buildSeoGraph walks the route tree and produces nodes (one per public URL) and typed edges: crumb-parent (breadcrumb ancestry), related (deliberate cross-links), redirect, and collection-member.

Dynamic pages — blog posts rendering through /blog/$slug — enter as collections. Instances inherit the param route's declared policy, so declarations stay the single source of truth:

const blogCollection: SeoCollection = {
  route: "/blog/$slug",
  source: "blog",
  instances: posts.map((p) => ({
    path: `/blog/${p.slug}`,
    title: p.title,
    description: p.description,
    publishedAt: p.date,
  })),
};

checkGraph runs every rule against the graph and returns violations at two severities:

  • structural — internally broken declarations; these fail seo check (exit 1): dead or duplicate edges, path collisions, a redirect in the sitemap, a sitemap/noindex contradiction, a related target with no link card, an instance without a title.
  • editorial — quality smells, reported but non-failing: duplicate or mis-sized titles and descriptions.

Coverage gate (Vite)

seoRouteConfig parses the route tree with @tanstack/router-generator (the same parser as the router) and fails vite build when a page in an enforced group has neither staticData nor head:

// vite.config.ts
import { seoRouteConfig } from "tanstack-plugin-seo/vite";

seoRouteConfig({
  outputPath: fileURLToPath(new URL("./lib/route-config.ts", import.meta.url)),
  publicGroups: ["(marketing)", "(docs)"],
  enforceCoverageIn: ["(marketing)", "(docs)"],
  alwaysDisallow: ["/dashboard", "/api"],
});

It also derives a small config module from the route tree: robotsExclusions (feed to renderRobots) and reservedSegments (top-level segments an app must not hand out as tenant/org slugs).

CLI

The graph commands acquire your graph through seo.config.ts at the app root — the viteGraphLoader evaluates your graph module inside a headless Vite server, so path aliases, content plugins, and virtual modules all resolve:

// seo.config.ts
import { defineSeoConfig, viteGraphLoader } from "tanstack-plugin-seo/config";

export default defineSeoConfig({
  origin: "https://example.com",
  disallow: routeConfig.robotsExclusions,
  contentSignal: "search=yes, ai-input=yes, ai-train=yes",
  loadGraph: viteGraphLoader({
    root: import.meta.dirname,
    entry: "/lib/seo/graph.ts",
    exportName: "loadSeoGraph",
  }),
});
seo check                 # CI gate — exit 1 on structural violations
seo graph                 # the graph as a tree · --format mermaid | json
seo inspect /pricing      # one node: policy, sitemap status, in/out edges
seo inspect <url> --live  # fetch a deployed page, validate its rendered <head>
seo sitemap               # print sitemap.xml
seo robots                # print robots.txt

Stdout is data, stderr is status — seo check --json | jq just works.

Audit any website

seo audit is framework-independent and does not need seo.config.ts. It validates target URLs before making requests, follows redirects through the same validation boundary, inspects the rendered document and discovery files, and can collect Lighthouse evidence through a validating proxy.

seo audit https://example.com
seo audit https://example.com https://example.com/docs --json | jq
seo audit https://localhost:3000 --allow-private --probe-only

The report keeps evidence and findings separate: scanners collect observations; pure rules classify structural failures and editorial quality issues. One failed scanner does not erase successful evidence from the others. JSON mode emits one versioned document on stdout, while diagnostics and artifact paths stay on stderr.

Use --probe-only when Lighthouse is unavailable or unnecessary. Private and reserved destinations are rejected unless --allow-private is explicit; that flag is intended for local development and CI fixtures.

Compare audit artifacts

Write timestamped reports from two revisions, then compare their semantic SEO outcomes without failing on timestamps, timing, or other raw scanner evidence:

seo audit https://example.com --output-dir .audit/before
# deploy or check out the next revision
seo audit https://example.com --output-dir .audit/after
seo diff .audit/before/<report>.json .audit/after/<report>.json
seo diff .audit/before/<report>.json .audit/after/<report>.json --json | jq

| Change | Outcome | Exit | | --- | --- | ---: | | Timestamp, warning text, or raw scanner evidence only | unchanged | 0 | | Editorial finding or improvement | changed | 0 | | Structural finding, scanner degradation, or lost coverage | regressed | 1 |

Inputs must satisfy the published AuditReport schema version 1. JSON mode emits a separately versioned audit-diff document even when a regression makes the command exit 1; operational and schema failures write only to stderr.

Raw scanner evidence remains in the source artifacts but is not interpreted by the generic comparator. Performance gates should be expressed as scanner findings with explicit thresholds rather than inferred from volatile Lighthouse measurements. Finding payloads (message, fix, observed, and expected) remain semantic: same-severity changes are reported as non-blocking changed outcomes.

Testing

Everything the CLI checks is a pure function you can call from a test:

import {
  checkGraph,
  hasStructuralViolations,
  inspectHtml,
} from "tanstack-plugin-seo";

expect(hasStructuralViolations(checkGraph(loadSeoGraph()))).toBe(false);

// render a page however you like, then assert the head it actually ships
const report = inspectHtml(url, 200, html);
expect(report.issues).toEqual([]);

License

MIT