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():
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 indist/client/as a tree of.htmlfiles.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:
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
lastmodorpriority, the built-in generator uses the same values for every URL, which matters for SEO sincelastmodtells crawlers when content actually changed. - Duplicate index route entries, routes like
/postsand/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:
{
"$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.htmlfrom served URLs. Without this, TanStack Start's static output would expose/posts/my-post.htmlinstead 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:
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:
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:
{
"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
Mar 2026
·5 min read
Mar 2026
·4 min read