Writing

Replacing TanStack Start's built-in sitemap with a custom server route

How I replaced the auto-generated sitemap in TanStack Start with a hand-rolled server route to get real lastmod dates and per-URL priority control.

15 Mar 2026

·

5 min read

·
TanStack StartSEO

Share

I recently wrote about deploying a static TanStack Start app to Vercel, including how to configure the built-in sitemap generation. The built-in approach worked, but I kept running into limitations that made me want more control. Specifically, I wanted real lastmod dates pulled from each post's frontmatter and the ability to set different priority values per URL. The built-in sitemap generator doesn't support either of those, so I replaced it with a custom server route.

Tested with TanStack Start 1.163.2 and content-collections as of March 2026.

What the built-in sitemap gives you

TanStack Start's Vite plugin has a sitemap option that generates a sitemap.xml at build time from the prerendered route tree:

vite.config.ts
tanstackStart({
  prerender: {
    enabled: true,
    crawlLinks: true,
  },
  sitemap: {
    enabled: true,
    host: process.env.VITE_DOMAIN,
  },
}),

This produces a valid sitemap with every prerendered URL listed. The problem is that every entry gets the same treatment: there's no way to set lastmod per URL, no priority field, and no changefreq. For a blog where post dates matter for freshness signals, that's a meaningful gap.

I also ran into a duplicate-entry bug where index routes like /posts/ and /posts both appeared in the sitemap. I'd been working around that with per-page sitemap: { exclude: true } overrides, which was getting unwieldy:

vite.config.ts
pages: [
  // Bug workaround: trailing-slash variants of index routes end up as
  // separate sitemap entries alongside the non-slash versions.
  // https://github.com/TanStack/router/issues/6978
  { path: "/posts/", sitemap: { exclude: true } },
  { path: "/guides/", sitemap: { exclude: true } },
],

Between the missing fields and the workarounds, it felt like the right time to take over sitemap generation entirely.

The custom server route approach

TanStack Start supports server routes, which are route files that define raw HTTP handlers instead of React components. A file at src/routes/sitemap[.]xml.ts (the brackets escape the dot so TanStack Router treats it as a literal .xml extension) handles GET /sitemap.xml and returns whatever Response you want.

The full implementation:

src/routes/sitemap[.]xml.ts
import { createFileRoute } from "@tanstack/react-router";

import { DOMAIN } from "@/constants";
import { getAllPosts } from "@/lib/posts";

function formatDate(date: Date): string {
  return date.toISOString().split("T")[0];
}

const staticPages = [
  { path: "/", priority: "1.0" },
  { path: "/posts", priority: "0.8" },
  { path: "/experience", priority: "0.7" },
];

export const Route = createFileRoute("/sitemap.xml")({
  server: {
    handlers: {
      GET: async () => {
        const today = formatDate(new Date());
        const posts = await getAllPosts({ data: {} });

        const urls = [
          ...staticPages.map(
            ({ path, priority }) => `  <url>
    <loc>${DOMAIN}${path}</loc>
    <lastmod>${today}</lastmod>
    <priority>${priority}</priority>
  </url>`,
          ),
          ...posts.map(
            (post) => `  <url>
    <loc>${DOMAIN}/posts/${post.slug}</loc>
    <lastmod>${formatDate(post.date)}</lastmod>
    <priority>0.7</priority>
  </url>`,
          ),
        ];

        const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join("\n")}
</urlset>`;

        return new Response(xml, {
          headers: {
            "Content-Type": "application/xml; charset=utf-8",
          },
        });
      },
    },
  },
});

A few things to call out:

  • lastmod comes from the post's frontmatter date, not the build timestamp. This means Google sees the actual publish date for each post rather than using today's date for every entry.
  • Priority is explicit per page. The homepage gets 1.0, the posts index gets 0.8, individual posts get 0.7, and lower-priority static pages get 0.7 too. Adjust these to reflect what you actually want Google to prioritise.
  • Draft posts are already filtered out by the getAllPosts helper, so no extra filtering is needed in the sitemap route.
  • The duplicate-entry bug disappears. Since I'm building the URL list from content-collections data rather than the route tree, the trailing-slash issue doesn't apply.

Disabling the built-in sitemap

With the custom route in place, the Vite config gets much simpler. I disabled the built-in sitemap and added the new route to the explicit prerender list so it gets rendered at build time:

vite.config.ts
tanstackStart({
  prerender: {
    enabled: true,
    crawlLinks: true,
  },
  pages: ["/robots.txt", "/llms.txt", "/rss.xml", "/sitemap.xml"].map(
    (path) => ({
      path,
      prerender: { enabled: true },
    })
  ),
  sitemap: {
    enabled: false,
  },
}),

The pages array shrank from a list of per-page overrides to a single .map() call. All the sitemap: { exclude: true } workarounds are gone because the built-in sitemap generator isn't running at all.

Gotchas

I ran into a couple of things worth noting.

The bracket escaping in the filename

The route file is named sitemap[.]xml.ts, not sitemap.xml.ts. TanStack Router uses dots in filenames as path separators (like $slug.tsx), so without the brackets it would try to parse xml as a route segment. The [.] syntax tells the router to treat the dot as a literal character.

Prerendering the server route

Server routes aren't automatically prerendered by crawlLinks because there's no <a href="/sitemap.xml"> in the rendered HTML for the crawler to follow. You need to add it to the pages array explicitly with prerender: { enabled: true }. Without this, the sitemap would only exist at runtime on a server, which defeats the purpose for a statically deployed site.

Wrapping up

The main benefit of this approach is that the sitemap now reflects real content metadata instead of being a flat list of URLs. If you're running a content-heavy TanStack Start site and want lastmod dates or per-URL priorities, a custom server route is a clean way to get there without needing a workaround for each edge case.

Continue reading