Next.js App Router in Production · Rendering, Caching and Mutations
Dynamic Routes, Params, Metadata and Not Found
Build dynamic routes with async params, prerender known paths with generateStaticParams, set metadata, and understand not-found status codes when streaming.
What you'll build
A /products/[slug] route that prerenders known products at build time, renders other products on first request and caches them, sets the page title with generateMetadata, and shows a custom not-found UI for unknown products.
The idea
- Dynamic segments (
[slug]) give pages aparamspromise;PageProps<"/products/[slug]">types it from the route. generateStaticParamslists paths to prerender at build time; other values render on their first request and are then served from cache.- With Cache Components, read
paramsinside a<Suspense>boundary so Next.js can send the page shell immediately. notFound()renders the nearestnot-found.tsx. If it runs before streaming starts, the status is 404; after streaming starts (inside Suspense), the status stays 200 and Next.js addsnoindexso search engines don't index the page. Unmatched URLs return a real 404.
The code
// file: next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;
// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><main>{children}</main></body>
</html>
);
}
// file: app/lib/catalog.ts
import { cacheLife, cacheTag } from "next/cache";
const products: Record<string, { name: string; price: number }> = {
mug: { name: "Mug", price: 8.5 },
teapot: { name: "Teapot", price: 24 },
kettle: { name: "Kettle", price: 31.9 },
};
export async function getProduct(slug: string) {
"use cache";
cacheLife("hours");
cacheTag(`product:${slug}`);
console.log(`[app] loading product ${slug}`);
return products[slug] ?? null;
}
// file: app/products/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getProduct } from "../../lib/catalog";
export async function generateStaticParams() {
return [{ slug: "mug" }, { slug: "teapot" }]; // prerendered at build time
}
export async function generateMetadata({ params }: PageProps<"/products/[slug]">): Promise<Metadata> {
const product = await getProduct((await params).slug);
return { title: product ? `${product.name} | Shop` : "Product not found" };
}
async function Product({ params }: { params: Promise<{ slug: string }> }) {
const product = await getProduct((await params).slug);
if (!product) notFound();
return (
<>
<h1>{product.name}</h1>
<p>${product.price.toFixed(2)}</p>
</>
);
}
export default function Page({ params }: PageProps<"/products/[slug]">) {
return (
<Suspense fallback={<p>Loading product...</p>}>
<Product params={params} />
</Suspense>
);
}
// file: app/products/[slug]/not-found.tsx
export default function NotFound() {
return <h1>Product not found</h1>;
}
// headers: x-nextjs-cache
// request: GET /products/mug
// request: GET /products/kettle
// request: GET /products/kettle
// request: GET /products/lamp
// request: GET /no-such-pageOutput
GET /products/mug -> 200 (x-nextjs-cache: HIT) Mug $8.50 GET /products/kettle -> 200 [app] loading product kettle Kettle $31.90 GET /products/kettle -> 200 (x-nextjs-cache: HIT) Kettle $31.90 GET /products/lamp -> 200 (noindex) [app] loading product lamp Loading product... GET /no-such-page -> 404 (x-nextjs-cache: HIT, noindex) 404 This page could not be found.
Run this on your computer — see “Get Started” for setup.
mug was prerendered (no loading log, cache HIT). kettle was rendered on its first request, then served from cache. lamp streamed before notFound() ran, so it's a 200 with noindex; the browser swaps the fallback for the not-found UI. If unknown slugs must return a real 404 status (for example for API-like crawlers), check them before rendering, for example in Proxy.
Common mistakes
- Treating
paramsandsearchParamsas plain objects; they're promises in current Next.js. - Awaiting
paramsat the top of a page instead of inside a Suspense boundary, which blocks the static shell. - Assuming
notFound()always produces a 404 status; inside streamed content it's 200 + noindex. - Duplicating data fetches for metadata and the page instead of sharing a cached function.
Key points
- Async params, typed with
PageProps<route>. - generateStaticParams prerenders known paths; others render on demand and are cached.
- notFound() status depends on whether streaming has started.
Further reading: Dynamic routes · generateStaticParams · notFound
Practice questions
What does generateStaticParams do?
Answer
It returns the route parameters to prerender at build time; other values are rendered when first requested and can then be cached.
Why might notFound() return status 200?
Answer
If it runs after the response started streaming (inside a Suspense boundary), the status is already sent; Next.js renders the not-found UI and adds a noindex robots tag.
How should metadata and the page share data?
Answer
Both call the same cached data function (use cache or React cache), so the data is fetched once per render.
Open in the app to mark lessons complete, take notes and track your progress.