AI Reading
Quick summary of this article
Build data-driven Next.js 16 pages with dynamic segments, catch-all routes, awaited params and searchParams, generateStaticParams for static generation and generateMetadata for SEO.
paramsandsearchParamsare Promises in Next.js 16 and must be awaited — synchronous access was fully removed.generateStaticParamspre-renders every dynamic route at build time, turning a blog into static HTML.generateMetadatabuilds per-page titles, descriptions and Open Graph tags from the same data the page renders.- Catch-all
[...slug]and optional catch-all[[...slug]]segments handle arbitrary URL depth.
Complete Next.js 16 Series — Module 4 of 12
Static pages are the easy case. Real applications serve /blog/nextjs-16-guide and /products?sort=price&page=2, where the URL carries the data. Next.js handles this with dynamic segments, and in version 16 the request APIs are fully asynchronous.
Your First Dynamic Route
Create a folder with square brackets:
src/app/blog/[slug]/page.tsx → /blog/anything
type Props = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
return (
<article className="mx-auto max-w-2xl px-6 py-12">
<h1 className="text-3xl font-bold">{slug}</h1>
</article>
);
}
Two details matter here:
paramsis typed asPromise<{ slug: string }>.- You must
awaitit. In Next.js 15 this was a warning with a synchronous fallback. In Next.js 16 the fallback is gone — readingparams.slugdirectly throws.
TypeScript shortcut: after running
npx next typegen, globally available helpersPageProps<'/blog/[slug]'>andLayoutProps<'/blog/[slug]'>are generated, so you can skip writing the generic yourself.
All Three Dynamic Segment Forms
src/app/docs/[slug]/page.tsx → /docs/intro
src/app/docs/[...slug]/page.tsx → /docs/a/b/c (slug = ['a','b','c'])
src/app/docs/[[...slug]]/page.tsx → /docs and /docs/a/b (slug may be undefined)
A catch-all route is ideal for documentation trees or a CMS-driven file hierarchy:
type Props = {
params: Promise<{ slug?: string[] }>;
};
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
const path = slug?.join(" / ") ?? "Documentation home";
return (
<div className="prose mx-auto max-w-3xl px-6 py-12">
<p className="text-sm text-neutral-500">You are reading: {path}</p>
</div>
);
}
Reading Query Strings with searchParams
Filters, sorting and pagination live in the query string, also asynchronous:
type Props = {
searchParams: Promise<{ q?: string; page?: string; sort?: string }>;
};
export default async function ProductsPage({ searchParams }: Props) {
const { q = "", page = "1", sort = "newest" } = await searchParams;
const currentPage = Math.max(1, Number(page) || 1);
return (
<div className="px-6 py-10">
<h1 className="text-2xl font-bold">Products</h1>
<p className="mt-2 text-neutral-600">
Query: {q || "none"} · Page {currentPage} · Sorted by {sort}
</p>
</div>
);
}
A page that reads searchParams opts into dynamic rendering, because the URL depends on the request. That is correct and expected — but it means you cannot also statically prerender it without extra work.
Filtering Without Losing Server Rendering
A client-side filter bar that pushes URL state is the pattern to prefer. The URL stays shareable, and the server still does the data fetching:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
export default function ProductFilters() {
const router = useRouter();
const params = useSearchParams();
const [isPending, startTransition] = useTransition();
function update(key: string, value: string) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
next.delete("page");
startTransition(() => router.push(`/products?${next.toString()}`));
}
return (
<div className="flex flex-wrap gap-3" aria-busy={isPending}>
<input
defaultValue={params.get("q") ?? ""}
onChange={(e) => update("q", e.target.value)}
placeholder="Search…"
className="rounded border px-3 py-2 text-sm"
/>
<select
defaultValue={params.get("sort") ?? "newest"}
onChange={(e) => update("sort", e.target.value)}
className="rounded border px-3 py-2 text-sm"
>
<option value="newest">Newest</option>
<option value="price">Price</option>
<option value="rating">Rating</option>
</select>
</div>
);
}
useTransition keeps the old results visible while the new server render streams in, so the UI never flashes empty.
generateStaticParams: Static HTML for Dynamic Routes
By default a dynamic route renders on demand. To pre-render every variant at build time, export generateStaticParams:
export async function generateStaticParams() {
const posts = await getPostSlugs(); // your data source
return posts.map((post) => ({ slug: post.slug }));
}
npm run build now emits every /blog/[slug] page as static HTML. Visitors hit a CDN; no server render, no database call.
You can mix both strategies inside one route:
export const dynamicParams = true; // render slugs not returned above on demand
export const revalidate = 3600; // regenerate a cached page at most once an hour
With dynamicParams = true (the default), the common slugs are static and rare slugs render on first request then get cached. Set it to false for a closed set where unknown slugs should 404.
Per-Page SEO with generateMetadata
Static metadata cannot depend on the URL. generateMetadata can:
import type { Metadata } from "next";
import { notFound } from "next/navigation";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return { title: "Post not found" };
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt.toISOString(),
images: [{ url: `/api/og?title=${encodeURIComponent(post.title)}` }],
},
twitter: { card: "summary_large_image" },
};
}
Next.js deduplicates the underlying data fetch between generateMetadata and the page component, so getPost(slug) runs once per request, not twice.
Handling Missing Data with notFound()
Never render a broken page. Throw the framework’s sentinel instead:
import { notFound } from "next/navigation";
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article>{/* ... */}</article>;
}
notFound() renders the nearest not-found.tsx and returns an HTTP 404 status — correct for SEO and for users.
// src/app/blog/[slug]/not-found.tsx
import Link from "next/link";
export default function PostNotFound() {
return (
<div className="mx-auto max-w-lg px-6 py-20 text-center">
<h1 className="text-2xl font-bold">Post not found</h1>
<p className="mt-2 text-neutral-600">It may have been moved or deleted.</p>
<Link href="/blog" className="mt-6 inline-block text-brand-600 underline">
Back to all posts
</Link>
</div>
);
}
Module 4 Recap
[slug],[...slug]and[[...slug]]cover single, required catch-all and optional catch-all segments.paramsandsearchParamsare Promises — alwaysawaitthem in Next.js 16.- Reading
searchParamsmakes a route dynamic; push filters through the URL to keep it shareable. generateStaticParamsprerenders dynamic routes;dynamicParamsandrevalidatecontrol the rest.generateMetadataproduces per-page SEO tags, deduplicated against the page’s own fetch.notFound()gives a real 404 with a custom UI.
Next up — Module 5: Server Components vs Client Components, and where to put the boundary.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 3: Tailwind CSS 4 & Dark Mode
- Next: Module 5: Server vs Client Components
