
Next.js generateStaticParams
Dynamic route segments like app/blog/[slug]/page.tsx are, by default, rendered on demand: the first time someone requests /blog/hello-world, Next.js runs the page for that specific slug and returns the result. That's convenient, but it also means every single visit pays the cost of rendering, even for content that barely ever changes. generateStaticParams is the function that lets you flip that default — telling Next.js, ahead of time, exactly which values a dynamic segment can take, so it can render those routes once at build time instead of once per visitor.
It sounds like a small feature, but it's one of the highest-leverage functions in the App Router's API surface. Get it right and a blog with ten thousand posts serves every single one as a pre-built static file. Get it wrong — or skip it entirely — and the same site quietly renders every post on every request, even though the content hasn't changed since last Tuesday. This article covers exactly how the function works, what it returns, how it composes across nested dynamic segments, and the handful of gotchas that trip people up once Cache Components enters the picture.
The Problem generateStaticParams Solves
Static export and prerendering work naturally for routes with no dynamic parts — Next.js knows there's exactly one /about page, so it can render it once and be done. Dynamic segments break that certainty. A route like app/product/[id]/page.tsx could theoretically match thousands of different id values, and Next.js has no way to know which ones actually exist in your data without asking.
generateStaticParams is that ask. You export it alongside your page (or layout, or Route Handler), and inside it you return the list of concrete param values that should be pre-rendered. Next.js then treats your one dynamic template as N separate static routes — one per object in the array you returned.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((res) =>
res.json(),
);
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// fetch and render the post for this slug
}
Return three objects and you get three statically generated pages. Return three thousand and you get three thousand. The function's whole job is producing that list.
Where You Can Use It
generateStaticParams isn't limited to pages. It works in three places:
- Pages (
page.tsx/page.js) — the most common use, generating one static route per returned param set. - Layouts (
layout.tsx/layout.js) — useful when a shared layout needs to pre-render for every value of a segment above it, even if the page underneath handles other dynamic segments independently. - Route Handlers (
route.ts/route.js) — for statically generating API responses at build time, not just HTML pages.
That last one surprises people the most. It's easy to think of generateStaticParams as a page-only concern, but a Route Handler serving app/api/posts/[id]/route.ts can use it exactly the same way, pre-computing JSON responses for known IDs instead of hitting your database on every request.
The Return Shape
generateStaticParams must return an array of objects. Each object represents one route to statically generate, and its keys map directly to the dynamic segment names in the file path. The shape is dictated entirely by how many dynamic segments the route has and what kind they are:
| Route pattern | Expected return shape |
|---|---|
/product/[id] | { id: string }[] |
/products/[category]/[product] | { category: string, product: string }[] |
/products/[...slug] | { slug: string[] }[] |
For a single dynamic segment, this is about as simple as it gets:
// app/product/[id]/page.tsx
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }, { id: "3" }];
}
That produces exactly three static routes: /product/1, /product/2, /product/3. For a route with two dynamic segments in the same path, each returned object needs to supply both:
// app/products/[category]/[product]/page.tsx
export function generateStaticParams() {
return [
{ category: "electronics", product: "laptop-14" },
{ category: "electronics", product: "monitor-27" },
{ category: "kitchen", product: "kettle-steel" },
];
}
And for a catch-all segment ([...slug]), the value is an array rather than a string, since a catch-all can match multiple path parts at once:
// app/docs/[...slug]/page.tsx
export function generateStaticParams() {
return [
{ slug: ["getting-started"] },
{ slug: ["guides", "deployment"] },
{ slug: ["reference", "api", "auth"] },
];
}
This generates /docs/getting-started, /docs/guides/deployment, and /docs/reference/api/auth as three independently static routes, even though they're all handled by the same file.
Controlling What Happens to Unlisted Params
Returning a list from generateStaticParams doesn't automatically mean only those values are valid. By default, if someone requests /product/999 and 999 wasn't in your returned list, Next.js will still try to render it — falling back to on-demand rendering for anything you didn't explicitly list.
You control this behavior with the dynamicParams route segment config, exported alongside generateStaticParams:
// app/product/[id]/page.tsx
export const dynamicParams = false;
export async function generateStaticParams() {
const products = await getTopProducts();
return products.map((p) => ({ id: p.id }));
}
With dynamicParams set to false, any id not present in the array you returned results in a 404 (or, for a catch-all route, simply doesn't match). This is the setting you want when you're deliberately generating a bounded, known set of pages and want anything outside that set treated as genuinely not existing — a documentation site with a fixed page list, for example, rather than a product catalog that grows daily.
Leave dynamicParams at its default (true), and unlisted values get rendered dynamically on their first visit, which is exactly the behavior you want for a large, growing content set where pre-building everything at build time would be wasteful or impossible.
Three Prerendering Strategies
The interplay between what you return from generateStaticParams and how dynamicParams is configured gives you three distinct strategies, and choosing between them is really a question about your content's shape and how often it changes.
Everything at build time. Return the complete list of every valid param, and Next.js prerenders all of them before the build finishes. This is the simplest mental model and the best fit when the full data set is known and reasonably sized — a marketing site with forty pages, a catalog with a few hundred products.
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
A subset at build time, the rest on first visit. Return only the most important or most frequently accessed params — your top ten blog posts, say — and let everything else render dynamically the first time it's requested.
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.slice(0, 10).map((post) => ({ slug: post.slug }));
}
This is the pattern for large, long-tail content sets: you get instant static delivery for the handful of pages that get most of the traffic, without paying the build-time cost of pre-rendering ten thousand posts that might get five visits a year between them.
Nothing at build time, all on first visit. Return an empty array, or use export const dynamic = "force-static" on the route. No pages are pre-rendered during the build, but once a route is visited, the result is cached and served statically from then on (assuming Incremental Static Regeneration is configured to allow it).
export async function generateStaticParams() {
return [];
}
One easy-to-miss detail here: you must always return an array, even an empty one. Returning undefined or omitting the return entirely doesn't opt you into this "generate everything at runtime" behavior — it just makes the route dynamically rendered on every request, with none of ISR's caching benefit.
Cache Components Changes the Rules
If your project has Cache Components enabled, the empty-array trick above no longer works the same way. Cache Components requires generateStaticParams to return at least one param object for a dynamic route; an empty array now causes a build error rather than quietly opting the route into runtime generation.
The reasoning is that Cache Components uses the build-time generation pass to validate that your route doesn't accidentally read request-time-only data — cookies(), headers(), or searchParams — in a context where that would break prerendering. Without at least one concrete param to actually run the page against during the build, there's nothing for that validation to exercise.
If you genuinely don't know any real param values ahead of time but still want that build-time validation, the documented workaround is to return a placeholder:
export async function generateStaticParams() {
return [{ slug: "__placeholder__" }];
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
if (slug === "__placeholder__") {
notFound();
}
// render normally for real slugs
}
It's a workable escape hatch, but it's worth being honest with yourself about the tradeoff: a placeholder satisfies the build, but it doesn't actually validate that your real pages behave correctly under prerendering — only that this one fake page does. Treat it as a last resort, not a habit.
Multiple Dynamic Segments in One Route
Things get more interesting once a route has more than one dynamic segment, because you get to choose where the param-generation logic lives, and the two approaches produce meaningfully different execution patterns.
Given app/products/[category]/[product]/page.tsx, there's an important asymmetry: the page (the innermost file) can generate params for both [category] and [product], but a layout above it — say app/products/[category]/layout.tsx — can only generate params for [category], because it doesn't have visibility into the [product] segment nested below it.
Bottom-up, you generate every combination directly from the child page:
// app/products/[category]/[product]/page.tsx
export async function generateStaticParams() {
const products = await getAllProducts();
return products.map((product) => ({
category: product.category.slug,
product: product.id,
}));
}
This is simple and works fine when fetching "all products with their category" is one cheap query.
Top-down, you split the work: the layout generates the parent segment, and the child page's generateStaticParams receives the already-resolved parent params as an argument, using them to generate just its own segment:
// app/products/[category]/layout.tsx
export async function generateStaticParams() {
const categories = await getCategories();
return categories.map((category) => ({ category: category.slug }));
}
// app/products/[category]/[product]/page.tsx
export async function generateStaticParams({
params: { category },
}: {
params: { category: string };
}) {
const products = await getProductsForCategory(category);
return products.map((product) => ({ product: product.id }));
}
Here's the mechanic that's easy to miss: the child's generateStaticParams is called once per parent param set — if the layout generates five categories, the page's function runs five separate times, once for each category, each time receiving that category's slug and only needing to fetch products scoped to it. This top-down split is genuinely useful when fetching "everything" in one shot would mean joining large, unrelated data sets; splitting the query per category keeps each individual fetch narrow and fast.
Note that the params argument to a child generateStaticParams is available synchronously (unlike the params prop passed to your actual page component, which is a Promise) and only ever contains parent segment values — never sibling or grandchild segments.
Using It with Route Handlers
The same function works for statically generating API responses, not just HTML:
// app/api/posts/[id]/route.ts
export async function generateStaticParams() {
return [{ id: "1" }, { id: "2" }, { id: "3" }];
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
return Response.json({ id, title: `Post ${id}` });
}
With Cache Components enabled, it's worth pairing this with a 'use cache'-wrapped data-fetching function, so the actual data lookup benefits from the same caching layer the rest of your app uses, rather than re-fetching on every generation pass:
async function getPost(id: string) {
"use cache";
const res = await fetch(`https://api.example.com/posts/${id}`);
return res.json();
}
export async function generateStaticParams() {
return [{ id: "1" }, { id: "2" }, { id: "3" }];
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const post = await getPost(id);
return Response.json(post);
}
Fetch Deduplication During Generation
One behavior that saves you from writing your own caching layer: fetch requests are automatically memoized across all generate-prefixed functions (generateStaticParams, generateMetadata, generateViewport, and so on), Layouts, Pages, and Server Components rendered for the same route during a single generation pass. If your generateStaticParams function fetches the same posts list that your page component also fetches to render its content, that data is only actually requested once — React deduplicates it under the hood.
This matters in practice because it means you don't need to manually thread data between generateStaticParams and your page component to avoid double-fetching. Just call fetch in both places with the same URL and let the framework's memoization do the work. If you're fetching data through something other than the built-in fetch (a database client, for instance), you'll need to wrap it in React's own cache() function to get the same deduplication.
Common Mistakes
Forgetting that empty means different things in different configurations. An empty array without Cache Components enabled is a valid, documented pattern for "generate everything on first request." The exact same empty array with Cache Components enabled is a build error. If you're migrating a project onto Cache Components, this is one of the first things worth auditing.
Trying to generate a segment from below where it lives. A page can generate params for every dynamic segment in its own path, but a layout can only generate params for the segments at or above its own level in the tree — never for a segment defined in a page or layout nested more deeply below it. If you need a layout-level generateStaticParams to somehow know about a child segment's values, that's a sign the generation logic belongs in the child instead.
Assuming dynamicParams: false prevents rebuild-time changes. Setting dynamicParams to false controls what happens to unlisted params at request time — it doesn't freeze your static output forever. If your data changes and you rebuild, generateStaticParams runs again and picks up the new list. The setting is about request-time fallback behavior, not about permanently locking in a snapshot.
Not handling the placeholder-param edge case cleanly. If you use the __placeholder__ pattern to satisfy Cache Components' at-least-one-param requirement, make sure your page component actually checks for and rejects that placeholder value with notFound() — otherwise you've shipped a genuinely broken page that happens to build successfully.
Key Takeaways
| Question | Answer |
|---|---|
| What does it return? | An array of objects, one per route to pre-render, with keys matching the route's dynamic segment names |
| Where can it be used? | page.js, layout.js, and route.js |
| What controls unlisted params? | The dynamicParams route segment config (true = render on demand, false = 404) |
| How do I pre-render nothing at build time? | Return [], or use dynamic = "force-static" — not supported the same way under Cache Components |
| What does Cache Components require? | At least one param object; empty arrays are a build error |
| How do multi-segment routes work? | Bottom-up (one function generates every segment) or top-down (parent generates its segment, child receives it as an argument and generates its own) |
| Does it re-run on ISR revalidation? | No — it only runs during next build and, in dev, when you navigate to a route |
generateStaticParams is a small function with an outsized effect on how your app actually performs in production. The decision it forces you to make — build everything up front, build a strategic subset, or build nothing and let the cache fill in over time — is really a decision about your content's shape, its size, and how predictably it changes. Get comfortable moving between the three strategies, and you'll rarely find yourself stuck choosing between "rebuild the whole site to add one page" and "render everything from scratch on every request."


