Next.js dynamic slug routing: [slug], generateStaticParams, App Router
Dynamic slug routing in Next.js is the bridge between a content database and a public URL. The mechanics are simple, the gotchas live in build-time generation, fallback behavior, and which router you are on. Most of them have nothing to do with Next.js and everything to do with where the slug lives.
[slug] folders define the route shape
In the App Router, a folder called [slug] under app/ becomes a dynamic segment. The folder structure app/blog/[slug]/page.tsx matches /blog/whatever, and the matched value is available as the slug param. Brackets are mandatory; without them the segment is treated as a literal path.
The Pages Router uses pages/blog/[slug].tsx instead, with the same bracket convention but without the page.tsx wrapper folder. The two routers cohabit in the same project: a route that exists in app/ wins over the same route in pages/. If you are migrating, start with one route at a time in the App Router and let the Pages Router catch the rest until you finish the move.
For nested dynamic segments use additional folders: app/[lang]/blog/[slug]/page.tsx matches /en/blog/welcome and you receive both params. For catch-all routes use [...slug], which collapses any depth into an array param. Use this for documentation sites where the slug is itself a path with slashes.
generateStaticParams turns slugs into pre-rendered pages
In the App Router, you tell Next.js which slugs to render at build time by exporting an async generateStaticParams from the page module. The function returns an array of param objects; Next.js generates one HTML file per object.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map((p) => ({ slug: p.slug }));
}
The slugs you return must be the same slugs the runtime page expects. If your CMS stores slugs without locale prefixes but your URLs include /en, the params here are the file-shape params ({ slug }), not the full URL. Wire it carefully or you will see "404 not generated" build warnings.
Pages not present in the returned array are handled per the route's dynamicParams setting. Default true means unknown slugs are server-rendered on demand. Set false to make unknown slugs hard 404, which you want for static brochure sites where the slug list is closed.
Slugs need a single source of truth
The most common Next.js slug bug is the slug stored in two places. The CMS has one normalization, the file system has another, and a refactor leaves them out of sync. Pick one source.
For CMS-driven content (Sanity, Contentful, Ghost) the CMS is the source. Always slugify on the backend, store the slug as a field, expose it via the API. Your Next.js code reads the slug from the CMS at build (in generateStaticParams) and at runtime (in the page component). Never re-derive the slug from the title in the frontend; the moment your slugifier disagrees with the CMS's, links break.
For file-based content (MDX in a content folder) the filename is the source. Strip the extension, do not touch the rest. Resist the urge to re-normalize the filename in code; if you want a slug different from the filename, rename the file.
useParams in App Router, getStaticPaths in Pages Router
In the App Router, the page component receives params as a prop. From a client component, the useParams hook from next/navigation returns the same object. In Server Components, params are passed directly:
// app/blog/[slug]/page.tsx (Server Component)
export default async function Page({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetchPost(slug);
if (!post) notFound();
return <Article post={post} />;
}
Note params is a Promise in Next 15+. Awaiting it is mandatory; treating it as a sync object compiles but warns at runtime.
In the Pages Router, getStaticPaths plays the role of generateStaticParams and getStaticProps fetches the data. The shape is more verbose but the model is the same: pick the slugs at build, fetch the data per slug, render once. The fallback flag controls runtime behavior for unknown slugs ('blocking' is the safe default).
Working example
tsx// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
interface Post {
slug: string;
title: string;
content: string;
}
async function fetchAllPosts(): Promise<Post[]> {
// Replace with your CMS, DB, or file walk.
const res = await fetch("https://cms.example.com/posts", {
next: { revalidate: 3600 },
});
return res.json();
}
async function fetchPost(slug: string): Promise<Post | null> {
const all = await fetchAllPosts();
return all.find((p) => p.slug === slug) ?? null;
}
// Pre-render every known slug at build time.
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map((p) => ({ slug: p.slug }));
}
// Reject unknown slugs as 404 (static export friendly).
export const dynamicParams = false;
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await fetchPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
} Just need the result?
When you are seeding a Next.js blog or doc site and want a clean list of slugs for your CMS or MDX folder, the slug generator at aldeacode.com produces the deterministic kebab-case form. Paste your draft titles, copy the slugs, save them as the source of truth in your content layer. generateStaticParams reads them, the runtime renders them, and you never re-slugify in the frontend.
Open URL Slug Generator →Frequently asked questions
Should I slugify titles in the Next.js app or in the CMS?
In the CMS, every time. Two slugifiers diverge in subtle ways and the moment they disagree, links break. Store the slug as a field on the content record, expose it through the API, read it from Next.js without further transformation.
What does dynamicParams = false do?
It tells the App Router to 404 any slug that was not in generateStaticParams output. With dynamicParams = true (default), unknown slugs are server-rendered on demand. False is correct for closed catalogs; true is correct for content that grows after deploy.
Why do I need to await params in Next 15?
Params became async to support streaming and partial pre-rendering. The runtime returns a Promise; awaiting unwraps it. The old sync access still compiles but logs a warning, and future versions remove the fallback. Update existing pages now.