Module 4: Dynamic Routes, Async Params & SEO Metadata in Next.js 16

Module 4: Dynamic Routes, Async Params & SEO Metadata in Next.js 16

AI Reading

Quick summary of this article

Build data-driven Next.js 16 pages with dynamic segments, catch-all routes, awaited params and searchParams, generateStaticParams for static generation and generateMetadata for SEO.

  • params and searchParams are Promises in Next.js 16 and must be awaited — synchronous access was fully removed.
  • generateStaticParams pre-renders every dynamic route at build time, turning a blog into static HTML.
  • generateMetadata builds per-page titles, descriptions and Open Graph tags from the same data the page renders.
  • Catch-all [...slug] and optional catch-all [[...slug]] segments handle arbitrary URL depth.

Complete Next.js 16 Series — Module 4 of 12

Static pages are the easy case. Real applications serve /blog/nextjs-16-guide and /products?sort=price&page=2, where the URL carries the data. Next.js handles this with dynamic segments, and in version 16 the request APIs are fully asynchronous.

Your First Dynamic Route

Create a folder with square brackets:

src/app/blog/[slug]/page.tsx   →  /blog/anything
type Props = {
  params: Promise<{ slug: string }>;
};

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;

  return (
    <article className="mx-auto max-w-2xl px-6 py-12">
      <h1 className="text-3xl font-bold">{slug}</h1>
    </article>
  );
}

Two details matter here:

  1. params is typed as Promise<{ slug: string }>.
  2. You must await it. In Next.js 15 this was a warning with a synchronous fallback. In Next.js 16 the fallback is gone — reading params.slug directly throws.

TypeScript shortcut: after running npx next typegen, globally available helpers PageProps<'/blog/[slug]'> and LayoutProps<'/blog/[slug]'> are generated, so you can skip writing the generic yourself.

All Three Dynamic Segment Forms

src/app/docs/[slug]/page.tsx        →  /docs/intro
src/app/docs/[...slug]/page.tsx     →  /docs/a/b/c   (slug = ['a','b','c'])
src/app/docs/[[...slug]]/page.tsx   →  /docs  and  /docs/a/b  (slug may be undefined)

A catch-all route is ideal for documentation trees or a CMS-driven file hierarchy:

type Props = {
  params: Promise<{ slug?: string[] }>;
};

export default async function DocsPage({ params }: Props) {
  const { slug } = await params;
  const path = slug?.join(" / ") ?? "Documentation home";

  return (
    <div className="prose mx-auto max-w-3xl px-6 py-12">
      <p className="text-sm text-neutral-500">You are reading: {path}</p>
    </div>
  );
}

Reading Query Strings with searchParams

Filters, sorting and pagination live in the query string, also asynchronous:

type Props = {
  searchParams: Promise<{ q?: string; page?: string; sort?: string }>;
};

export default async function ProductsPage({ searchParams }: Props) {
  const { q = "", page = "1", sort = "newest" } = await searchParams;
  const currentPage = Math.max(1, Number(page) || 1);

  return (
    <div className="px-6 py-10">
      <h1 className="text-2xl font-bold">Products</h1>
      <p className="mt-2 text-neutral-600">
        Query: {q || "none"} · Page {currentPage} · Sorted by {sort}
      </p>
    </div>
  );
}

A page that reads searchParams opts into dynamic rendering, because the URL depends on the request. That is correct and expected — but it means you cannot also statically prerender it without extra work.

Filtering Without Losing Server Rendering

A client-side filter bar that pushes URL state is the pattern to prefer. The URL stays shareable, and the server still does the data fetching:

"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";

export default function ProductFilters() {
  const router = useRouter();
  const params = useSearchParams();
  const [isPending, startTransition] = useTransition();

  function update(key: string, value: string) {
    const next = new URLSearchParams(params);
    if (value) next.set(key, value);
    else next.delete(key);
    next.delete("page");

    startTransition(() => router.push(`/products?${next.toString()}`));
  }

  return (
    <div className="flex flex-wrap gap-3" aria-busy={isPending}>
      <input
        defaultValue={params.get("q") ?? ""}
        onChange={(e) => update("q", e.target.value)}
        placeholder="Search…"
        className="rounded border px-3 py-2 text-sm"
      />
      <select
        defaultValue={params.get("sort") ?? "newest"}
        onChange={(e) => update("sort", e.target.value)}
        className="rounded border px-3 py-2 text-sm"
      >
        <option value="newest">Newest</option>
        <option value="price">Price</option>
        <option value="rating">Rating</option>
      </select>
    </div>
  );
}

useTransition keeps the old results visible while the new server render streams in, so the UI never flashes empty.

generateStaticParams: Static HTML for Dynamic Routes

By default a dynamic route renders on demand. To pre-render every variant at build time, export generateStaticParams:

export async function generateStaticParams() {
  const posts = await getPostSlugs(); // your data source

  return posts.map((post) => ({ slug: post.slug }));
}

npm run build now emits every /blog/[slug] page as static HTML. Visitors hit a CDN; no server render, no database call.

You can mix both strategies inside one route:

export const dynamicParams = true; // render slugs not returned above on demand

export const revalidate = 3600;    // regenerate a cached page at most once an hour

With dynamicParams = true (the default), the common slugs are static and rare slugs render on first request then get cached. Set it to false for a closed set where unknown slugs should 404.

Per-Page SEO with generateMetadata

Static metadata cannot depend on the URL. generateMetadata can:

import type { Metadata } from "next";
import { notFound } from "next/navigation";

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) return { title: "Post not found" };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt.toISOString(),
      images: [{ url: `/api/og?title=${encodeURIComponent(post.title)}` }],
    },
    twitter: { card: "summary_large_image" },
  };
}

Next.js deduplicates the underlying data fetch between generateMetadata and the page component, so getPost(slug) runs once per request, not twice.

Handling Missing Data with notFound()

Never render a broken page. Throw the framework’s sentinel instead:

import { notFound } from "next/navigation";

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return <article>{/* ... */}</article>;
}

notFound() renders the nearest not-found.tsx and returns an HTTP 404 status — correct for SEO and for users.

// src/app/blog/[slug]/not-found.tsx
import Link from "next/link";

export default function PostNotFound() {
  return (
    <div className="mx-auto max-w-lg px-6 py-20 text-center">
      <h1 className="text-2xl font-bold">Post not found</h1>
      <p className="mt-2 text-neutral-600">It may have been moved or deleted.</p>
      <Link href="/blog" className="mt-6 inline-block text-brand-600 underline">
        Back to all posts
      </Link>
    </div>
  );
}

Module 4 Recap

  • [slug], [...slug] and [[...slug]] cover single, required catch-all and optional catch-all segments.
  • params and searchParams are Promises — always await them in Next.js 16.
  • Reading searchParams makes a route dynamic; push filters through the URL to keep it shareable.
  • generateStaticParams prerenders dynamic routes; dynamicParams and revalidate control the rest.
  • generateMetadata produces per-page SEO tags, deduplicated against the page’s own fetch.
  • notFound() gives a real 404 with a custom UI.

Next up — Module 5: Server Components vs Client Components, and where to put the boundary.

Leave a Reply

Your email address will not be published. Required fields are marked *