SEO
Technical SEO patterns using TanStack Start's head property on routes. SSR ensures crawlers receive fully rendered HTML with all meta tags.
Document head management
head() on createFileRoute / createRootRouteEvery route can define a head() function that returns meta tags, link tags, and scripts. Tags merge from root → parent → child, with child routes overriding parent values.
// Root route — sets defaults for all pages
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'claude-start-cf — TanStack Start + Cloudflare Template' },
{ name: 'description', content: 'A base template for...' },
{ property: 'og:site_name', content: 'claude-start-cf' },
],
links: [
{ rel: 'stylesheet', href: appCss },
{ rel: 'canonical', href: SITE_URL },
],
}),
})// Child route — overrides title and description
export const Route = createFileRoute('/')({
head: () => ({
meta: [
{ title: 'claude-start-cf — Home' },
{ name: 'description', content: 'Build full-stack apps on the edge...' },
],
}),
component: Home,
})The root route in this app sets charset, viewport, default title/description, OG site_name, and the canonical URL. Child routes only need to override what changes.
Open Graph & social sharing
head({ loaderData }) — dynamic metaOpen Graph tags control how pages appear when shared on social media. Use loaderData to generate dynamic tags from content.
// src/routes/blog/$slug.tsx
export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => renderPost({ data: { slug: params.slug } }),
head: ({ loaderData }) => ({
meta: [
{ title: `${loaderData.post.title} — claude-start-cf Blog` },
{ name: 'description', content: loaderData.post.description },
// Open Graph
{ property: 'og:title', content: loaderData.post.title },
{ property: 'og:description', content: loaderData.post.description },
{ property: 'og:type', content: 'article' },
// Twitter Card
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:title', content: loaderData.post.title },
],
links: [
{ rel: 'canonical', href: `${SITE_URL}/blog/${loaderData.post.slug}` },
],
}),
})Live example: /blog/hello-world has dynamic OG tags generated from its frontmatter. View source to see the rendered meta tags in the HTML.
Structured data (JSON-LD)
head() → scripts: [{ type: 'application/ld+json' }]JSON-LD structured data helps search engines understand your content and can enable rich results. Added via the scripts array in head().
head: ({ loaderData }) => ({
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: loaderData.post.title,
description: loaderData.post.description,
author: loaderData.post.authors.map((name) => ({
'@type': 'Person',
name,
})),
datePublished: loaderData.post.published,
}),
},
],
})The blog post routes in this app include Article structured data. Use the Rich Results Test to validate your implementation.
Dynamic sitemap
src/routes/sitemap[.]xml.tsA server route that generates a sitemap from static routes + content-collections posts. The bracket-dot syntax creates a route with a literal period in the URL.
// src/routes/sitemap[.]xml.ts
import { allPosts } from 'content-collections'
const staticRoutes = [
{ path: '/', changefreq: 'weekly', priority: '1.0' },
{ path: '/blog', changefreq: 'weekly', priority: '0.8' },
// ...other routes
]
export const Route = createFileRoute('/sitemap.xml')({
server: {
handlers: {
GET: async () => {
const postUrls = allPosts.map((post) => `
<url>
<loc>${SITE_URL}/blog/${post.slug}</loc>
<lastmod>${post.published}</lastmod>
</url>`)
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticRoutes.map(r => `<url><loc>${SITE_URL}${r.path}</loc></url>`).join('\n')}
${postUrls.join('\n')}
</urlset>`
return new Response(sitemap, {
headers: { 'Content-Type': 'application/xml' },
})
},
},
},
})robots.txt
src/routes/robots[.]txt.tsA server route that generates robots.txt dynamically. For most sites a static file in public/ works fine, but a server route lets you vary rules by environment.
// src/routes/robots[.]txt.ts
export const Route = createFileRoute('/robots.txt')({
server: {
handlers: {
GET: async () => {
const robots = `User-agent: *
Allow: /
Sitemap: ${SITE_URL}/sitemap.xml`
return new Response(robots, {
headers: { 'Content-Type': 'text/plain' },
})
},
},
},
})Head inspector
Live SSR head extractionFetches a page from this app via a server function and extracts the <head> HTML to show what crawlers see. Try different paths to see how meta tags change per route.
SEO checklist
Implementation guideSteps to add SEO to your TanStack Start app.
- Set defaults in root route
charset, viewport, default title/description, OG site_name, canonical URL, stylesheet links.
- Add head() to content routes
Use loaderData for dynamic titles, descriptions, OG tags, and JSON-LD structured data.
- Create sitemap.xml
Static in public/ for simple sites, or a server route for dynamic content. Reference it from robots.txt.
- Create robots.txt
Allow crawlers and point to your sitemap. Static file or server route.
- Verify with tools
Google Search Console, Rich Results Test, OG Debugger, or the Head Inspector above.
| Feature | Where | API |
|---|---|---|
| Meta tags | head() → meta | title, description, OG, twitter |
| Canonical URL | head() → links | { rel: "canonical", href } |
| JSON-LD | head() → scripts | { type: "application/ld+json" } |
| Sitemap | server route | /sitemap.xml → XML response |
| robots.txt | server route | /robots.txt → text response |
| SSR | default | On by default, ssr: false to disable |