Advanced Routing & Dynamic Routes in Next.js
Routing in the Next.js App Router is built on a file-system convention. Every folder under app/ becomes a route segment, and the files inside it decide what renders at that URL. This convention is simple for static pages, but real applications need dynamic paths, grouped layouts, parallel views, and generated metadata. Understanding how these pieces compose is the difference between a clean URL structure and a fragile one.
Dynamic routes let one page file match many URLs. A product page can serve /products/1, /products/2, and any other ID without creating a dedicated file for each item. Static generation can be combined with dynamic paths through generateStaticParams, so the performance benefits of SSG extend to data-driven pages. Route groups, parallel routes, and catch-all segments add organizational power without leaking into the URL.
This guide covers dynamic route segments, static generation for dynamic routes, dynamic metadata, route groups, parallel routes, catch-all patterns, programmatic navigation, and route precedence. The goal is a production-grade mental model: keep URLs stable, validate parameters, and let Next.js generate the right page for the right request.
A route segment is dynamic when its folder name is wrapped in square brackets. The file app/blog/[slug]/page.tsx matches /blog/hello-world, /blog/routing-guide, and any other value in the slug position. The matched value is passed to the page as the params prop.
In Next.js 16, params is a Promise that must be awaited before use. This matches the same async pattern used for searchParams and for data fetching in Server Components. Always await it and destructure the fields you need.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | import { notFound } from "next/navigation"; |
| 3 | import { getPostBySlug } from "@/lib/posts"; |
| 4 | |
| 5 | export default async function BlogPostPage({ |
| 6 | params, |
| 7 | }: { |
| 8 | params: Promise<{ slug: string }>; |
| 9 | }) { |
| 10 | const { slug } = await params; |
| 11 | const post = await getPostBySlug(slug); |
| 12 | |
| 13 | if (!post) { |
| 14 | notFound(); |
| 15 | } |
| 16 | |
| 17 | return ( |
| 18 | <article> |
| 19 | <h1>{post.title}</h1> |
| 20 | <p>{post.excerpt}</p> |
| 21 | </article> |
| 22 | ); |
| 23 | } |
You can have multiple dynamic segments in the same path. app/shop/[category]/[productId]/page.tsx matches URLs like /shop/electronics/42, and the params object will contain both category and productId as strings. Use them together when the data model is nested.
| 1 | // app/shop/[category]/[productId]/page.tsx |
| 2 | import { notFound } from "next/navigation"; |
| 3 | import { getProduct } from "@/lib/products"; |
| 4 | |
| 5 | export default async function ProductPage({ |
| 6 | params, |
| 7 | }: { |
| 8 | params: Promise<{ category: string; productId: string }>; |
| 9 | }) { |
| 10 | const { category, productId } = await params; |
| 11 | const product = await getProduct(category, productId); |
| 12 | |
| 13 | if (!product) { |
| 14 | notFound(); |
| 15 | } |
| 16 | |
| 17 | return ( |
| 18 | <main> |
| 19 | <h1>{product.name}</h1> |
| 20 | <p>Category: {category}</p> |
| 21 | <p>ID: {productId}</p> |
| 22 | </main> |
| 23 | ); |
| 24 | } |
Statically typed params are important because every param starts as a string. If you expect a number, parse it with Number.parseInt or a validation library such as Zod. Never cast a param directly to a narrower type without validation, because a malformed URL can produce runtime errors that TypeScript will not catch.
| 1 | // lib/validation.ts |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | export const productParamsSchema = z.object({ |
| 5 | category: z.string().min(1), |
| 6 | productId: z.coerce.number().int().positive(), |
| 7 | }); |
| 8 | |
| 9 | // app/shop/[category]/[productId]/page.tsx |
| 10 | import { productParamsSchema } from "@/lib/validation"; |
| 11 | |
| 12 | export default async function ProductPage({ |
| 13 | params, |
| 14 | }: { |
| 15 | params: Promise<{ category: string; productId: string }>; |
| 16 | }) { |
| 17 | const raw = await params; |
| 18 | const { category, productId } = productParamsSchema.parse(raw); |
| 19 | |
| 20 | // productId is now a number, not a string |
| 21 | const product = await getProduct(category, productId); |
| 22 | // ... |
| 23 | } |
| Convention | Example URL | Params |
|---|---|---|
| [id] | /products/42 | { id: "42" } |
| [category]/[id] | /shop/electronics/42 | { category: "electronics", id: "42" } |
| [...slug] | /docs/routing/dynamic | { slug: ["routing", "dynamic"] } |
| [[...slug]] | /docs or /docs/routing | { slug: [] } or { slug: ["routing"] } |
info
By default, a dynamic route is rendered on demand at request time. If the list of valid values is known at build time, you can pre-render the pages with generateStaticParams. This function returns an array of param objects, and Next.js builds one HTML page for each object during next build.
The function can be async, so it can fetch data from a CMS, database, or API. It runs at build time, not on the server at request time, so it should not depend on user-specific state. The returned array must contain one object per dynamic segment, with keys matching the bracket names in the folder path.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | import { getPostSlugs } from "@/lib/posts"; |
| 3 | |
| 4 | export async function generateStaticParams() { |
| 5 | const slugs = await getPostSlugs(); |
| 6 | |
| 7 | return slugs.map((slug) => ({ |
| 8 | slug, |
| 9 | })); |
| 10 | } |
| 11 | |
| 12 | export default async function BlogPostPage({ |
| 13 | params, |
| 14 | }: { |
| 15 | params: Promise<{ slug: string }>; |
| 16 | }) { |
| 17 | const { slug } = await params; |
| 18 | // getPostBySlug is called again here, but the data is cached during the build |
| 19 | const post = await getPostBySlug(slug); |
| 20 | // ... |
| 21 | } |
For nested dynamic segments, the returned params must match the full segment list. If the route is app/shop/[category]/[productId]/page.tsx, each object in the array needs both category and productId.
| 1 | // app/shop/[category]/[productId]/page.tsx |
| 2 | import { getAllProducts } from "@/lib/products"; |
| 3 | |
| 4 | export async function generateStaticParams() { |
| 5 | const products = await getAllProducts(); |
| 6 | |
| 7 | return products.map((product) => ({ |
| 8 | category: product.category.slug, |
| 9 | productId: product.id.toString(), |
| 10 | })); |
| 11 | } |
| 12 | |
| 13 | export default async function ProductPage({ |
| 14 | params, |
| 15 | }: { |
| 16 | params: Promise<{ category: string; productId: string }>; |
| 17 | }) { |
| 18 | const { category, productId } = await params; |
| 19 | // ... |
| 20 | } |
What happens when a request arrives for a path that was not returned by generateStaticParams? That depends on the dynamicParams segment config. By default, dynamicParams = true, which means unknown paths are generated on demand and cached. If you set it to false, unknown paths return a 404.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | export const dynamicParams = false; |
| 3 | |
| 4 | export async function generateStaticParams() { |
| 5 | // Only these slugs are valid at build time. |
| 6 | // Any other slug returns 404. |
| 7 | return [{ slug: "hello-world" }, { slug: "routing-guide" }]; |
| 8 | } |
Use dynamicParams = false when the list of valid paths is closed and you want to avoid accidentally rendering a page for a bad slug. Use the default true when the list is large or changes often and you are willing to let the first request pay the generation cost. For large sites, consider combining generateStaticParams with ISR to revalidate pages incrementally.
warning
SEO metadata for dynamic routes should be derived from the actual content. A product page should have the product name in the title, the product description in the Open Graph snippet, and a canonical URL that matches the request. Next.js supports this through the generateMetadata function, which receives the same params as the page.
Like the page component, generateMetadata must await params in Next.js 16. It can fetch data, but it should reuse the same cached fetch as the page to avoid double database hits. Use fetch with a stable cache key or a shared data function that Next.js can deduplicate.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | import type { Metadata } from "next"; |
| 3 | import { getPostBySlug } from "@/lib/posts"; |
| 4 | import { notFound } from "next/navigation"; |
| 5 | |
| 6 | export async function generateMetadata({ |
| 7 | params, |
| 8 | }: { |
| 9 | params: Promise<{ slug: string }>; |
| 10 | }): Promise<Metadata> { |
| 11 | const { slug } = await params; |
| 12 | const post = await getPostBySlug(slug); |
| 13 | |
| 14 | if (!post) { |
| 15 | return { |
| 16 | title: "Not Found โ ForgeLearn", |
| 17 | }; |
| 18 | } |
| 19 | |
| 20 | return { |
| 21 | title: `${post.title} โ ForgeLearn`, |
| 22 | description: post.excerpt, |
| 23 | alternates: { |
| 24 | canonical: `https://forgelearn.dev/blog/${slug}`, |
| 25 | }, |
| 26 | openGraph: { |
| 27 | title: post.title, |
| 28 | description: post.excerpt, |
| 29 | url: `https://forgelearn.dev/blog/${slug}`, |
| 30 | type: "article", |
| 31 | images: [ |
| 32 | { |
| 33 | url: `https://forgelearn.dev/api/og?title=${encodeURIComponent( |
| 34 | post.title |
| 35 | )}§ion=Blog&color=%2300FF41`, |
| 36 | width: 1200, |
| 37 | height: 630, |
| 38 | }, |
| 39 | ], |
| 40 | }, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | export default async function BlogPostPage({ |
| 45 | params, |
| 46 | }: { |
| 47 | params: Promise<{ slug: string }>; |
| 48 | }) { |
| 49 | const { slug } = await params; |
| 50 | const post = await getPostBySlug(slug); |
| 51 | |
| 52 | if (!post) { |
| 53 | notFound(); |
| 54 | } |
| 55 | |
| 56 | return ( |
| 57 | <article> |
| 58 | <h1>{post.title}</h1> |
| 59 | <div dangerouslySetInnerHTML={{ __html: post.body }} /> |
| 60 | </article> |
| 61 | ); |
| 62 | } |
You can also export a static metadata object when the page does not need dynamic values. Static metadata is merged with the root layout metadata. For dynamic routes, prefer generateMetadata so every URL has its own title and description.
Be careful with special characters in generated metadata. Always escape or encode values used in URLs, titles, and descriptions. The Open Graph image URL above uses encodeURIComponent so that ampersands, slashes, and spaces do not break the query string.
best practice
Route groups let you organize routes without affecting the URL. A folder wrapped in parentheses, such as (shop), is ignored when Next.js builds the URL. The file app/(shop)/products/page.tsx renders at /products, not /(shop)/products.
Groups are useful for two things: applying different layouts to different sections of the site, and keeping the file structure readable without changing the public URL. You can have multiple groups in the same app directory, and each group can have its own layout.tsx, error.tsx, and loading.tsx.
| 1 | app/ |
| 2 | โโโ (marketing)/ |
| 3 | โ โโโ layout.tsx |
| 4 | โ โโโ page.tsx # / |
| 5 | โ โโโ about/ |
| 6 | โ โ โโโ page.tsx # /about |
| 7 | โ โโโ pricing/ |
| 8 | โ โโโ page.tsx # /pricing |
| 9 | โโโ (shop)/ |
| 10 | โ โโโ layout.tsx |
| 11 | โ โโโ products/ |
| 12 | โ โ โโโ page.tsx # /products |
| 13 | โ โโโ products/ |
| 14 | โ โโโ [id]/ |
| 15 | โ โโโ page.tsx # /products/:id |
| 16 | โโโ layout.tsx # root layout |
| 17 | โโโ not-found.tsx |
Because the group name is not part of the URL, two different groups can expose pages at the same path if you are not careful. Next.js will detect this conflict at build time and report an error. Keep the URL space in mind when placing files inside groups.
Route groups can also be nested. A folder like app/(dashboard)/(analytics)/reports/page.tsx still renders at /reports. The nested groups are only for layout composition and code organization. Do not use them to encode URL hierarchy.
note
Parallel routes allow multiple pages to render in the same layout simultaneously. They are created with folders prefixed by @, such as @team and @analytics. Each slot is independent and can have its own loading and error states, making them ideal for dashboards and split-pane layouts.
Slots are not part of the URL. The folder app/dashboard/@team/page.tsx does not change the URL from /dashboard. Instead, the parent layout receives each slot as a prop and renders them side by side. The prop name matches the slot folder name without the @ prefix.
| 1 | app/dashboard/ |
| 2 | โโโ layout.tsx |
| 3 | โโโ page.tsx |
| 4 | โโโ @team/ |
| 5 | โ โโโ page.tsx |
| 6 | โ โโโ loading.tsx |
| 7 | โโโ @analytics/ |
| 8 | โ โโโ page.tsx |
| 9 | โ โโโ error.tsx |
| 10 | โโโ settings/ |
| 11 | โโโ page.tsx |
| 1 | // app/dashboard/layout.tsx |
| 2 | export default function DashboardLayout({ |
| 3 | children, |
| 4 | team, |
| 5 | analytics, |
| 6 | }: { |
| 7 | children: React.ReactNode; |
| 8 | team: React.ReactNode; |
| 9 | analytics: React.ReactNode; |
| 10 | }) { |
| 11 | return ( |
| 12 | <main> |
| 13 | <h1>Dashboard</h1> |
| 14 | {children} |
| 15 | <div className="grid grid-cols-2 gap-4"> |
| 16 | <section>{team}</section> |
| 17 | <section>{analytics}</section> |
| 18 | </div> |
| 19 | </main> |
| 20 | ); |
| 21 | } |
The tricky part of parallel routes is navigation. When a user navigates to a sub-page, such as /dashboard/settings, the layout must know what to render in each slot. If a slot does not have a matching page for the new URL, Next.js looks for a default.tsx file in that slot and renders it instead.
| 1 | // app/dashboard/@team/default.tsx |
| 2 | export default function TeamDefault() { |
| 3 | return <p>Select a team member to view details.</p>; |
| 4 | } |
| 5 | |
| 6 | // app/dashboard/@analytics/default.tsx |
| 7 | export default function AnalyticsDefault() { |
| 8 | return <p>Choose an analytics report to display.</p>; |
| 9 | } |
Without a default.tsx, an unmatched slot renders nothing, which can leave a blank pane. The default.tsx is the fallback UI for a slot when the current URL does not map to a page in that slot. It is the equivalent of a catch-all placeholder for parallel views.
warning
Catch-all routes match any number of segments after a point. A folder named [...slug] captures everything from that position onward into an array. The file app/docs/[...slug]/page.tsx matches /docs? No. It matches /docs/routing, /docs/routing/dynamic, and any deeper path.
Optional catch-all routes use double brackets: [[...slug]]. The file app/docs/[[...slug]]/page.tsx matches both /docs and /docs/routing. The slug array will be empty when no extra segments are present. This is useful for documentation sites where the root path should also be handled by the same page.
| Convention | Matches /docs | Matches /docs/a | Matches /docs/a/b | Params |
|---|---|---|---|---|
| [...slug] | No | Yes | Yes | slug is an array |
| [[...slug]] | Yes | Yes | Yes | slug is an array, possibly empty |
| 1 | // app/docs/[...slug]/page.tsx |
| 2 | import { notFound } from "next/navigation"; |
| 3 | import { getDocByPath } from "@/lib/docs"; |
| 4 | |
| 5 | export default async function DocPage({ |
| 6 | params, |
| 7 | }: { |
| 8 | params: Promise<{ slug: string[] }>; |
| 9 | }) { |
| 10 | const { slug } = await params; // e.g. ["routing", "dynamic"] |
| 11 | const doc = await getDocByPath(slug); |
| 12 | |
| 13 | if (!doc) { |
| 14 | notFound(); |
| 15 | } |
| 16 | |
| 17 | return ( |
| 18 | <article> |
| 19 | <h1>{doc.title}</h1> |
| 20 | <div>{doc.body}</div> |
| 21 | </article> |
| 22 | ); |
| 23 | } |
| 1 | // app/docs/[[...slug]]/page.tsx |
| 2 | export default async function OptionalDocPage({ |
| 3 | params, |
| 4 | }: { |
| 5 | params: Promise<{ slug: string[] }>; |
| 6 | }) { |
| 7 | const { slug } = await params; |
| 8 | |
| 9 | if (slug.length === 0) { |
| 10 | // Render the docs landing page |
| 11 | return <DocsLanding />; |
| 12 | } |
| 13 | |
| 14 | const doc = await getDocByPath(slug); |
| 15 | // ... |
| 16 | } |
Catch-all routes are often used for documentation, blogs with nested categories, and CMS-driven pages where the content hierarchy is not fixed. The key risk is that a catch-all route can hide more specific routes if placed incorrectly. Next.js resolves routes from most specific to least specific, so a static folder like app/docs/routing/page.tsx wins over app/docs/[...slug]/page.tsx for /docs/routing.
danger
When multiple routes could match the same URL, Next.js uses a fixed precedence order. Static segments are more specific than dynamic segments, and dynamic segments are more specific than catch-all segments. Route groups are ignored in URL matching, so files inside different groups can collide if they resolve to the same URL.
The order is: static routes, dynamic routes with parameters, catch-all routes, optional catch-all routes. If two routes have the same specificity, the one that is defined earlier in the file-system order wins, but this is rare and usually indicates a design problem. The safest approach is to avoid overlapping route definitions.
| Route definition | URL matched | Precedence |
|---|---|---|
| app/blog/new/page.tsx | /blog/new | Highest: static |
| app/blog/[slug]/page.tsx | /blog/:slug | Dynamic |
| app/blog/[...slug]/page.tsx | /blog/:slug/* | Catch-all |
| app/blog/[[...slug]]/page.tsx | /blog and /blog/* | Lowest: optional catch-all |
A common conflict is a static route that looks like a dynamic one. For example, /blog/new and /blog/[slug] overlap because new is a valid slug string. Place the static route in its own folder, such as app/blog/new/page.tsx, and Next.js will match it before the dynamic route.
Route groups can create hidden conflicts. The files app/(marketing)/about/page.tsx and app/(company)/about/page.tsx both resolve to /about. Next.js will fail the build with a route conflict error. Resolve these by merging the content into a single page or by using different URL paths.
warning
1. Keep URLs stable. Changing a URL breaks bookmarks, search rankings, and external links. If you must move a page, use permanentRedirect from the old route to the new one.
2. Validate every dynamic parameter. Use a schema library or manual checks to ensure params are well-formed before using them in queries or identifiers. Return notFound() for invalid values.
3. Avoid deep nesting. URLs like /a/b/c/d/e are hard to read and remember. Flatten the structure when possible, and use query parameters for secondary filters.
4. Use route groups for layout and organization, not for URL structure. The URL should reflect what the user sees, not the internal folder hierarchy.
5. Generate metadata for dynamic routes. A missing title or description hurts SEO and social sharing. Keep generateMetadata fast by fetching only the fields it needs.
6. Prefer static generation with generateStaticParams when the path list is known at build time. It improves performance and reduces server load for content that does not change per user.
7. Use catch-all routes sparingly. They are powerful but make it easy to accidentally serve content for invalid paths. Always validate the captured segments and return a 404 when appropriate.
8. Add default.tsx to every parallel route slot. It prevents blank panes when the URL does not match a page in that slot.
9. Test route precedence during development. Run next build locally to catch conflicts and catch-all shadowing before deploying.
10. Use typed params. In TypeScript, define the params prop as a Promise and await it. Parse and coerce values to the correct types before using them.
pro tip