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:
app/
├── products/
│ └── [id]/
│ └── page.tsx ← /products/abc123, /products/xyz789
├── blog/
│ └── [slug]/
│ └── page.tsx ← /blog/my-post-title
└── [locale]/
└── page.tsx ← /en, /fr, /de (i18n root)
The folder name — without brackets — becomes the key in params:
tsx// app/products/[id]/page.tsx interface PageProps { params: Promise<{ id: string }>; } 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.
Nested Dynamic Segments
You can nest dynamic segments. Each folder adds a key to params:
app/
└── users/
└── [userId]/
└── posts/
└── [postId]/
└── page.tsx ← /users/42/posts/7
tsx// app/users/[userId]/posts/[postId]/page.tsx interface PageProps { params: Promise<{ userId: string; postId: string }>; } export default async function PostPage({ params }: PageProps) { const { userId, postId } = await params; const post = await getPost(userId, postId); return <PostDetail post={post} />; }
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:
app/docs/[...slug]/page.tsx
/docs/getting-started → params.slug = ['getting-started']
/docs/api/authentication → params.slug = ['api', 'authentication']
/docs/api/rest/endpoints → params.slug = ['api', 'rest', 'endpoints']
tsx// app/docs/[...slug]/page.tsx interface PageProps { params: Promise<{ slug: string[] }>; } export default async function DocsPage({ params }: PageProps) { const { slug } = await params; const doc = await getDoc(slug.join('/')); if (!doc) notFound(); return <DocContent doc={doc} />; }
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:
app/[[...slug]]/page.tsx
/ → params.slug = undefined
/about → params.slug = ['about']
/en/about → params.slug = ['en', 'about']
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).
generateStaticParams — Pre-rendering Dynamic Routes
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// app/products/[id]/page.tsx export async function generateStaticParams() { const products = await db.products.findMany({ select: { id: true }, take: 1000, // pre-render top 1000 products }); return products.map(p => ({ id: p.id })); } 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:
tsx// 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:
tsx// app/docs/[...slug]/page.tsx export async function generateStaticParams() { const docs = await getAllDocs(); return docs.map(doc => ({ slug: doc.path.split('/'), // e.g. ['api', 'authentication'] })); }
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// URL: /products?category=shoes&sort=price&page=2 interface PageProps { params: Promise<{ [key: string]: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function ProductsPage({ params, searchParams }: PageProps) { const { category, sort, page } = await searchParams; const products = await db.products.findMany({ where: category ? { category } : {}, orderBy: sort ? { [sort]: 'asc' } : { createdAt: 'desc' }, take: 20, skip: page ? (parseInt(page) - 1) * 20 : 0, }); return <ProductGrid products={products} />; }
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'use client'; import { useRouter } from 'next/navigation'; 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.
usePathname
The current URL path as a string:
tsx'use client'; import { usePathname } from 'next/navigation'; export default function NavLink({ href, children }: { href: string; children: React.ReactNode }) { const pathname = usePathname(); const isActive = pathname === href || pathname.startsWith(href + '/'); return ( <a href={href} className={isActive ? 'text-white font-semibold' : 'text-zinc-400 hover:text-white'} > {children} </a> ); }
useSearchParams
Reads the current URL query string. Returns a URLSearchParams-like object:
tsx'use client'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; export default function FilterBar() { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); function setFilter(key: string, value: string) { const params = new URLSearchParams(searchParams.toString()); params.set(key, value); router.push(`${pathname}?${params.toString()}`); } const currentCategory = searchParams.get('category') ?? 'all'; return ( <div className="flex gap-2"> {['all', 'shoes', 'bags', 'accessories'].map(cat => ( <button key={cat} onClick={() => setFilter('category', cat)} className={currentCategory === cat ? 'bg-white text-black' : 'text-zinc-400'} > {cat} </button> ))} </div> ); }
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// app/products/page.tsx (Server Component) import { Suspense } from 'react'; import FilterBar from '@/components/FilterBar'; export default function ProductsPage() { return ( <div> <Suspense fallback={<FilterBarSkeleton />}> <FilterBar /> </Suspense> {/* rest of page */} </div> ); }
useParams
Reads dynamic route params from a Client Component (equivalent to params in Server Components, but as a synchronous hook):
tsx'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 (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'use client'; import Link from 'next/link'; import { useLinkStatus } from 'next/navigation'; function StatusIndicator() { const { pending } = useLinkStatus(); return pending ? <Spinner /> : null; } export default function ProductCard({ product }: { product: Product }) { return ( <Link href={`/products/${product.id}`} className="block p-4 border rounded"> <StatusIndicator /> <h3>{product.name}</h3> </Link> ); }
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.
tsximport Link from 'next/link'; export default function BlogIndex({ posts }) { return ( <ul> {posts.map(post => ( <li key={post.slug}> <Link href={`/blog/${post.slug}`}>{post.title}</Link> </li> ))} </ul> ); }
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// Default — prefetch when link enters viewport (production only) <Link href="/dashboard">Dashboard</Link> // Explicitly disable prefetching <Link href="/heavy-page" prefetch={false}>Heavy Page</Link> // Force full prefetch even for dynamic routes (use sparingly) <Link href="/products/42" prefetch={true}>Product</Link>
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// Static string interpolation <Link href={`/products/${product.id}`}>{product.name}</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.
permanentRedirect() and Redirect Hierarchy
You've seen redirect() from F-4. For permanent (308) redirects:
tsximport { 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.
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// Client Component — manages URL state 'use client'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; export function SortSelector() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); function handleSort(value: string) { const params = new URLSearchParams(searchParams.toString()); params.set('sort', value); router.push(`${pathname}?${params.toString()}`); } return ( <select onChange={e => handleSort(e.target.value)} value={searchParams.get('sort') ?? 'newest'}> <option value="newest">Newest</option> <option value="price-asc">Price: Low to High</option> <option value="price-desc">Price: High to Low</option> </select> ); }
tsx// Server Component — reads URL state export default async function ProductsPage({ searchParams }) { const { sort } = await searchParams; const products = await getProducts({ sort }); return ( <> <SortSelector /> <ProductGrid products={products} /> </> ); }
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.