Skip to main content
Next.js · lesson 7 of 11
Next.js intermediate Lesson 7 of 11

Dynamic Routes and generateStaticParams

A Next.js 16 [slug] route awaits params as a Promise; generateStaticParams prerenders known paths, and Cache Components rejects dynamicParams: false.

By SkillByExample · Published

nextjs dynamic-routes generatestaticparams cache-components

Dynamic by Default: Rendering Modes showed connection() and cookies() failing a build outside <Suspense> as request APIs. params belongs to that same request-API category — distinct from the uncached data category fetch() falls into, which Caching with “use cache” covers separately. This lesson builds a [slug] dynamic segment and watches that rule apply to params specifically: reading it fails the build on its own, generateStaticParams is the fix that’s unique to a dynamic route, and the route table ends up showing exactly which slugs got prerendered and which didn’t. The project setup reuses the Docker pattern from the first lesson — the node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.

params Fails the Build the Same Way connection() and cookies() Do

Five files set up the project: package.json, next.config.ts, tsconfig.json, and app/layout.tsx are unchanged from the earlier lessons; app/page.tsx carries a new comment, since this lesson’s new route is /posts/[slug].

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "20.19.43",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
  title: "Next App",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
// app/page.tsx — a plain static home page; this lesson's new route is /posts/[slug]
export default function HomePage() {
  return <h1>Home</h1>;
}

app/posts/[slug]/page.tsx awaits params — typed as a Promise, per Next.js 16 — and looks the resolved slug up in a small in-memory table. No generateStaticParams yet. Predict whether this builds at all, given that Dynamic by Default: Rendering Modes already showed connection() and cookies() failing the same way outside <Suspense>:

// app/posts/[slug]/page.tsx — no generateStaticParams yet
type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return <p>Post: {title}</p>;
}

npm install is shown here, not hidden, so a reader running the same two commands sees the same install happen — though its exact duration will differ from the one below:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 28 packages in 45s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 263ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 12.0s
  Running TypeScript ...
  Finished TypeScript in 6.0s ...
Error: Route "/posts/[slug]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
    at body (<anonymous>)
    at html (<anonymous>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
  - Start the app in development mode by running `next dev`, then open "/posts/[slug]" in your browser to investigate the error.
  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.
Error occurred prerendering page "/posts/[slug]". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /posts/[slug]/page: /posts/[slug], exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

The exact same generic error Dynamic by Default: Rendering Modes showed for connection() — and the fixed list it prints literally includes params alongside fetch(...), cookies(), and headers(). A dynamic segment’s params is request-specific data too; Cache Components doesn’t treat it any differently just because it came from the URL instead of a header.

generateStaticParams Prerenders a Known Set

The [cache]/[stream]/[block] fixes the error lists aren’t the only option for a dynamic segment specifically: generateStaticParams tells Next.js exactly which slug values exist ahead of time, so it can prerender each one to real static HTML instead of treating every params read as unknowable:

// app/posts/[slug]/page.tsx — generateStaticParams supplies the known slugs
type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return <p>Post: {title}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 136ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.4s
  Running TypeScript ...
  Finished TypeScript in 5.4s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
└   /posts/[slug]
  ├ ◐ /posts/[slug]
  ├ ○ /posts/hello-world
  └ ○ /posts/second-post


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

The build succeeds now, and the table grows a nested shape that no earlier lesson’s table has shown: /posts/[slug] itself gets ◐ — a shell for any slug not in the known set — and the two slugs generateStaticParams actually returned each get their own ○ row underneath, fully prerendered as real static HTML files, /posts/hello-world and /posts/second-post.

An Unlisted Slug Still Renders — On Demand

Start the app and request a known slug, then a slug generateStaticParams never returned:

$ docker run -d --name nextjs-dynroutes-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
19ba020d68cb003761557013bed348ab27c873b5fa8afd90e1a0ef881510d029
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -D - -o /dev/null http://localhost:3100/posts/hello-world | grep -iE '^HTTP/|^cache-control|^x-nextjs'
HTTP/1.1 200 OK
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=31536000
$ curl -s -D - -o /dev/null http://localhost:3100/posts/not-a-real-post | grep -iE '^HTTP/|^cache-control|^x-nextjs'
HTTP/1.1 200 OK
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
$ curl -s http://localhost:3100/posts/not-a-real-post | grep -oE '<p>Post: [^/]+/p>'
<p>Post: <!-- -->Unknown post</p>
$ docker rm -f nextjs-dynroutes-1
nextjs-dynroutes-1

Both return 200, but the headers tell two different stories. /posts/hello-world carries x-nextjs-cache: HIT and a year-long Cache-Control: s-maxage=31536000 — the exact static file the build already produced. /posts/not-a-real-post carries x-nextjs-postponed: 1 instead, and Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate — the ◐ shell’s dynamic hole ran for real, on this request, computing "Unknown post" from scratch. This is dynamicParams’s default of true: an unlisted slug isn’t an error, it’s just rendered the slow way instead of the fast way.

Forgetting await Fails at Compile Time

params is typed Promise<Params>, not Params — indexing into it directly, without await, is a type error, not a runtime one. Predict what the build reports:

// app/posts/[slug]/page.tsx — params read without await
type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const title = posts[params.slug] ?? "Unknown post";
  return <p>Post: {title}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 132ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.6s
  Running TypeScript ...
app/posts/[slug]/page.tsx(14,30): error TS2339: Property 'slug' does not exist on type 'Promise<Params>'.
Failed to type check.

error TS2339: Property 'slug' does not exist on type 'Promise<Params>' — TypeScript itself catches this, during Running TypeScript, before Next.js ever gets to prerendering a single route. Restore the await:

// app/posts/[slug]/page.tsx — await restored
type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return <p>Post: {title}</p>;
}

dynamicParams Doesn’t Mix With Cache Components

The pre-Cache-Components App Router idiom for “never render an unlisted param, 404 it instead” is a route segment config, export const dynamicParams = false. Predict whether it still works under Cache Components:

// app/posts/[slug]/page.tsx — the older dynamicParams flag
type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export const dynamicParams = false;

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return <p>Post: {title}</p>;
}

A real terminal shows the lines below in color — this build’s code frame is Turbopack’s own, and NO_COLOR/FORCE_COLOR=0/TERM=dumb don’t turn it off, so the command strips the ANSI codes visibly instead:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 108ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./app/posts/[slug]/page.tsx:13:14
Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.
  11 | }
  12 |
> 13 | export const dynamicParams = false;
     |              ^^^^^^^^^^^^^
  14 |
  15 | export default async function PostPage({ params }: { params: Promise<Params> }) {
  16 |   const { slug } = await params;

Ecmascript file had an error


    at ignore-listed frames

Next.js’s own error names the flag and the fix: "dynamicParams" is not compatible with nextConfig.cacheComponents. Please remove it. Under Cache Components, every unlisted slug goes through the same dynamic-by-default mechanism the previous section showed, whether a route wants that or not — there’s no build-time flag to turn that off. The correct way to make an unlisted slug behave like “not found” is in the route’s own code, with notFound():

// app/posts/[slug]/page.tsx — notFound() instead of dynamicParams: false
import { notFound } from "next/navigation";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug];
  if (!title) notFound();
  return <p>Post: {title}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 92ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.8s
  Running TypeScript ...
  Finished TypeScript in 2.5s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
└   /posts/[slug]
  ├ ◐ /posts/[slug]
  ├ ○ /posts/hello-world
  └ ○ /posts/second-post


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker run -d --name nextjs-dynroutes-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
1c9e80858bb26a05f6dc0e75253b77b18a5d4eb69758f78dfaf17c6a24a4efb9
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3100/posts/not-a-real-post
200
$ curl -s http://localhost:3100/posts/not-a-real-post > /tmp/nf.html; grep -oE 'This page could not be found\.' /tmp/nf.html
This page could not be found.
This page could not be found.
$ grep -oE '<meta name="robots"[^/]+/>' /tmp/nf.html
<meta name="robots" content="noindex"/>
$ docker rm -f nextjs-dynroutes-2
nextjs-dynroutes-2

The build succeeds, and the saved response really does carry Next’s own “This page could not be found.” text for /posts/not-a-real-post — but the status line curl reports is 200, not 404. The ◐ shell already committed a 200 status to the response before the streamed, notFound()-triggering part of the page ever ran; a response can’t rewrite a status line it already sent. notFound() changes what a visitor sees, but not, by itself, the status code a monitoring tool or a crawler would read from this particular route shape.

The grep prints the phrase twice because two different pieces of Next.js’s own built-in not-found UI both match it: the page’s <title>, which reads “404: This page could not be found.”, and the visible <h2>, which reads exactly “This page could not be found.” — one match each, not one element repeated. Neither is plain HTML text in this response, though; /posts/not-a-real-post ships a client-rendered error shell with an empty <body>, and both strings live inside the same <script> tag’s React Server Components payload, the data a browser uses to build and hydrate the page. What is plain HTML, and new on this route specifically, is <meta name="robots" content="noindex"> in <head> — none of this lesson’s known-slug pages carry it; Next.js adds it to a notFound() response so a crawler that lands on an unlisted slug directly is told not to index it.

Practice

Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order.

1. Add a third known slug, "third-post", to the posts table, and have generateStaticParams return it first in its list, ahead of "hello-world" and "second-post" — a non-alphabetical order on purpose. Confirm whether the route table’s nested rows follow that returned order or sort alphabetically regardless.

Solution
// app/posts/[slug]/page.tsx — a third known slug, listed first on purpose
import { notFound } from "next/navigation";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
  "third-post": "Third Post",
};

export async function generateStaticParams() {
  return [{ slug: "third-post" }, { slug: "hello-world" }, { slug: "second-post" }];
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug];
  if (!title) notFound();
  return <p>Post: {title}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 72ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 1859ms
  Running TypeScript ...
  Finished TypeScript in 2.5s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
└   /posts/[slug]
  ├ ◐ /posts/[slug]
  ├ ○ /posts/third-post
  ├ ○ /posts/hello-world
  └ ○ /posts/second-post


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

/posts/third-post lands as the first nested row, not the last — the table lists these three slugs in exactly the order generateStaticParams returned them, third-post first, not alphabetically (which would put hello-world first) and not in the order their keys appear in the posts object (which no longer matches the returned array at all). A dynamic segment’s nested rows follow generateStaticParams’s own return order. Practice 2 below confirms the same rule a second way, with the catch-all route’s /docs/intro landing before the alphabetically-earlier /docs/guide/install.

2. Add a catch-all segment, app/docs/[...slug]/page.tsx, whose generateStaticParams returns two multi-part paths, and confirm the table shows nested paths with more than one segment.

Solution
// app/docs/[...slug]/page.tsx — a catch-all segment, slug is an array
type Params = { slug: string[] };

const docs: Record<string, string> = {
  "intro": "Introduction",
  "guide/install": "Installation Guide",
};

export async function generateStaticParams() {
  return [{ slug: ["intro"] }, { slug: ["guide", "install"] }];
}

export default async function DocPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const path = slug.join("/");
  const title = docs[path] ?? "Unknown doc";
  return <p>Doc: {title}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 81ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.6s
  Running TypeScript ...
  Finished TypeScript in 4.3s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├   /docs/[...slug]
│ ├ ◐ /docs/[...slug]
│ ├ ○ /docs/intro
│ └ ○ /docs/guide/install
└   /posts/[slug]
  ├ ◐ /posts/[slug]
  ├ ○ /posts/third-post
  ├ ○ /posts/hello-world
  └ ○ /posts/second-post


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

[...slug] collects every segment after /docs/ into an array — generateStaticParams returning { slug: ["guide", "install"] } prerenders /docs/guide/install as one static row, two URL segments deep, right alongside the single-segment /docs/intro. The same ◐ shell row still covers every path the catch-all’s generateStaticParams didn’t list.

3. Remove generateStaticParams from app/posts/[slug]/page.tsx entirely, but keep the build working by moving the params read into a child component wrapped in <Suspense> — the [stream] fix from Dynamic by Default: Rendering Modes — and confirm no slug gets its own static row anymore.

Solution
// app/posts/[slug]/page.tsx — no generateStaticParams; params read behind Suspense instead
import { Suspense } from "react";
import { notFound } from "next/navigation";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
  "third-post": "Third Post",
};

async function Post({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug];
  if (!title) notFound();
  return <p>Post: {title}</p>;
}

export default function PostPage({ params }: { params: Promise<Params> }) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Post params={params} />
    </Suspense>
  );
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 106ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.4s
  Running TypeScript ...
  Finished TypeScript in 2.7s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├   /docs/[...slug]
│ ├ ◐ /docs/[...slug]
│ ├ ○ /docs/intro
│ └ ○ /docs/guide/install
└   /posts/[slug]
  └ ◐ /posts/[slug]


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

/posts/[slug] collapses to a single ◐ row with no static children at all — <Suspense> satisfies Cache Components’ rule the same generic way it does for any other request-specific read, but without generateStaticParams there’s no known set of slugs left to prerender, so every single request, known slug or not, now renders on demand behind the shell.

4. Restore generateStaticParams on app/posts/[slug]/page.tsx, but make it return an empty array, []. Predict whether Cache Components treats “zero known slugs” the same as “no generateStaticParams at all.”

Solution
// app/posts/[slug]/page.tsx — generateStaticParams returns an empty array
import { notFound } from "next/navigation";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
  "third-post": "Third Post",
};

export async function generateStaticParams() {
  return [];
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug];
  if (!title) notFound();
  return <p>Post: {title}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 69ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.6s
  Running TypeScript ...
  Finished TypeScript in 2.7s ...
Error: When using Cache Components, all `generateStaticParams` functions must return at least one result. This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.

Learn more: https://nextjs.org/docs/messages/empty-generate-static-params
    at <unknown> (app/posts/[slug]/page.tsx:13:10)
  11 |
  12 | export async function generateStaticParams() {
> 13 |   return [];
     |          ^
  14 | }
  15 |
  16 | export default async function PostPage({ params }: { params: Promise<Params> }) {

> Build error occurred
Error: Failed to collect page data for /posts/[slug]
    at ignore-listed frames {
  type: 'Error'
}

A different, more specific error than the one earlier in this lesson — not the generic uncached-data message, but one naming generateStaticParams directly: it has to return at least one result under Cache Components, specifically so Next.js has a real page it can use to validate there’s no other dynamic access hiding in the same route. An empty list isn’t treated as “no generateStaticParams” (Practice 3’s case, which built fine with <Suspense>) — it’s treated as a configuration mistake and fails the build outright.

Frequently Asked Questions

Why is params a Promise in Next.js 16 instead of a plain object?
Cache Components treats every request-specific input the same way — cookies(), headers(), searchParams, and params are all async, so a route can prerender a static shell around them and resolve the real value only once an actual request streams in. A plain synchronous object couldn't support that: the shell has to be producible before any request exists, and params for an unlisted slug doesn't exist yet at that point.
Why did reading params fail the build before generateStaticParams was added, even though nothing called fetch, cookies(), or headers()?
params triggers the same generic prerender check fetch, cookies, headers, searchParams, and connection() all do — the build's own error message names all five together, since all of them can block a static shell from completing. But params itself is a request API, the same category cookies(), headers(), and connection() belong to, not the uncached-data category fetch() and database reads fall into; [Caching with "use cache"](/nextjs/caching-with-use-cache/) covers that distinction, including the one fix ("use cache") that only applies to the data category. generateStaticParams is what's specific to a dynamic segment: it gives Next.js a known set of values to prerender ahead of time, which is what lets the route ship a static shell at all. Wrapping the same read in <Suspense> instead, without generateStaticParams, also builds — but then no slug gets prerendered, only the fallback shell.
What happens when a URL's slug isn't one of the values generateStaticParams returned?
By default (dynamicParams left unset, which means true), Next.js renders that slug on the server the moment a real request arrives, behind the same Partial Prerender shell the known slugs use — this lesson's own curl shows the response carrying an x-nextjs-postponed header that the prerendered, known slugs' responses don't have. It is not a 404 by default; a route decides what counts as missing in its own code, typically with notFound().
Why does dynamicParams: false fail the build instead of restricting prerendering to just the known slugs?
dynamicParams: false is the older, pre-Cache-Components way to tell Next.js "never render an unlisted param, 404 it instead." The installed Next.js 16.4.3 rejects it outright under Cache Components with a dedicated error naming the route segment config and the exact fix ("Please remove it") — Cache Components routes unlisted params through the same dynamic-by-default mechanism every other request-specific read uses, and a route's own code (notFound(), typically) is what has to decide a slug doesn't exist.
Does calling notFound() for an unlisted slug send a real HTTP 404 status code?
Not always. This lesson's own curl against a Partial-Prerendered dynamic route shows the response body switching to the real "This page could not be found" markup, but the status line still reads 200 — the static shell's 200 status was already committed to the response before the streamed, notFound()-triggering part ever resolved, and a streaming HTTP response can't rewrite a status line it already sent. A route that needs a real 404 status for an unlisted slug can't rely on the default Partial Prerender shape to deliver it.