|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/routing
$cat docs/advanced-routing-&-dynamic-routes-in-next.js.md
updated Last weekยท30 min readยทpublished

Advanced Routing & Dynamic Routes in Next.js

โ—†Next.jsโ—†Routingโ—†Advancedโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

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.

Dynamic Route Segments

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.

[slug]/page.tsx
TSX
1// app/blog/[slug]/page.tsx
2import { notFound } from "next/navigation";
3import { getPostBySlug } from "@/lib/posts";
4
5export 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.

shop/[category]/[productId]/page.tsx
TSX
1// app/shop/[category]/[productId]/page.tsx
2import { notFound } from "next/navigation";
3import { getProduct } from "@/lib/products";
4
5export 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.

typed_params.tsx
TSX
1// lib/validation.ts
2import { z } from "zod";
3
4export 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
10import { productParamsSchema } from "@/lib/validation";
11
12export 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}
ConventionExample URLParams
[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

Treat every dynamic segment as untrusted input. Validate and sanitize params before using them in database queries or API calls. A well-typed schema is the first line of defense.
generateStaticParams

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.

generateStaticParams.tsx
TSX
1// app/blog/[slug]/page.tsx
2import { getPostSlugs } from "@/lib/posts";
3
4export async function generateStaticParams() {
5 const slugs = await getPostSlugs();
6
7 return slugs.map((slug) => ({
8 slug,
9 }));
10}
11
12export 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.

nested_static_params.tsx
TSX
1// app/shop/[category]/[productId]/page.tsx
2import { getAllProducts } from "@/lib/products";
3
4export 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
13export 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.

dynamicParams_false.tsx
TSX
1// app/blog/[slug]/page.tsx
2export const dynamicParams = false;
3
4export 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

Do not use generateStaticParams to fetch user-specific data. It runs at build time for all users. Personalization must happen at request time inside the page component or a Server Component further down the tree.
generateMetadata

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.

generateMetadata.tsx
TSX
1// app/blog/[slug]/page.tsx
2import type { Metadata } from "next";
3import { getPostBySlug } from "@/lib/posts";
4import { notFound } from "next/navigation";
5
6export 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 )}&section=Blog&color=%2300FF41`,
36 width: 1200,
37 height: 630,
38 },
39 ],
40 },
41 };
42}
43
44export 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

Keep generateMetadata lean. It blocks the HTML response, so heavy computation here delays Time to First Byte. Fetch only the fields needed for the head, and leave body content for the page component.
Route Groups

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.

route-groups.txt
TEXT
1app/
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

Route groups are not a security boundary. They only affect layout and organization. If a route is inside a group, it is still reachable by its URL. Use middleware or Server Component checks for actual access control.
Parallel Routes & Default

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.

parallel-routes.txt
TEXT
1app/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
dashboard/layout.tsx
TSX
1// app/dashboard/layout.tsx
2export 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.

default.tsx
TSX
1// app/dashboard/@team/default.tsx
2export default function TeamDefault() {
3 return <p>Select a team member to view details.</p>;
4}
5
6// app/dashboard/@analytics/default.tsx
7export 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

Parallel routes are powerful but increase layout complexity. They are best for stable, top-level sections such as dashboards. Avoid them for transient UI or modals unless you also understand the interaction with browser history and soft navigation.
Catch-All & Optional Catch-All

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.

ConventionMatches /docsMatches /docs/aMatches /docs/a/bParams
[...slug]NoYesYesslug is an array
[[...slug]]YesYesYesslug is an array, possibly empty
[...slug]/page.tsx
TSX
1// app/docs/[...slug]/page.tsx
2import { notFound } from "next/navigation";
3import { getDocByPath } from "@/lib/docs";
4
5export 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}
[[...slug]]/page.tsx
TSX
1// app/docs/[[...slug]]/page.tsx
2export 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

A catch-all route with dynamicParams = true can render a page for any URL under its prefix. Validate the captured slug array and return notFound() for invalid paths. Otherwise, you will serve duplicate or garbage pages.
Programmatic Navigation

Declarative links with <Link> cover most navigation, but there are times when you need to redirect from code. Server Components and route handlers use redirect and permanentRedirect from next/navigation. Client components use useRouter or the <Link> component.

redirect returns a 307 temporary redirect by default. It is the right choice after a form submission, when the user is not authenticated, or when a resource has moved. permanentRedirect returns a 308 and tells search engines that the old URL is gone forever. Use it for canonical URL migrations and SEO cleanup.

redirect.tsx
TSX
1// app/page.tsx
2import { redirect } from "next/navigation";
3import { auth } from "@/auth";
4
5export default async function HomePage() {
6 const session = await auth();
7
8 if (!session) {
9 redirect("/login");
10 }
11
12 redirect("/dashboard");
13}
permanentRedirect.tsx
TSX
1// app/old-product/[id]/page.tsx
2import { permanentRedirect } from "next/navigation";
3
4export default async function OldProductPage({
5 params,
6}: {
7 params: Promise<{ id: string }>;
8}) {
9 const { id } = await params;
10 // Old product pages have been merged into /products/:id
11 permanentRedirect(`/products/${id}`);
12}

On the client, use useRouter from next/navigation for imperative navigation. It provides push, replace, back, forward, and refresh. The refresh method is useful after a mutation that should invalidate the current route data without a full navigation.

SearchForm.tsx
TSX
1// components/SearchForm.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { useState } from "react";
6
7export default function SearchForm() {
8 const router = useRouter();
9 const [query, setQuery] = useState("");
10
11 function handleSubmit(e: React.FormEvent) {
12 e.preventDefault();
13 router.push(`/search?q=${encodeURIComponent(query)}`);
14 }
15
16 return (
17 <form onSubmit={handleSubmit}>
18 <input
19 value={query}
20 onChange={(e) => setQuery(e.target.value)}
21 placeholder="Search..."
22 />
23 <button type="submit">Search</button>
24 </form>
25 );
26}

For declarative navigation, the <Link> component prefetches the target page when it enters the viewport, making subsequent navigation feel instant. Use it for menus, lists, and breadcrumbs. For interactions that require JavaScript state, such as a wizard stepper, use useRouter instead.

ProductList.tsx
TSX
1// components/ProductList.tsx
2import Link from "next/link";
3
4export default function ProductList({ products }: { products: Product[] }) {
5 return (
6 <ul>
7 {products.map((product) => (
8 <li key={product.id}>
9 <Link href={`/products/${product.id}`}>
10 {product.name}
11 </Link>
12 </li>
13 ))}
14 </ul>
15 );
16}
APIContextStatusUse case
redirect()Server Component / route handler307Login gating, after mutation
permanentRedirect()Server Component / route handler308Canonical URL migration
useRouter().push()Client ComponentSoft navigationForms, wizards, user actions
<Link>Client ComponentSoft navigationMenus, lists, pagination
โ„น

info

Prefer <Link> for navigation that the user initiates through a clickable element. It gives you prefetching, client-side transitions, and accessibility for free. Use useRouter only when the navigation is driven by logic, not a direct click.
Route Conflicts & Precedence

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 definitionURL matchedPrecedence
app/blog/new/page.tsx/blog/newHighest: static
app/blog/[slug]/page.tsx/blog/:slugDynamic
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

Do not rely on file-system ordering to resolve conflicts. Conflicts are a sign that your URL structure is ambiguous. Refactor the routes to make each URL map to a single, explicit file.
Best Practices

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

Think of your URL design as a public API. Every path should be predictable, stable, and meaningful. A good URL structure makes the application easier to navigate, easier to test, and easier to cache at the edge.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-ROUTINGยทRevision: 1.0