tanstack-plugin-seo
v0.5.0
Published
SEO tooling for any website, with route-declared SEO and first-class TanStack Router support.
Maintainers
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["<head> meta · canonical<br/>Breadcrumbs · JSON-LD"]
gate["seoRouteConfig (vite)"] -. "fails undeclared pages" .-> routeInstall
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 | createSeo → seoHead, 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 checkTyped 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, arelatedtarget with nolinkcard, 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.txtStdout 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-onlyThe 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
