SEO · Open source
Start SEO Kit
Typed SEO helpers for TanStack Start: one head() call for title, canonical, Open Graph, Twitter, robots and hreflang tags, a sitemap and robots.txt served from server routes, typed JSON-LD with Product schema, and an optional Open Graph image route.
What it solves
TanStack Start gives every route a head() option and lets you add server routes, but the SEO itself is up to you.
Each route ends up with a hand-written meta array where the canonical link, og:url and image URLs have to be made absolute by hand and kept in step with the title. Start's own sitemap support is tied to prerendering, so an app that renders pages on request has no sitemap.xml unless you write the XML yourself. JSON-LD is a loose object, and nothing tells you that a product's price has a currency symbol in it or that its rating has no count.
defineSeo() takes your site defaults once (URL, name, title template, default image, Twitter handle, locale, trailing-slash rule) and returns a seo() function for route head(). Each call returns { meta, links } in the shapes TanStack Router renders: the templated title, description, robots, a canonical link, hreflang alternates, Open Graph and article tags, Twitter card tags and JSON-LD, with every page and image URL made absolute. createSitemap() and createRobotsHandler() return GET handlers for Start server routes, so sitemap.xml and robots.txt are built per request and work when nothing is prerendered; past 50,000 URLs the sitemap turns into a sitemap index with numbered child files. The commerce entry point builds Product JSON-LD with Offer or AggregateOffer, ratings, reviews, shipping and return policy, the Open Graph product:* tags, and checkProduct(), which lists errors and warnings against Google's product documentation without throwing. The og entry point renders 1200x630 PNG share images with satori and resvg-wasm, which are optional peers. Tested with @tanstack/react-start 1.169.3 and @tanstack/react-router 1.170.45 (React) on Node in dev and in a production build, and from a fresh npm install. An independent package, not made or endorsed by TanStack.
- defineSeo() and seo(): title templates, description, canonical, Open Graph, Twitter cards, robots, hreflang alternates, article tags and JSON-LD from one typed call in head()
- Page and image URLs resolved against the site URL, with your router's trailing-slash rule applied to page URLs
- createSitemap(): sitemap.xml from a Start server route, plain urlset up to 50,000 URLs, then a sitemap index of per-source child files; array and function sources load once per request, and paginated sources let each child sitemap load only its own range
- routesFromTree() lists the parameter-free page routes of your route tree; entries support lastmod, changefreq, priority, hreflang alternates and images
- createRobotsHandler() and robotsTxt(), with a per-request config so preview deployments can disallow every crawler
- jsonLd builders for Organization, WebSite with a search action, Article and BlogPosting, BreadcrumbList, FAQPage, Person and @graph
- commerce entry: Product, Offer, AggregateOffer, AggregateRating, Review, shipping and return-policy JSON-LD, product:* Open Graph tags, productSeo(), productSitemapEntries() and checkProduct()
- og entry: createOgImageHandler(), renderOgImage() and a ready-made ogCard() design on satori and @resvg/resvg-wasm, both optional peers
- No runtime dependencies, ESM only, marked side-effect free; Node 20 or later
Install
Requires TanStack Start (React).
npm install @amjadiqbal/start-seo-kit
Usage
// src/seo.ts
import { defineSeo, jsonLd } from '@amjadiqbal/start-seo-kit';
export const seo = defineSeo({
url: 'https://acme.dev',
name: 'Acme',
titleTemplate: '%s | Acme',
image: { url: '/og.png', width: 1200, height: 630, alt: 'Acme' },
twitter: { site: '@acme' },
});
// src/routes/posts/$slug.tsx
export const Route = createFileRoute('/posts/$slug')({
loader: ({ params }) => fetchPost(params.slug),
head: ({ loaderData: post }) =>
seo({
title: post?.title, // <title>Hello | Acme</title>, og:title Hello
description: post?.excerpt,
path: `/posts/${post?.slug}`, // canonical and og:url: https://acme.dev/posts/hello
article: { publishedTime: post?.publishedAt },
jsonLd: jsonLd.article({ type: 'BlogPosting', headline: post?.title ?? '' }),
}),
});
Setup
Install the package in a TanStack Start app. The core has no runtime dependencies and declares no peer dependency on TanStack. It ships as an ES module only and needs Node 20 or later. satori and @resvg/resvg-wasm are optional peers, needed only for the Open Graph image route.
- 1 npm install @amjadiqbal/start-seo-kit
- 2 Only for the OG image route: npm install satori @resvg/resvg-wasm
- 3 Create src/seo.ts with defineSeo({ url, name, titleTemplate, ... }) and export the result.
- 4 Call seo({ ... }) from route head() functions.
- 5 Add server routes for /sitemap.xml, /sitemaps/$name and /robots.txt with the handlers from createSitemap() and createRobotsHandler().
Meta tags with seo()
defineSeo(config) normalizes the site URL (it must be an absolute http or https URL, or it throws) and returns seo(input), plus seo.site and seo.url(path), which gives the absolute URL of a page path. seo() returns { meta, links }, so you can spread it and add your own scripts or styles. There is also a standalone seo({ siteUrl, ... }) export without a site config; with no siteUrl it throws on any relative URL.
- Title: the page title goes through titleTemplate, where every %s is replaced. A per-page titleTemplate overrides the site one, and titleTemplate: false uses the title as is. With no title, defaultTitle is used without the template.
- og:title and twitter:title use the page title without the template, or socialTitle if you set it.
- Canonical: canonical, or path when canonical is not set, resolved to an absolute URL with the trailing-slash rule (never by default, always if you set it). Absolute URLs are kept as given. canonical: false drops the link. og:url uses the same URL.
- hreflang: alternates: { en: '/en/about', 'x-default': '/about' } adds one <link rel="alternate" hreflang> per entry, resolved the same way as the canonical.
- Robots: robots takes a string or { index, follow, noarchive, nosnippet, noimageindex, notranslate, maxSnippet, maxImagePreview, maxVideoPreview, unavailableAfter }. noindex: true gives noindex, follow unless you set follow: false.
- Image: a string or { url, width, height, alt, type }; adds og:image (plus og:image:secure_url for https), its size, type and alt, and twitter:image. image: false drops the site default.
- og:type is website, or article when article is set (published, modified and expiration times, author, section). twitter:card defaults to summary_large_image when there is an image and summary otherwise; twitter site and creator merge with the site config.
- jsonLd takes one object or an array; meta and links take extra tags, appended as given.
How Router merges the tags
TanStack Router merges head() from every matched route. For meta, the deepest route wins per name or property and anything a child does not set is inherited. Links are not merged: every matched route's links are rendered.
- Call seo() in the root route for site-wide defaults without path, and pass path only in leaf routes, so each page has one canonical link.
- If the root sets an image with width and height and a child sets another image without them, the child inherits the root's dimensions. Give child images their size, or set images only in leaf routes.
- Router keeps one tag per property, so the builder emits a single og:image and a single article:author.
Structured data
The jsonLd builders return plain schema.org objects with @context and drop undefined fields. seo({ jsonLd }) puts each one in head().meta as { 'script:ld+json': object }. The kit does not serialize or escape them itself: TanStack Router's React adapter writes each as <script type="application/ld+json"> with JSON.stringify and replaces <, >, & and the U+2028 and U+2029 separators with \u escapes, so text such as </script> in a description cannot end the tag.
- jsonLd.organization({ name, url, logo, sameAs, email, telephone, type }), where type can be a subtype such as LocalBusiness.
- jsonLd.website({ name, url, searchUrlTemplate, publisher, inLanguage }) adds a SearchAction when searchUrlTemplate is set.
- jsonLd.article({ type, headline, url, image, datePublished, dateModified, author, publisher, keywords }) for Article, BlogPosting, NewsArticle or TechArticle. A string author or publisher becomes { @id } when it is a URL and a named Person or Organization otherwise. Dates become ISO strings.
- jsonLd.breadcrumbs(items), jsonLd.faq(items) and jsonLd.person(input).
- jsonLd.graph(...nodes) combines nodes into one @graph document and drops their own @context.
seo({
title: 'About',
path: '/about',
jsonLd: [
jsonLd.organization({ name: 'Acme', url: 'https://acme.dev', logo: 'https://acme.dev/logo.png' }),
jsonLd.breadcrumbs([{ name: 'Home', url: 'https://acme.dev/' }, { name: 'About' }]),
],
});
Product pages
Import from @amjadiqbal/start-seo-kit/commerce. productSeo(product) returns { type: 'product', meta, jsonLd } to spread into seo(). The JSON-LD is a Product with brand, gtin, sku, mpn, category, colour, size, material and product group ID, and offers as one Offer, an array of Offers, or an AggregateOffer when you pass { lowPrice, highPrice, currency, offerCount }.
- Offer: price, currency, availability and condition as schema.org URLs, priceValidUntil as a date, seller, shippingDetails and hasMerchantReturnPolicy.
- Shipping: rate, currency, one or more countries, optional regions, and handling and transit days as [min, max] business-day ranges.
- Return policy: { country, category: 'finite' | 'unlimited' | 'notPermitted', days, fees, method, url } or just { url }. fees is free, customer or { amount, currency }.
- rating becomes AggregateRating; reviews become Review nodes with a Person author, a rating (best defaults to 5) and pros and cons as positiveNotes and negativeNotes lists.
- Open Graph: product:price:amount and product:price:currency from the first offer (lowPrice for a range), product:availability, product:condition for a single Offer, product:brand, product:retailer_item_id from sku and product:item_group_id.
- productJsonLd(), offerLd(), aggregateOfferLd(), aggregateRatingLd(), reviewLd(), shippingLd() and returnPolicyLd() return the single nodes, and jsonLd is re-exported for breadcrumbs.
- productSitemapEntries(items, { path, lastmod, images, changefreq, priority }) maps a catalogue to sitemap entries with image tags.
head: ({ loaderData: p }) =>
seo({
title: p.name,
path: `/p/${p.slug}`,
image: p.images[0],
...productSeo({
name: p.name,
image: p.images,
sku: p.sku,
brand: p.brand,
offers: { price: 119.99, currency: 'USD', availability: 'InStock', condition: 'NewCondition' },
rating: { value: 4.6, reviewCount: 38 },
}),
}),
Checking products with checkProduct()
checkProduct(product, { merchantListing?: boolean }) returns { errors, warnings } and never throws, so it can run in a unit test over your whole catalogue. The rules follow Google's product snippet and merchant listing documentation as read on 2026-10-22. It only checks what can be checked offline. Passing it does not mean Google will show a rich result; Google decides that.
- Errors: an empty name; none of offers, rating or reviews; with merchantListing, no image or no offers.
- Errors: a price, low or high price or shipping rate that is negative or written with a currency symbol or thousands separator; a currency that is not three capital letters.
- Errors: a shipping currency different from the offer's; shipping with neither handlingDays nor transitDays; a finite return window without days.
- Errors: a rating with neither count nor reviewCount, or a value outside worst (default 1) and best (default 5); a review with an empty author.
- Warnings: an offer without availability; ratings or reviews without offers; an AggregateOffer when merchantListing is set; a review with exactly one pro or con; no brand; none of gtin, mpn or sku; no description.
for (const p of await listProducts()) {
expect(checkProduct(toProductInput(p), { merchantListing: true }).errors).toEqual([]);
}
Sitemap
createSitemap({ siteUrl, sources }) returns index and page handlers for Start server routes and an entries() helper. Each named source is an array of entries, a function returning them (sync or async), or a paginated source with count() and entries({ offset, limit }). Because the XML is built per request, it works for apps that are not prerendered.
- Auto mode: while the total stays at or under maxUrlsPerSitemap (default 50,000), /sitemap.xml is one <urlset>. Above it, /sitemap.xml becomes a <sitemapindex> listing /sitemaps/<source>-1.xml, -2.xml and so on, split per source, with at least one file per source. index: true always serves an index; index: false throws once the total goes over the limit.
- The page handler reads the file name from the $name param, returns that slice of the source, and returns 404 for an unknown source or an empty chunk after the first. Page 1 of an empty source is an empty <urlset>, so links from the index never break.
- Array and function sources are loaded in full once per request: once for /sitemap.xml and once for each child sitemap, which keeps only its own chunk. For a paginated source, an index calls only count() (and lastmod() if set) and each child sitemap calls entries({ offset, limit }) for its own range; when everything fits in one <urlset>, /sitemap.xml loads all of its entries. A paginated source's lastmod() is used as the lastmod of its index entries.
- Entries take loc, lastmod, changefreq, priority (clamped to 0 to 1), hreflang alternates written as xhtml:link, and images written as image:image. Locations are made absolute with the trailing-slash rule and XML-escaped.
- routesFromTree(routeTree, { exclude, defaults }) walks the route tree and keeps routes without $ or {} parameters, skipping the root, server-only routes, paths with a file extension and routes with staticData: { sitemap: false }. staticData.sitemap can set per-route values such as priority.
- Use a function source for routesFromTree (pages: () => routesFromTree(routeTree)): the sitemap module is imported by a route in routeTree.gen, so reading the tree at import time is a circular import, and the error message says so.
- Responses are application/xml with Cache-Control public, max-age=3600 by default. sitemapXml() and sitemapIndexXml() are exported if you build the response yourself.
export const sitemap = createSitemap({
siteUrl: 'https://acme.dev',
sources: {
pages: () => routesFromTree(routeTree, { exclude: ['/login', /^\/admin/] }),
posts: { count: () => db.posts.count(), entries: ({ offset, limit }) => db.posts.page(offset, limit) },
},
});
// src/routes/sitemap[.]xml.ts
export const Route = createFileRoute('/sitemap.xml')({ server: { handlers: { GET: sitemap.index } } });
// src/routes/sitemaps/$name.ts
export const Route = createFileRoute('/sitemaps/$name')({ server: { handlers: { GET: sitemap.page } } });
robots.txt
createRobotsHandler(config) returns a GET handler; config can be an object or a function of the request, so it can depend on the host. Without rules it allows everything for every user agent. disallowAll: true replaces the rules with User-agent: * and Disallow: /. Each rule takes userAgent, allow, disallow and crawlDelay. Sitemap paths are made absolute with siteUrl, and a relative sitemap without siteUrl throws. The response is text/plain with Cache-Control public, max-age=3600. A static public/robots.txt is served before the route, so remove it.
GET: createRobotsHandler(({ request }) => ({
siteUrl: 'https://acme.dev',
disallowAll: new URL(request.url).hostname !== 'acme.dev',
rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api'] }],
sitemaps: ['/sitemap.xml'],
}))
Open Graph images (optional)
Import from @amjadiqbal/start-seo-kit/og and install satori and @resvg/resvg-wasm. createOgImageHandler({ fonts, render }) returns a GET handler: render receives the request, params and URL and returns an element, or null for a 404. satori needs at least one TTF, OTF or WOFF font (not WOFF2); a fonts function runs once and is cached. The handler renders a 1200x630 PNG with Cache-Control public, max-age=86400, immutable by default.
- ogCard({ title, description, eyebrow, footer, fontFamily, background, foreground, muted, accent }) is a ready-made card as a plain element tree, so no JSX is needed. The title size steps down for long titles.
- On Node the resvg .wasm file is read from node_modules. Elsewhere, pass the wasm option (bytes, a Response, a WebAssembly.Module, a URL or a function returning one); that path is not tested.
- format: 'svg' skips resvg and returns SVG; width, height and cacheControl can be changed.
- renderOgImage(element, options) renders outside a route, for example at build time.
const og = createOgImageHandler({
fonts: async () => [{ name: 'Inter', weight: 700, data: await readFile('src/assets/Inter-Bold.ttf') }],
render: async ({ params }) => {
const post = await fetchPost(params.slug);
return post ? ogCard({ title: post.title, eyebrow: 'Acme blog', footer: 'acme.dev', fontFamily: 'Inter' }) : null;
},
});
export const Route = createFileRoute('/og/$slug')({ server: { handlers: { GET: og } } });
Compatibility and testing
Tested with @tanstack/react-start 1.169.3 and @tanstack/react-router 1.170.45 (React) on Node, in dev and in a production build, plus a fresh install from npm. Not tested: Solid Start, and edge runtimes for the OG image route. The package declares no TanStack peer dependency because it imports nothing from TanStack: it returns plain objects and Response objects. The server-route helpers need Start's server.handlers route option; older versions have not been tried. The commerce output follows Google's documentation, but Google decides whether a page gets a rich result, and no live indexing has been tested.
- Real app: a TanStack Start 1.169.3 app (React, Vite, Node) installing the packed package, checked in a browser in dev and after vite build and vite preview: server-rendered head tags on several route types, tags updating on client-side navigation with one canonical link per page, a </script> string in JSON-LD staying inside its script tag, the sitemap index and child sitemaps, robots.txt and OG image PNGs.
- 46 unit tests (Vitest) cover tag output, URL handling, sitemap XML escaping, chunking, the index and how often each source is loaded per request, route-tree walking, robots rules, the commerce schema, the checkProduct rules and a real PNG rendered through satori and resvg-wasm.
- A type test assigns seo() output to a Router route's head() and the handlers to server routes with no casts.
- Solid Start and other adapters: no tests of any kind. The meta shapes are the ones Router renders, but escaping of JSON-LD is done by the adapter, and only the React adapter has been checked.
- OG image route on edge or other non-Node runtimes: not tested.
Credits
Open Graph images are rendered with satori (Vercel) and resvg-wasm, which you install yourself as optional peers. Start SEO Kit is an independent package and is not affiliated with or endorsed by TanStack or Google.
Hire me
Need this built into your app, or something custom?
I wrote Start SEO Kit. I can wire it into your TanStack app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.
Support
Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.
More TanStack packages
All packages
Query Key Kit
Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.
Table URL State
Keep a TanStack Table v9 table's sorting, column filters, global filter, pagination and column visibility in the URL search params, in both directions, and export the current view to CSV or, from a separate entry point, to XLSX.
AI SDK Bridge
Use Vercel AI SDK provider models (@ai-sdk/* and community providers, provider spec v2, v3 and v4) as TanStack AI 1.x adapters for chat, embeddings, transcription, speech, image generation and rerank.
shopify-app-tanstack-start
Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.