Static Prerendering
Static Prerendering
Public, non-personalized pages are rendered to static HTML once at build time
and served straight off disk , zero React render cost per request, zero
database hit, faster than even ssr: 'data-only'. Currently covers /,
/pricing, /features, /changelog, /privacy, /tos, /license,
/login, /blog, every /blog/:slug post, /docs, and every /docs/:slug
page , including this one, once it's part of the docs/warpkit collection
this site's /docs renders from.
This doc covers what gets prerendered and when. For how the built server actually serves it (route-table structure, ETag/304, gzip, in-memory preloading), see static-asset-serving.md.
How it works
Two pieces have to agree, and both live outside src/ - except the one
thing they actually share, which now lives in exactly one place:
0. src/lib/prerendered-paths.ts , the flat list of exact-match static
paths, imported by both pieces below:
export const STATIC_HTML_PATHS = [
'/', '/pricing', '/features', '/changelog', '/contact',
'/privacy', '/tos', '/license', '/login', '/blog', '/docs'
] as const;
export const NON_HTML_PRERENDERED_PATHS = ['/robots.txt', '/sitemap.xml'] as const;
This exists because the flat path list used to be hand-duplicated between
vite.config.ts's filter and server/start.ts's PRERENDERED map - a
path in one but not the other failed silently (falls through to full SSR
every request, not a build error), which is exactly what broke
robots.txt/sitemap.xml before they were added to both. /blog/* and
/docs/* are deliberately excluded from this shared list: both files
already derive those independently from content-collections data
(crawlLinks vs allPosts/allDocsPages), never hand-listed, so they were
never the actual duplication.
1. vite.config.ts , the tanstackStart({ prerender: {...}, pages: [...] })
options tell the build which routes to render to HTML:
prerender: {
enabled: true,
filter: page =>
(STATIC_HTML_PATHS as readonly string[]).includes(page.path) ||
(NON_HTML_PRERENDERED_PATHS as readonly string[]).includes(page.path) ||
page.path.startsWith('/blog/') ||
page.path.startsWith('/docs/'),
crawlLinks: true // discovers /blog/$slug and /docs/$slug pages by parsing <a href> out of the rendered index pages
},
pages: NON_HTML_PRERENDERED_PATHS.map(path => ({ path, prerender: { enabled: true } }))
filter is an allowlist, not a denylist. TanStack's router-generator
auto-discovers every route with no $param segment as a candidate
(dashboard, admin, settings pages included), but filter is what actually
decides which candidates get rendered. Never loosen this to something
permissive like "any route outside _app" , it only takes one future
auth-gated static-shaped route to leak personalized HTML into a build
artifact served to everyone.
crawlLinks: true means blog posts and docs pages don't need a
hand-maintained path list: the prerenderer fetches /blog and /docs,
parses the links out of the rendered HTML, and queues each discovered page
for prerendering too , recursively, so a docs page only reachable from
another docs page's sidebar (not directly from /docs itself) still gets
found.
2. server/start.ts , the production entry point checks incoming
requests against a PRERENDERED map before the request ever reaches the SSR
handler:
const PRERENDERED: Record<string, string> = {
...Object.fromEntries(
STATIC_HTML_PATHS.map(urlPath => [
urlPath,
urlPath === '/' ? path.join(distClient, 'index.html') : prerenderedIndexHtml(urlPath.slice(1))
])
),
...Object.fromEntries(
allPosts.map(post => [`/blog/${post.slug}`, prerenderedIndexHtml(`blog/${post.slug}`)])
),
...Object.fromEntries(
allDocsPages
.filter(doc => doc.slug !== 'index')
.map(doc => [`/docs/${doc.slug}`, prerenderedIndexHtml(`docs/${doc.slug}`)])
)
};
Blog and docs entries are derived from allPosts/allDocsPages (the same
content-collections sources crawlLinks discovers pages from), not
hand-listed , the map can't drift from what actually exists.
docs/warpkit/index.md gets slug 'index' and is explicitly excluded from
the generated entries, since it's already covered by the /docs static
entry above , without the filter, both would try to claim the same URL. A
matching request with no session cookie gets Bun.file(htmlPath) directly;
everything else (a path not in the map, or a request carrying a session
cookie) falls through to the normal SSR handler.
Why authenticated requests always bypass it
Every prerendered path is served through the same cookie check. / and
/login genuinely need it: each has a beforeLoad that redirects an
already-logged-in visitor away (/ to /dashboard, /login the same) , the
static HTML can't do that itself, so a request carrying a session cookie
must fall through to real SSR to get the redirect. _auth.tsx's
beforeLoad gates that DB-backed check behind hasSessionCookie()
(src/lib/has-session-cookie.ts, shared with _landing.index.tsx) the same
way the homepage always has: skip the real bootstrap/session lookup
entirely for a visitor who obviously has no session, only pay for it when a
cookie is actually present. The other pages (/pricing, /blog, /docs/*,
etc.) have no session-dependent content at all, so gating them through the
same cookie check is free consistency, not a separate mechanism to reason
about.
Adding a new page to the allowlist
- Confirm the route has no auth-gated or per-user content , grep its route
file for
beforeLoad/loader/requireUser. If it reads anything from a session or the database on a per-visitor basis, it does not belong here. - Add the path to
STATIC_HTML_PATHSinsrc/lib/prerendered-paths.ts- one edit, read by bothvite.config.ts's filter andserver/start.ts'sPRERENDEREDmap, so there's nothing to keep in sync by hand anymore. make buildand check the log for[prerender] - /your-path, then confirmdist/client/your-path/index.htmlexists.- Boot the built server (
make start) and curl the route , a real page load, not a build-time crawl.
Non-HTML server routes: robots.txt and sitemap.xml
/robots.txt and /sitemap.xml (src/routes/robots[.]txt.ts,
sitemap[.]xml.ts) are prerendered too, but they're server routes -
server.handlers.GET with no component - not HTML pages, so they don't fit
the PRERENDERED/prerenderedIndexHtml mechanism described above:
autoStaticPathsDiscovery(the mechanism that finds prerender candidates automatically) only walks routes with acomponentprop (prerender-routes-plugin.tsin@tanstack/start-plugin-core). A route with no component - any server-only route - is invisible to it, so it must be added to an explicitpagesarray invite.config.ts'stanstackStart({...})options (a sibling ofprerender, not nested inside it), in addition tofilter(which still gates explicitly-listed pages too). Both are derived fromNON_HTML_PRERENDERED_PATHS(src/lib/prerendered-paths.ts), so a new non-HTML route only needs adding to that one array:pages: NON_HTML_PRERENDERED_PATHS.map(path => ({ path, prerender: { enabled: true } }))- Because their responses aren't
text/html, the prerender crawler writes them to the literal output path (dist/client/robots.txt,dist/client/sitemap.xml), not<path>/index.html- seeisImplicitHTMLin@tanstack/start-plugin-core'sprerender.ts. That means they're served byserver/start.ts's generic static-asset branch (MIME-by-extension), not thePRERENDEREDmap - thePRERENDEREDmap's hardcodedContent-Type: text/htmlwould be wrong for these. - The static-asset branch's default
Cache-Controlfor non-hashed files ispublic, max-age=0, must-revalidate. A smallSEO_CACHE_CONTROLoverride inserver/start.tspreserves the longer TTLs (/robots.txt→ 86400s,/sitemap.xml→ 3600s) the routes themselves set when SSR-rendered. - The route files stay (they're not deleted) -
bun run devstill serves them live via SSR, since prerendering only runs onvite build. Only production gets the prerendered fast path. Their actual content-building logic lives insrc/lib/seo-static.ts(buildRobotsTxt,buildSitemapXml) so it's unit-testable and shared between the route handler and its test.
This exists because both routes originally ran the full SSR pipeline on every request, including crawler hits - slow enough on a cold origin to time out Ahrefs's crawler against a live deployment.
What this is not
There's no revalidate-on-interval primitive (no ISR). Prerendered HTML goes
stale only on the next deploy, which is correct for git-backed content
(content-collections blog posts and this docs/warpkit collection) but
means a CMS with an independent publish cadence would need its own
webhook-triggered rebuild , nothing here does that automatically. See
docs/warpkit/performance.md for the load-test numbers
this trades against ssr: 'data-only' and full SSR.