claude-start-cf

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 / createRootRoute

Every 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 meta

Open 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.ts

A 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.ts

A 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 extraction

Fetches 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 guide

Steps to add SEO to your TanStack Start app.

  1. Set defaults in root route

    charset, viewport, default title/description, OG site_name, canonical URL, stylesheet links.

  2. Add head() to content routes

    Use loaderData for dynamic titles, descriptions, OG tags, and JSON-LD structured data.

  3. Create sitemap.xml

    Static in public/ for simple sites, or a server route for dynamic content. Reference it from robots.txt.

  4. Create robots.txt

    Allow crawlers and point to your sitemap. Static file or server route.

  5. Verify with tools

    Google Search Console, Rich Results Test, OG Debugger, or the Head Inspector above.

FeatureWhereAPI
Meta tagshead() → metatitle, description, OG, twitter
Canonical URLhead() → links{ rel: "canonical", href }
JSON-LDhead() → scripts{ type: "application/ld+json" }
Sitemapserver route/sitemap.xml → XML response
robots.txtserver route/robots.txt → text response
SSRdefaultOn by default, ssr: false to disable