Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2d76e05
feat: prerenderParams
nikuscs May 5, 2026
906d6ac
ci: apply automated fixes
autofix-ci[bot] May 5, 2026
8462744
fix: address prerender review feedback
nikuscs May 5, 2026
3393a2e
fix: stabilize prerender adapter builds
nikuscs May 5, 2026
4b9ef66
fix: isolate prerender route options bundle
nikuscs May 5, 2026
6c90512
fix: harden prerender route options bundling
nikuscs May 5, 2026
89360c2
test: assert prerender route options are stripped
nikuscs May 5, 2026
85df91d
fix: remove nitro route options special case
nikuscs May 5, 2026
b281ac9
test: cover sitemap host edge cases
nikuscs May 6, 2026
18f31e0
ci: apply automated fixes
autofix-ci[bot] May 6, 2026
db25629
fix: address prerender route option edge cases
nikuscs May 6, 2026
a5ef687
test: expand prerender params coverage and refactor sitemap build
nikuscs May 15, 2026
c7f30a7
fix: align prerender changes with current main
LadyBluenotes Jul 12, 2026
db5d576
chore: refresh prerender routes after rebase
LadyBluenotes Jul 19, 2026
8db1660
separate prerender route option build policies
LadyBluenotes Jul 19, 2026
864f714
skip route option loading for SPA prerendering
LadyBluenotes Jul 19, 2026
393a056
prune inline code for client-only routes
LadyBluenotes Jul 19, 2026
35e9489
invalidate server route trees when ssr changes
LadyBluenotes Jul 20, 2026
6fd0be8
notify plugins for root route changes
LadyBluenotes Jul 20, 2026
f8b9056
prune client-only routes from RSC server output
LadyBluenotes Jul 20, 2026
f15bd96
prune lazy routes from SSR-disabled server trees
LadyBluenotes Jul 20, 2026
ca1f6ec
update changeset
LadyBluenotes Jul 20, 2026
ecff9f8
Merge branch 'main' into feat-server-side-params
LadyBluenotes Jul 20, 2026
b522781
test: keep generator fixtures on one filesystem
LadyBluenotes Jul 20, 2026
41c777e
ci: apply automated fixes
autofix-ci[bot] Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tall-trees-prerender-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/router-generator': patch
'@tanstack/router-plugin': patch
'@tanstack/start-client-core': minor
'@tanstack/start-plugin-core': minor
'@tanstack/start-server-core': minor
---

Add typed route `prerenderParams`, dynamic sitemap generation, and server-bundle pruning for SSR-disabled routes.
51 changes: 51 additions & 0 deletions docs/start/framework/react/guide/static-prerendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export default defineConfig({
// Maximum number of redirects to follow during prerendering
maxRedirects: 5,

// Maximum time in milliseconds to wait for each prerenderParams callback
prerenderParamsTimeout: 30000,

// Build route options used for prerendering separately from the server bundle
separateRouteOptionsBundle: true,

// Fail if an error occurs during prerendering
failOnError: true,

Expand Down Expand Up @@ -135,6 +141,8 @@ export default defineConfig({

<!-- ::end:tabs -->

By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle.

## Automatic Static Route Discovery

All static paths will be automatically discovered and seamlessly merged with the specified `pages` config
Expand All @@ -147,6 +155,49 @@ Routes are excluded from automatic discovery in the following cases:

Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled.

## Dynamic Route Prerendering

Dynamic routes can declare `prerenderParams` to generate specific parameter values at build time. Each returned entry creates one page from the route path and can override sitemap or prerender options for that page.

```tsx
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
validateSearch: (search: Record<string, unknown>): { ref?: string } => ({
...(typeof search.ref === 'string' ? { ref: search.ref } : {}),
}),
sitemap: {
changefreq: 'weekly',
},
prerenderParams: async () => {
const posts = await fetchPosts()

return posts.map((post) => ({
params: { postId: post.id },
search: { ref: 'sitemap' },
sitemap: {
lastmod: post.updatedAt,
priority: 0.8,
},
}))
},
component: PostComponent,
})

function PostComponent() {
const { postId } = Route.useParams()

return <div>Post {postId}</div>
}
```

`prerenderParams` receives `{ routePath, signal }`. The signal aborts when the build process is interrupted and when `prerender.prerenderParamsTimeout` elapses. Each entry's `params` and optional `search` values are typed from the route and used to create the generated URL. Search params are preserved in generated page paths and sitemap URLs using the router's default search serialization; custom `stringifySearch` router options are not applied during this build-time expansion. The route-level `sitemap` option applies to every generated page, and `entry.sitemap` is merged on top for a specific parameter entry. Use `entry.sitemap.exclude` to generate the HTML page without adding it to the sitemap.

The `sitemap` route option only controls metadata for generated sitemap entries. It does not enable sitemap output by itself; sitemap XML is still controlled by the top-level `sitemap` configuration in your Start plugin config.

Code that is only referenced by `prerenderParams` or `sitemap` is removed from the client route bundle, so these options can import server-only data sources used to discover pages at build time.

## Crawling Links

When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well.
Expand Down
51 changes: 51 additions & 0 deletions docs/start/framework/solid/guide/static-prerendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export default defineConfig({
// Maximum number of redirects to follow during prerendering
maxRedirects: 5,

// Maximum time in milliseconds to wait for each prerenderParams callback
prerenderParamsTimeout: 30000,

// Build route options used for prerendering separately from the server bundle
separateRouteOptionsBundle: true,

// Fail if an error occurs during prerendering
failOnError: true,

Expand Down Expand Up @@ -139,6 +145,8 @@ export default defineConfig({

<!-- ::end:tabs -->

By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle.

Comment thread
nikuscs marked this conversation as resolved.
## Automatic Static Route Discovery

All static paths will be automatically discovered and seamlessly merged with the specified `pages` config
Expand All @@ -151,6 +159,49 @@ Routes are excluded from automatic discovery in the following cases:

Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled.

## Dynamic Route Prerendering

Dynamic routes can declare `prerenderParams` to generate specific parameter values at build time. Each returned entry creates one page from the route path and can override sitemap or prerender options for that page.

```tsx
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/solid-router'

export const Route = createFileRoute('/posts/$postId')({
validateSearch: (search: Record<string, unknown>): { ref?: string } => ({
...(typeof search.ref === 'string' ? { ref: search.ref } : {}),
}),
sitemap: {
changefreq: 'weekly',
},
prerenderParams: async () => {
const posts = await fetchPosts()

return posts.map((post) => ({
params: { postId: post.id },
search: { ref: 'sitemap' },
sitemap: {
lastmod: post.updatedAt,
priority: 0.8,
},
}))
},
component: PostComponent,
})

function PostComponent() {
const params = Route.useParams()

return <div>Post {params().postId}</div>
}
```

`prerenderParams` receives `{ routePath, signal }`. The signal aborts when the build process is interrupted and when `prerender.prerenderParamsTimeout` elapses. Each entry's `params` and optional `search` values are typed from the route and used to create the generated URL. Search params are preserved in generated page paths and sitemap URLs using the router's default search serialization; custom `stringifySearch` router options are not applied during this build-time expansion. The route-level `sitemap` option applies to every generated page, and `entry.sitemap` is merged on top for a specific parameter entry. Use `entry.sitemap.exclude` to generate the HTML page without adding it to the sitemap.

The `sitemap` route option only controls metadata for generated sitemap entries. It does not enable sitemap output by itself; sitemap XML is still controlled by the top-level `sitemap` configuration in your Start plugin config.

Code that is only referenced by `prerenderParams` or `sitemap` is removed from the client route bundle, so these options can import server-only data sources used to discover pages at build time.

## Crawling Links

When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well.
Expand Down
43 changes: 43 additions & 0 deletions e2e/react-start/basic/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/no
import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader'
import { Route as PostsIndexRouteImport } from './routes/posts.index'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug'
import { Route as RawStreamIndexRouteImport } from './routes/raw-stream/index'
import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call'
import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint'
Expand Down Expand Up @@ -71,6 +72,7 @@ import { Route as RedirectTargetViaBeforeLoadRouteImport } from './routes/redire
import { Route as RedirectTargetViaLoaderRouteImport } from './routes/redirect/$target/via-loader'
import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param'
import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search'
import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug'
import { Route as FooBarQuxHereRouteImport } from './routes/foo/$bar/$qux/_here'
import { Route as NotFoundDeepBCRouteRouteImport } from './routes/not-found/deep/b/c/route'
import { Route as RedirectTargetServerFnIndexRouteImport } from './routes/redirect/$target/serverFn/index'
Expand Down Expand Up @@ -233,6 +235,11 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({
path: '/$postId',
getParentRoute: () => PostsRoute,
} as any)
const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({
id: '/prerender-params/$slug',
path: '/prerender-params/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const RawStreamIndexRoute = RawStreamIndexRouteImport.update({
id: '/',
path: '/',
Expand Down Expand Up @@ -401,6 +408,12 @@ const SpecialCharsMalformedSearchRoute =
path: '/search',
getParentRoute: () => SpecialCharsMalformedRouteRoute,
} as any)
const LayoutLayout2PrerenderNestedSlugRoute =
LayoutLayout2PrerenderNestedSlugRouteImport.update({
id: '/prerender-nested/$slug',
path: '/prerender-nested/$slug',
getParentRoute: () => LayoutLayout2Route,
} as any)
const FooBarQuxHereRoute = FooBarQuxHereRouteImport.update({
id: '/foo/$bar/$qux/_here',
path: '/foo/$bar/$qux',
Expand Down Expand Up @@ -473,6 +486,7 @@ export interface FileRoutesByFullPath {
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
'/posts/$postId': typeof PostsPostIdRoute
'/prerender-params/$slug': typeof PrerenderParamsSlugRoute
'/raw-stream/client-call': typeof RawStreamClientCallRoute
'/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute
'/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute
Expand Down Expand Up @@ -508,6 +522,7 @@ export interface FileRoutesByFullPath {
'/not-found/parent-boundary/': typeof NotFoundParentBoundaryIndexRoute
'/redirect/$target/': typeof RedirectTargetIndexRoute
'/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren
'/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute
'/foo/$bar/$qux': typeof FooBarQuxHereRouteWithChildren
'/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute
'/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute
Expand Down Expand Up @@ -536,6 +551,7 @@ export interface FileRoutesByTo {
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
'/posts/$postId': typeof PostsPostIdRoute
'/prerender-params/$slug': typeof PrerenderParamsSlugRoute
'/raw-stream/client-call': typeof RawStreamClientCallRoute
'/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute
'/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute
Expand Down Expand Up @@ -570,6 +586,7 @@ export interface FileRoutesByTo {
'/not-found/parent-boundary': typeof NotFoundParentBoundaryIndexRoute
'/redirect/$target': typeof RedirectTargetIndexRoute
'/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren
'/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute
'/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute
'/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute
'/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute
Expand Down Expand Up @@ -607,6 +624,7 @@ export interface FileRoutesById {
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
'/posts/$postId': typeof PostsPostIdRoute
'/prerender-params/$slug': typeof PrerenderParamsSlugRoute
'/raw-stream/client-call': typeof RawStreamClientCallRoute
'/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute
'/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute
Expand Down Expand Up @@ -642,6 +660,7 @@ export interface FileRoutesById {
'/not-found/parent-boundary/': typeof NotFoundParentBoundaryIndexRoute
'/redirect/$target/': typeof RedirectTargetIndexRoute
'/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren
'/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute
'/foo/$bar/$qux/_here': typeof FooBarQuxHereRouteWithChildren
'/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute
'/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute
Expand Down Expand Up @@ -679,6 +698,7 @@ export interface FileRouteTypes {
| '/not-found/via-beforeLoad-target-root'
| '/not-found/via-loader'
| '/posts/$postId'
| '/prerender-params/$slug'
| '/raw-stream/client-call'
| '/raw-stream/ssr-binary-hint'
| '/raw-stream/ssr-mixed'
Expand Down Expand Up @@ -714,6 +734,7 @@ export interface FileRouteTypes {
| '/not-found/parent-boundary/'
| '/redirect/$target/'
| '/not-found/deep/b/c'
| '/prerender-nested/$slug'
| '/foo/$bar/$qux'
| '/redirect/$target/serverFn/via-beforeLoad'
| '/redirect/$target/serverFn/via-loader'
Expand Down Expand Up @@ -742,6 +763,7 @@ export interface FileRouteTypes {
| '/not-found/via-beforeLoad-target-root'
| '/not-found/via-loader'
| '/posts/$postId'
| '/prerender-params/$slug'
| '/raw-stream/client-call'
| '/raw-stream/ssr-binary-hint'
| '/raw-stream/ssr-mixed'
Expand Down Expand Up @@ -776,6 +798,7 @@ export interface FileRouteTypes {
| '/not-found/parent-boundary'
| '/redirect/$target'
| '/not-found/deep/b/c'
| '/prerender-nested/$slug'
| '/redirect/$target/serverFn/via-beforeLoad'
| '/redirect/$target/serverFn/via-loader'
| '/redirect/$target/serverFn/via-useServerFn'
Expand Down Expand Up @@ -812,6 +835,7 @@ export interface FileRouteTypes {
| '/not-found/via-beforeLoad-target-root'
| '/not-found/via-loader'
| '/posts/$postId'
| '/prerender-params/$slug'
| '/raw-stream/client-call'
| '/raw-stream/ssr-binary-hint'
| '/raw-stream/ssr-mixed'
Expand Down Expand Up @@ -847,6 +871,7 @@ export interface FileRouteTypes {
| '/not-found/parent-boundary/'
| '/redirect/$target/'
| '/not-found/deep/b/c'
| '/_layout/_layout-2/prerender-nested/$slug'
| '/foo/$bar/$qux/_here'
| '/redirect/$target/serverFn/via-beforeLoad'
| '/redirect/$target/serverFn/via-loader'
Expand Down Expand Up @@ -877,6 +902,7 @@ export interface RootRouteChildren {
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute
PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute
RedirectTargetRoute: typeof RedirectTargetRouteWithChildren
MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute
RedirectIndexRoute: typeof RedirectIndexRoute
Expand Down Expand Up @@ -1096,6 +1122,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PostsPostIdRouteImport
parentRoute: typeof PostsRoute
}
'/prerender-params/$slug': {
id: '/prerender-params/$slug'
path: '/prerender-params/$slug'
fullPath: '/prerender-params/$slug'
preLoaderRoute: typeof PrerenderParamsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/raw-stream/': {
id: '/raw-stream/'
path: '/'
Expand Down Expand Up @@ -1320,6 +1353,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SpecialCharsMalformedSearchRouteImport
parentRoute: typeof SpecialCharsMalformedRouteRoute
}
'/_layout/_layout-2/prerender-nested/$slug': {
id: '/_layout/_layout-2/prerender-nested/$slug'
path: '/prerender-nested/$slug'
fullPath: '/prerender-nested/$slug'
preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport
parentRoute: typeof LayoutLayout2Route
}
'/foo/$bar/$qux/_here': {
id: '/foo/$bar/$qux/_here'
path: '/foo/$bar/$qux'
Expand Down Expand Up @@ -1508,11 +1548,13 @@ const SpecialCharsRouteRouteWithChildren =
interface LayoutLayout2RouteChildren {
LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute
LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute
LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute
}

const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = {
LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute,
LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute,
LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute,
}

const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren(
Expand Down Expand Up @@ -1649,6 +1691,7 @@ const rootRouteChildren: RootRouteChildren = {
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute,
PrerenderParamsSlugRoute: PrerenderParamsSlugRoute,
RedirectTargetRoute: RedirectTargetRouteWithChildren,
MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute,
RedirectIndexRoute: RedirectIndexRoute,
Expand Down
11 changes: 11 additions & 0 deletions e2e/react-start/basic/src/routes/-prerender-params.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import '@tanstack/react-start/server-only'

export const SERVER_ONLY_PRERENDER_MARKER =
'server-only-prerender-marker-should-not-be-in-client'

export function getServerOnlyPrerenderSlug() {
return SERVER_ONLY_PRERENDER_MARKER.replace(
'server-only-prerender-marker-should-not-be-in-client',
'server-only-slug',
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute(
'/_layout/_layout-2/prerender-nested/$slug',
)({
prerenderParams: () => [{ params: { slug: 'under-layout' } }],
component: RouteComponent,
})

function RouteComponent() {
const params = Route.useParams()

return <div>Nested prerendered slug: {params.slug}</div>
}
Loading
Loading