Dynamic Routes, Params, and Navigation Hooks22 min read
Module F-5·22 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.
F-5 — Dynamic Routes, Params, and Navigation Hooks
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.
Dynamic Segments: The Mechanics
A folder with square brackets creates a dynamic segment. Every page inside that folder receives the matched value as a params prop:
text
The folder name — without brackets — becomes the key in params:
tsx
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.
Nested Dynamic Segments
You can nest dynamic segments. Each folder adds a key to params:
text
tsx
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.
Catch-All and Optional Catch-All Segments
Catch-all [...slug] matches one or more path segments and returns them as an array:
text
tsx
Note: catch-all does not match the root path /docs — only paths with at least one additional segment.
Optional catch-all [[...slug]] also matches the root:
text
Optional catch-all is the pattern for CMS-driven sites where the homepage is content-managed alongside all other pages. The root check is if (!slug).
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.
tsx
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:
tsx
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:
tsx
Each array entry maps to one path. ['api', 'authentication'] pre-renders /docs/api/authentication.
searchParams — The Query String
Pages also receive searchParams for the URL query string. Like params, it's a Promise in Next.js 15+:
tsx
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).
Navigation Hooks in Client Components
Server Components can't navigate programmatically — navigation is a browser action. These hooks live in Client Components:
useRouter
Programmatic navigation:
tsx
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.
usePathname
The current URL path as a string:
tsx
useSearchParams
Reads the current URL query string. Returns a URLSearchParams-like object:
tsx
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:
tsx
useParams
Reads dynamic route params from a Client Component (equivalent to params in Server Components, but as a synchronous hook):
tsx
useLinkStatus (Next.js 16)
A newer hook that tracks the prefetch and navigation state of links. Useful for showing loading indicators when navigating to slow routes:
tsx
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.
The <Link> Component and Prefetching
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.
tsx
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:
tsx
In development mode, prefetching is disabled — it only runs in production builds. Don't be confused by the lack of prefetching when testing locally.
Linking with Dynamic Segments
For dynamic paths, construct the URL string directly:
tsx
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.
permanentRedirect() and Redirect Hierarchy
You've seen redirect() from F-4. For permanent (308) redirects:
tsx
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.
URL State as the Source of Truth
One pattern worth establishing early: for filters, pagination, search, and sort state — use the URL, not useState.
Why:
URLs are shareable — a user can copy/paste a filtered view and it works
Browser back button works correctly — pressing back restores the previous filter state
Server Components receive the state directly via searchParams — no client/server sync required
Bookmarking and deep linking work out of the box
The pattern:
tsx
tsx
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.
Where We Go From Here
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.
// Force 404 for any path not pre-rendered at build timeexportconst dynamicParams =false;// Allow dynamic rendering for paths not pre-rendered (default)exportconst dynamicParams =true;
'use client';import{ useRouter }from'next/navigation';exportdefaultfunctionProductForm(){const router =useRouter();asyncfunctionhandleSubmit(formData:FormData){const product =awaitcreateProduct(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<formaction={handleSubmit}>...</form>;}
// app/products/page.tsx (Server Component)import{Suspense}from'react';importFilterBarfrom'@/components/FilterBar';exportdefaultfunctionProductsPage(){return(<div><Suspensefallback={<FilterBarSkeleton/>}><FilterBar/></Suspense>{/* rest of page */}</div>);}
'use client';import{ useParams }from'next/navigation';exportdefaultfunctionEditButton(){const params =useParams<{ id:string}>();// params.id is the current dynamic segment valuereturn<ahref={`/products/${params.id}/edit`}>Edit</a>;}
// Default — prefetch when link enters viewport (production only)<Linkhref="/dashboard">Dashboard</Link>// Explicitly disable prefetching<Linkhref="/heavy-page"prefetch={false}>Heavy Page</Link>// Force full prefetch even for dynamic routes (use sparingly)<Linkhref="/products/42"prefetch={true}>Product</Link>
// Static string interpolation<Linkhref={`/products/${product.id}`}>{product.name}</Link>// Object form (useful when building complex URLs)<Linkhref={{ pathname:'/products/[id]', query:{ id: product.id}}}>{product.name}</Link>
import{ permanentRedirect }from'next/navigation';exportdefaultasyncfunctionOldProductPage({ params }){const{ oldId }=await params;const newId =awaitgetNewProductId(oldId);if(newId)permanentRedirect(`/products/${newId}`);notFound();}
// Client Component — manages URL state'use client';import{ useRouter, useSearchParams, usePathname }from'next/navigation';exportfunctionSortSelector(){const router =useRouter();const pathname =usePathname();const searchParams =useSearchParams();functionhandleSort(value:string){const params =newURLSearchParams(searchParams.toString()); params.set('sort', value); router.push(`${pathname}?${params.toString()}`);}return(<selectonChange={e =>handleSort(e.target.value)}value={searchParams.get('sort')??'newest'}><optionvalue="newest">Newest</option><optionvalue="price-asc">Price: Low to High</option><optionvalue="price-desc">Price: High to Low</option></select>);}