Writing

Deploying a static TanStack Start app to Vercel

How to configure TanStack Start's static prerender target and deploy it correctly to Vercel, including the non-obvious vercel.json settings that make routing work.

23 Feb 2026

·

5 min read

·
TanStack StartVercelDeployment

Share

I recently moved a portfolio site over to TanStack Start and wanted to deploy it as a fully static site on Vercel. The appeal is straightforward, the build output is plain HTML, CSS, and JS, so it can go on any static host (e.g., Vercel, Netlify, Cloudflare Pages, S3) and costs next to nothing compared to a server-based approach. Getting the configuration right had a few non-obvious steps though, so I wanted to document what I ran into.

Tested with TanStack Start 1.163.2 and Vercel's static output target as of early 2026.

Configuring prerendering in vite.config.ts

TanStack Start's Vite plugin exposes a prerender option directly on tanstackStart():

vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";

export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        enabled: true,
        crawlLinks: true,
      },
    }),
  ],
});
  • enabled: true, switches the build target from server to static. Output lands in dist/client/ as a tree of .html files.
  • crawlLinks: true, after rendering the entry point the build follows every internal <a href> it finds, rendering each discovered page. You don't have to maintain a list of routes, the crawler finds them automatically.

I found the crawler behaviour has a useful side effect: a broken internal link fails the build. I actually had a stale /rss.xml link in the footer that would have been a silent 404 in production, and the build caught it. Fix the link (or remove it), and the build passes. I think this is one of the underrated benefits of the crawl approach.

Sitemap config

The plugin has a built-in sitemap option:

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

I started with this but switched to a custom server route. Two things pushed me off it:

  • No per-URL lastmod or priority, the built-in generator uses the same values for every URL, which matters for SEO since lastmod tells crawlers when content actually changed.
  • Duplicate index route entries, routes like /posts and /posts/ both appear as separate entries, I think this is probably a bug in TanStack Start's sitemap generator.

I've covered the full implementation in a separate post on custom sitemaps in TanStack Start.

Configuring vercel.json

This was the part that tripped me up the most. Vercel's automatic framework detection won't pick up TanStack Start's static output correctly out of the box. A vercel.json at the repo root fixes it:

vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "installCommand": "bun install",
  "buildCommand": "bun run build",
  "outputDirectory": "dist/client",
  "cleanUrls": true,
  "trailingSlash": false,
  "headers": [
    {
      "source": "/assets/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

The critical field is outputDirectory. TanStack Start builds static output to dist/client/, not dist/. Vercel defaults to dist/, so without this override it looks in the wrong folder and the deploy either fails or serves a blank page. I spent a bit of time debugging this before I realised what was happening.

I've also set installCommand and buildCommand explicitly for the same reason. Vercel supports Bun, but being explicit means a future change to Vercel's detection logic can't break your builds quietly.

trailingSlash: false avoids duplicate content, which is covered more in the clean URLs section below.

The headers block sets Cache-Control: public, max-age=31536000, immutable on everything under /assets/. TanStack Start's Vite build content-hashes every asset filename (e.g. archivo-latin-wght-normal-aBc123.woff2), so these files are safe to cache forever. If the content changes, the filename changes. Without this header Vercel serves assets without long-lived caching and browsers refetch them on every visit.

Disabling trailing slashes and enabling clean URLs

"Clean URLs" means serving /posts/my-post instead of /posts/my-post.html or /posts/my-posts/. This requires matching config in both the vercel.json and the TanStack Router setup.

In vercel.json:

  • cleanUrls: true, which strips .html from served URLs. Without this, TanStack Start's static output would expose /posts/my-post.html instead of /posts/my-post.
  • trailingSlash: false, tells Vercel to redirect /posts/ to /posts. Without it, both URLs serve the same page and you end up with duplicate canonical paths.

On the TanStack Router side, set trailingSlash: "never" in the router config:

src/router.tsx
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

export function getRouter() {
  const router = createRouter({
    routeTree,
    trailingSlash: "never",
  });

  return router;
}

Doing this has a benefit to SEO, as it avoids potential duplicate content.

404 page

TanStack Router has a defaultNotFoundComponent option for rendering 404s, but it doesn't work with static hosting. Setting it throws an Invariant failed error at runtime. The same error appears if you try adding a /404 route, which is how Vercel normally expects a custom 404 page.

The workaround is a splat route combined with an SPA fallback rewrite. Create src/routes/$.tsx:

src/routes/$.tsx
import { createFileRoute } from "@tanstack/react-router";
import { NotFound } from "@/components/not-found";

export const Route = createFileRoute("/$")({
  component: NotFound,
});

Then add a catch-all rewrite to vercel.json so unmatched paths fall through to the app shell:

vercel.json
{
  "rewrites": [{ "source": "/(.*)", "destination": "/" }]
}

The splat route /$ catches any path the router doesn't recognise and renders the NotFound component. The rewrite ensures Vercel doesn't return its own 404 before the app has a chance to handle the request.

Continue reading