Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/tall-trees-prerender-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/start-client-core': minor
'@tanstack/start-plugin-core': minor
'@tanstack/start-server-core': minor
---

Add typed route `prerenderParams` support for generating dynamic prerender pages at build time.
46 changes: 46 additions & 0 deletions docs/start/framework/react/guide/static-prerendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ 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,

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

Expand Down Expand Up @@ -81,6 +84,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
46 changes: 46 additions & 0 deletions docs/start/framework/solid/guide/static-prerendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ 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,

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

Expand Down Expand Up @@ -81,6 +84,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 @@ -47,6 +47,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss
import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed'
import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint'
import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call'
import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader'
import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root'
Expand Down Expand Up @@ -75,6 +76,7 @@ import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './rout
import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader'
import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad'
import { Route as FooBarQuxHereRouteImport } from './routes/foo/$bar/$qux/_here'
import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug'
import { Route as NotFoundDeepBCRouteRouteImport } from './routes/not-found/deep/b/c/route'
import { Route as FooBarQuxHereIndexRouteImport } from './routes/foo/$bar/$qux/_here/index'
import { Route as NotFoundDeepBCDRouteImport } from './routes/not-found/deep/b/c/d'
Expand Down Expand Up @@ -271,6 +273,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({
path: '/client-call',
getParentRoute: () => RawStreamRoute,
} as any)
const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({
id: '/prerender-params/$slug',
path: '/prerender-params/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const PostsPostIdRoute = PostsPostIdRouteImport.update({
id: '/$postId',
path: '/$postId',
Expand Down Expand Up @@ -423,6 +430,12 @@ const FooBarQuxHereRoute = FooBarQuxHereRouteImport.update({
path: '/foo/$bar/$qux',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutLayout2PrerenderNestedSlugRoute =
LayoutLayout2PrerenderNestedSlugRouteImport.update({
id: '/prerender-nested/$slug',
path: '/prerender-nested/$slug',
getParentRoute: () => LayoutLayout2Route,
} as any)
const NotFoundDeepBCRouteRoute = NotFoundDeepBCRouteRouteImport.update({
id: '/c',
path: '/c',
Expand Down Expand Up @@ -465,6 +478,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 @@ -500,6 +514,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 @@ -527,6 +542,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 @@ -561,6 +577,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 @@ -597,6 +614,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 @@ -632,6 +650,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 @@ -668,6 +687,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 @@ -703,6 +723,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 @@ -730,6 +751,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 @@ -764,6 +786,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 @@ -799,6 +822,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 @@ -834,6 +858,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 @@ -863,6 +888,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 @@ -1138,6 +1164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RawStreamClientCallRouteImport
parentRoute: typeof RawStreamRoute
}
'/prerender-params/$slug': {
id: '/prerender-params/$slug'
path: '/prerender-params/$slug'
fullPath: '/prerender-params/$slug'
preLoaderRoute: typeof PrerenderParamsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/posts/$postId': {
id: '/posts/$postId'
path: '/$postId'
Expand Down Expand Up @@ -1334,6 +1367,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FooBarQuxHereRouteImport
parentRoute: typeof rootRouteImport
}
'/_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
}
'/not-found/deep/b/c': {
id: '/not-found/deep/b/c'
path: '/c'
Expand Down Expand Up @@ -1487,11 +1527,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 @@ -1627,6 +1669,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