-
Notifications
You must be signed in to change notification settings - Fork 29.9k
docs: dynamic routes w/ gsP #86402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
icyJoseph
wants to merge
10
commits into
canary
Choose a base branch
from
docs/dynamic-routes-gsp-route-handlers
base: canary
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+232
−2
Draft
docs: dynamic routes w/ gsP #86402
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
c7079c9
docs: dynamic routes w/ gsP
icyJoseph ee9d413
docs: fix headings in dynamic routes
icyJoseph a3a65f4
docs: simplify snippets and use RouteContext
icyJoseph b08fa94
docs: dynamic routes tweaks
icyJoseph 5bed7a3
docs: fix await slug into resolvedSlug
icyJoseph c27ad1f
docs: blog post IDs 1,2, and 3
icyJoseph bbdfa07
docs: gsp w/ cache components
icyJoseph ffb03b7
docs: sign post to dynamic routes from CC getting started
icyJoseph 134b325
docs: keep cache components info groupped
icyJoseph 69d1d15
docs: gsp explicit static shell generation and prefetching
icyJoseph File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,6 +145,270 @@ export default async function Page(props: PageProps<'/[locale]'>) { | |
| - Since the `params` prop is a promise. You must use `async`/`await` or React's use function to access the values. | ||
| - In version 14 and earlier, `params` was a synchronous prop. To help with backwards compatibility, you can still access it synchronously in Next.js 15, but this behavior will be deprecated in the future. | ||
|
|
||
| ### With Cache Components | ||
|
|
||
| When using [Cache Components](/docs/app/getting-started/cache-components) with dynamic routes, params are runtime data that require special handling. During prerendering, Next.js doesn't know which params users will request (like `/blog/hello-world` or `/blog/123`), so you need to provide fallback UI using `<Suspense>` boundaries. | ||
|
|
||
| You should use [`generateStaticParams`](/docs/app/api-reference/functions/generate-static-params) to prerender your most popular routes at build time (e.g., `/blog/1`, `/blog/2`, `/blog/3`). This serves two purposes: it validates your route doesn't incorrectly access dynamic APIs like `cookies()` or `headers()`, and it creates static HTML files for instant loading of those specific routes. Any other routes will render on-demand when requested at runtime. | ||
|
|
||
| > **Good to know**: You can check the `X-Nextjs-Cache` response header to verify your caching strategy. It will show `HIT` or `MISS` for disk-cached routes, and won't appear for memory-only caching. | ||
|
|
||
| The sections below show different patterns, from simplest (all runtime) to most optimized (prerendered samples + caching). | ||
|
|
||
| #### Without `generateStaticParams` | ||
|
|
||
| The simplest approach. All params are runtime data. | ||
|
|
||
| **Properties:** | ||
|
|
||
| - **Build time**: No validation, no prerendering | ||
| - **Prerendered params**: None | ||
| - **Runtime params**: Reading params requires Suspense fallback UI (build fails without it), shell renders on-demand, UI updates when content resolves | ||
| - **Caching**: Memory only (cleared on server restart) | ||
|
|
||
| ```tsx filename="app/blog/[slug]/page.tsx" | ||
| import { Suspense } from 'react' | ||
|
|
||
| export default async function Page({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }> | ||
| }) { | ||
| return ( | ||
| <div> | ||
| <h1>Blog Post</h1> | ||
| <Suspense fallback={<div>Loading...</div>}> | ||
| <Content slug={params.then((p) => p.slug)} /> | ||
| </Suspense> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| async function Content({ slug }: { slug: Promise<string> }) { | ||
| const { slug: resolvedSlug } = await slug | ||
| return <article>{/* Your content */}</article> | ||
| } | ||
| ``` | ||
|
|
||
| #### With `generateStaticParams` + `<Suspense>` | ||
|
|
||
| Good for frequently updating content where you don't need disk persistence. | ||
icyJoseph marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| **Properties:** | ||
|
|
||
| - **Build time**: Validates route, prerenders samples (e.g., `/1`, `/2`, `/3`) | ||
| - **Prerendered params**: Instant - served from disk | ||
| - **Runtime params**: Shell renders immediately, UI updates when content resolves | ||
| - **Caching**: Memory only (cleared on server restart) | ||
|
|
||
| ```tsx filename="app/blog/[slug]/page.tsx" | ||
| import { Suspense } from 'react' | ||
|
|
||
| export async function generateStaticParams() { | ||
| return [{ slug: '1' }, { slug: '2' }, { slug: '3' }] | ||
| } | ||
|
|
||
| export default async function Page({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }> | ||
| }) { | ||
| return ( | ||
| <div> | ||
| <h1>Blog Post</h1> | ||
| <Suspense fallback={<div>Loading...</div>}> | ||
| <Content slug={params.then((p) => p.slug)} /> | ||
| </Suspense> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| async function Content({ slug }: { slug: Promise<string> }) { | ||
| const { slug: resolvedSlug } = await slug | ||
| return <article>{/* Your content */}</article> | ||
| } | ||
| ``` | ||
|
|
||
| #### With `generateStaticParams` + `<Suspense>` + `use cache` on page | ||
|
|
||
| Recommended for most use cases. Provides immediate UI for prerendered and previously visited routes, with disk persistence similar to ISR. | ||
icyJoseph marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| **Properties:** | ||
|
|
||
| - **Build time**: Validates route, prerenders samples | ||
| - **Prerendered params**: Instant - served from disk | ||
| - **Runtime params**: Shell renders immediately, UI updates when content resolves | ||
| - **Caching**: Disk (persists across server restarts) | ||
|
|
||
| ```tsx filename="app/blog/[slug]/page.tsx" | ||
| import { Suspense } from 'react' | ||
| import { cacheLife } from 'next/cache' | ||
|
|
||
| export async function generateStaticParams() { | ||
| return [{ slug: '1' }, { slug: '2' }, { slug: '3' }] | ||
| } | ||
|
|
||
| export default async function Page({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }> | ||
| }) { | ||
| 'use cache' | ||
| cacheLife('days') | ||
| return ( | ||
| <div> | ||
| <h1>Blog Post</h1> | ||
| <Suspense fallback={<div>Loading...</div>}> | ||
| <Content slug={params.then((p) => p.slug)} /> | ||
| </Suspense> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| async function Content({ slug }: { slug: Promise<string> }) { | ||
| const { slug: resolvedSlug } = await slug | ||
| return <article>{/* Your content */}</article> | ||
| } | ||
| ``` | ||
|
|
||
| #### With `generateStaticParams` + `use cache` on page (no Suspense) | ||
|
|
||
| Full page waits but cached to disk. Good for less-frequently-changing content. | ||
icyJoseph marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| **Properties:** | ||
|
||
|
|
||
| - **Build time**: Validates route, prerenders samples | ||
| - **Prerendered params**: Instant - served from disk | ||
| - **Runtime params**: Full page waits to render, then cached to disk | ||
| - **Caching**: Disk (persists across server restarts) | ||
|
|
||
| ```tsx filename="app/blog/[slug]/page.tsx" | ||
| import { cacheLife } from 'next/cache' | ||
|
|
||
| export async function generateStaticParams() { | ||
| return [{ slug: '1' }, { slug: '2' }, { slug: '3' }] | ||
| } | ||
|
|
||
| export default async function Page({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }> | ||
| }) { | ||
| 'use cache' | ||
| cacheLife('days') | ||
| const { slug } = await params | ||
| return <div>{/* Full page content */}</div> | ||
| } | ||
| ``` | ||
|
|
||
| #### Parent Suspense boundaries override disk caching | ||
|
|
||
| When you have a parent Suspense boundary that wraps the page, it takes precedence over page-level `use cache` for runtime params. This happens with [`loading.tsx`](/docs/app/api-reference/file-conventions/loading) or when a layout wraps children in a Suspense boundary to defer to request time. This means: | ||
|
|
||
| - **Build-time params** (from `generateStaticParams`) are still fully prerendered and cached to disk | ||
| - **Runtime params** will always use the parent fallback UI and stream, with memory caching only | ||
| - No disk files are created for runtime params, even with `use cache` on the page | ||
| - The `X-Nextjs-Cache` header will not appear for runtime params | ||
|
|
||
| ```tsx filename="app/blog/[slug]/loading.tsx" | ||
| export default function Loading() { | ||
| return <div>Loading post...</div> | ||
| } | ||
| ``` | ||
|
|
||
| ```tsx filename="app/blog/[slug]/page.tsx" | ||
| export async function generateStaticParams() { | ||
| return [{ slug: '1' }, { slug: '2' }, { slug: '3' }] | ||
| } | ||
|
|
||
| export default async function Page({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }> | ||
| }) { | ||
| 'use cache' // This will be overridden by parent loading.tsx for runtime params | ||
| const { slug } = await params | ||
| return <div>Content for {slug}</div> | ||
| } | ||
| ``` | ||
|
|
||
| In this example: | ||
|
|
||
| - `/blog/1`, `/blog/2`, `/blog/3` are fully cached to disk at build time | ||
| - `/blog/10` (runtime param) shows `loading.tsx` fallback, then streams the page content with memory caching only | ||
|
|
||
| If you need disk caching for runtime params, avoid parent Suspense boundaries and use page-level `use cache` with direct param access (no Suspense in the page). | ||
|
|
||
| #### Recommended pattern | ||
|
|
||
| For the most flexible approach that provides full page ISR for runtime params: | ||
|
|
||
| 1. **Place static UI in the layout** - Content that doesn't depend on params goes in the layout, which is always part of the static shell | ||
| 2. **Use `use cache` on the page component** - This enables disk caching for the full page (persists across server restarts) | ||
| 3. **Read params within a Suspense boundary** - This enables streaming and provides good UX with fallback UI | ||
|
|
||
| ```tsx filename="app/blog/[slug]/layout.tsx" | ||
| export default function Layout({ children }: { children: React.ReactNode }) { | ||
| return ( | ||
| <div> | ||
| {/* Static navigation - always in the shell */} | ||
| <nav> | ||
| <a href="/">Home</a> | <a href="/blog">Blog</a> | ||
| </nav> | ||
| <main>{children}</main> | ||
| </div> | ||
| ) | ||
| } | ||
| ``` | ||
|
|
||
| ```tsx filename="app/blog/[slug]/page.tsx" | ||
| import { Suspense } from 'react' | ||
| import { cacheLife } from 'next/cache' | ||
|
|
||
| export async function generateStaticParams() { | ||
| return [{ slug: '1' }, { slug: '2' }, { slug: '3' }] | ||
| } | ||
|
|
||
| export default async function Page({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }> | ||
| }) { | ||
| 'use cache' | ||
| cacheLife('days') | ||
| return ( | ||
| <div> | ||
| <h1>Blog Post</h1> | ||
| <Suspense fallback={<div>Loading content...</div>}> | ||
| <BlogContent slug={params.then((p) => p.slug)} /> | ||
| </Suspense> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| async function BlogContent({ slug }: { slug: Promise<string> }) { | ||
| const resolvedSlug = await slug | ||
| const response = await fetch(`https://api.vercel.app/blog/${resolvedSlug}`) | ||
| const data = await response.json() | ||
| return ( | ||
| <article> | ||
| <h2>{data.title}</h2> | ||
| <p>{data.content}</p> | ||
| </article> | ||
| ) | ||
| } | ||
| ``` | ||
|
|
||
| This pattern gives you: | ||
|
|
||
| - **Static shell** from layout and page-level elements outside Suspense | ||
| - **Efficient client-side navigation** - Static layout UI is reused when navigating between different params (e.g., `/blog/1` to `/blog/2`), only the page content re-renders | ||
| - **Streaming with Suspense** for dynamic content (good UX) | ||
| - **Full page ISR** - Runtime params generate `.html` files cached to disk (persists across server restarts) | ||
| - **Immediate UI** for prerendered and previously visited routes | ||
| - **Build-time prerendering** for sampled params (from `generateStaticParams`) | ||
| - **On-demand rendering and caching** for runtime params | ||
|
|
||
| ## Examples | ||
|
|
||
| ### With `generateStaticParams` | ||
|
|
@@ -172,3 +436,56 @@ export async function generateStaticParams() { | |
| ``` | ||
|
|
||
| When using `fetch` inside the `generateStaticParams` function, the requests are [automatically deduplicated](/docs/app/guides/caching#request-memoization). This avoids multiple network calls for the same data Layouts, Pages, and other `generateStaticParams` functions, speeding up build time. | ||
|
|
||
| ### Dynamic GET Route Handlers with `generateStaticParams` | ||
|
|
||
| `generateStaticParams` also works with dynamic [Route Handlers](/docs/app/api-reference/file-conventions/route) to statically generate API responses at build time: | ||
|
|
||
| ```ts filename="app/api/posts/[id]/route.ts" switcher | ||
| export async function generateStaticParams() { | ||
| const posts = await fetch('https://api.vercel.app/blog').then((res) => | ||
| res.json() | ||
| ) | ||
|
|
||
| return posts.map((post: { id: number }) => ({ | ||
| id: String(post.id), | ||
| })) | ||
| } | ||
|
|
||
| export async function GET( | ||
| request: Request, | ||
| { params }: { params: Promise<{ id: string }> } | ||
| ) { | ||
| const { id } = await params | ||
| const posts = await fetch('https://api.vercel.app/blog').then((res) => | ||
| res.json() | ||
| ) | ||
| const post = posts.find((p: { id: number }) => p.id === Number(id)) | ||
|
|
||
| return Response.json(post) | ||
| } | ||
| ``` | ||
|
|
||
| ```js filename="app/api/posts/[id]/route.js" switcher | ||
| export async function generateStaticParams() { | ||
| const posts = await fetch('https://api.vercel.app/blog').then((res) => | ||
| res.json() | ||
| ) | ||
|
|
||
| return posts.map((post) => ({ | ||
| id: String(post.id), | ||
| })) | ||
| } | ||
|
|
||
| export async function GET(request, { params }) { | ||
| const { id } = await params | ||
| const posts = await fetch('https://api.vercel.app/blog').then((res) => | ||
| res.json() | ||
| ) | ||
| const post = posts.find((p) => p.id === Number(id)) | ||
|
|
||
| return Response.json(post) | ||
| } | ||
| ``` | ||
|
|
||
| In this example, the route handlers for all blog post IDs (1-25) returned by `generateStaticParams` will be statically generated at build time. Requests to other IDs will be handled dynamically at request time. | ||
icyJoseph marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.