Dynamic Routes, Params, and Navigation Hooks13 min read
Module F-5·13 min read
Slug segments, catch-all routes, async params in Next.js 15, generateStaticParams, and the full suite of client-side navigation hooks including the new useLinkStatus.
Who this is for: Developers who understand Server Components and basic data fetching from F-3 and F-4, and now need to work with URL parameters, statically pre-render dynamic content, and navigate programmatically from Client Components. This module covers the full routing toolkit — including the navigation hooks that Next.js 15 and 16 extended significantly.
export default async function ProductPage({ params }: PageProps) { const { id } = await params; // id = "abc123" for /products/abc123 const product = await getProduct(id); return <ProductDetail product={product} />; }
In Next.js 15+, params is a Promise. Always await it. If you're reading legacy code from Next.js 14, you'll see synchronous destructuring — that still works during the Next.js 15 transition period, but new code should await params.
Deeply nested dynamic routes are common in content-heavy applications. Keep in mind that each level of nesting is a separate layout boundary — you can have app/users/[userId]/layout.tsx that fetches the user profile once and shares it across all pages under that user's namespace.
By default, dynamic routes render on-demand at request time (server-rendered). generateStaticParams tells Next.js to pre-render specific parameter values at build time, generating static HTML files for those paths.
export default async function ProductPage({ params }: PageProps) { const { id } = await params; const product = await getProduct(id); if (!product) notFound(); return <ProductDetail product={product} />; }
At build time, Next.js calls generateStaticParams(), gets the list of IDs, and pre-renders each one. The resulting HTML files are stored and served directly from the CDN for those paths — no server work per request.
What happens to paths not in generateStaticParams? By default, they render on-demand (dynamic rendering). You can change this behaviour:
// Force 404 for any path not pre-rendered at build time export const dynamicParams = false;
// Allow dynamic rendering for paths not pre-rendered (default) export const dynamicParams = true;
dynamicParams = false is the strict mode. Use it when your entire dataset is known at build time (a fixed set of blog posts, documentation pages) and you want 404s for any URL that isn't in your dataset.
For catch-all routes:
// app/docs/[...slug]/page.tsx
export async function generateStaticParams() { const docs = await getAllDocs();
Important: accessing searchParams makes a page dynamic — it can't be statically generated because the query string is only known at request time. If you need to pre-render a page with filtering, consider baking filter options into the URL path instead (/products/shoes rather than /products?category=shoes).
export default function ProductForm() { const router = useRouter();
async function handleSubmit(formData: FormData) { const product = await createProduct(formData); router.push(`/products/${product.id}`); // navigate to new page // router.replace('/products'); // replace history entry // router.back(); // browser back // router.refresh(); // re-run server components for current route }
return <form action={handleSubmit}>...</form>; }
router.refresh() is the method you'll call most often from Client Components when server data changes and you need the Server Components to re-run. It doesn't do a full page reload — it re-fetches the current route's server-rendered content and merges it with the existing client state.
useSearchParams() requires a <Suspense> boundary when used in a component that isn't itself inside one. This is because it makes the component depend on search parameters that are only available at render time, and React needs a boundary to handle the async nature of this. You'll see this as a build warning if you forget it.
The typical pattern: wrap the component that uses useSearchParams in <Suspense> in the parent Server Component:
// app/products/page.tsx (Server Component) import { Suspense } from 'react'; import FilterBar from '@/components/FilterBar';
Reads dynamic route params from a Client Component (equivalent to params in Server Components, but as a synchronous hook):
'use client';
import { useParams } from 'next/navigation';
export default function EditButton() { const params = useParams<{ id: string }>(); // params.id is the current dynamic segment value return <a href={`/products/${params.id}/edit`}>Edit</a>; }
useLinkStatus must be rendered inside a <Link> component to receive the status for that specific link. It returns { pending: boolean } — pending is true while the route is being prefetched or navigated to.
next/link is the standard way to navigate between pages. It renders an <a> tag but intercepts clicks to do client-side navigation rather than full page reloads.
Prefetching is the killer feature: as soon as a <Link> enters the viewport, Next.js starts prefetching the destination route in the background. By the time the user clicks, the data is already loaded — navigation feels instant.
The prefetch prop controls this:
// Default — prefetch when link enters viewport (production only) <Link href="/dashboard">Dashboard</Link>
// Object form (useful when building complex URLs) <Link href={{ pathname: '/products/[id]', query: { id: product.id } }}> {product.name} </Link>
The object form exists for type safety — it's used with Next.js's experimental typedRoutes feature, which validates that the route and its params match a real route in your codebase. Without typedRoutes, the string form is cleaner.
You've seen redirect() from F-4. For permanent (308) redirects:
import { permanentRedirect } from 'next/navigation';
export default async function OldProductPage({ params }) { const { oldId } = await params; const newId = await getNewProductId(oldId); if (newId) permanentRedirect(`/products/${newId}`); notFound(); }
The difference matters for SEO: redirect() sends a 307 temporary redirect, permanentRedirect() sends a 308 permanent redirect. Search engines treat permanent redirects as "consolidate link equity here" — use permanent redirects when you're retiring a URL for good, not when you're conditionally sending users to different destinations.
The Server Component owns the data fetch. The Client Component owns the UI interaction. Neither knows about the other's implementation. The URL mediates between them. This is the most maintainable architecture for filter/sort/search patterns in Next.js.
F-6 covers the built-in Next.js components — <Image>, fonts, <Script>, and the new <Form> component. These are the utilities that handle the performance concerns most engineers solve poorly with raw HTML: image optimization, font loading without layout shift, third-party script loading without blocking render.
After F-6 and F-7 (Route Handlers), F-8 brings everything together in a complete application build. By then you'll have the full vocabulary — server components, client components, data fetching, dynamic routes, and the built-in components — to build real applications.
Knowledge Check
Why does useSearchParams() require a <Suspense> boundary when used in a component that isn't itself inside one?
How does the prefetch behavior of the <Link> component operate by default in production?
When should you use permanentRedirect() instead of redirect()?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.