Nandita.Mahesh
All writing
4 min read

Next.js Dynamic Routes 404 in Production (But Not Locally)? Check These First

A practical checklist for debugging Next.js App Router [slug] pages that 404 in production while the homepage renders fine — folder naming, generateStaticParams, and uncommitted files.

Next.jsDebuggingApp Router

Here's a specific, maddening bug: your homepage deploys fine on Vercel, but every dynamic route — /courses/sap-mm, /blog/some-post, whatever your [slug] folder represents — comes back with a 404. Locally, npm run dev shows every page correctly. In production, only the static routes survive.

I hit this exact issue building a course catalog with 25 individual course pages, and it's rarely one obvious thing. It's usually one of a handful of quiet mismatches between your local environment and what actually got committed and built. Here's the order I'd check things in.

1. Confirm the file is actually committed

This is the most common cause and the easiest to miss. Run:

git status
git ls-files src/app/courses

If your [slug]/page.tsx file, or the folder itself, doesn't show up in git ls-files, it was never pushed — Vercel is building from a repo that simply doesn't have the route. This happens easily if you created the folder, tested locally, and moved on without staging it, especially if your .gitignore is broader than you think.

2. Check the folder name matches exactly, including brackets

[slug] is not the same as [Slug], [slug] , or [id] if your data layer expects slug. Case sensitivity is the trap here: macOS and Windows filesystems are case-insensitive by default, so [Slug] and [slug] can coexist peacefully on your machine and then collide or vanish entirely on Linux, which is what Vercel builds on.

ls -la src/app/courses

Look at the exact casing and spacing. If you renamed a folder at any point, git mv it explicitly rather than renaming in your editor — a plain rename can sometimes register as a delete-and-recreate that a stale git index doesn't pick up cleanly.

3. Check what generateStaticParams is actually returning

If you're using static generation, a missing or misbehaving generateStaticParams means Next.js never knows the route exists at build time:

export async function generateStaticParams() {
  const courses = await getAllCourses();
  return courses.map((course) => ({ slug: course.slug }));
}

Two things to verify here: that getAllCourses() doesn't depend on something unavailable at build time (a database connection, an environment variable that isn't set on Vercel), and that the key you return — slug — matches the folder name exactly. If your folder is [slug] but you're returning { id: course.id }, Next.js has nothing to match against.

4. Check your data layer's environment assumptions

If your course or content data comes from a local JSON file or a centralized data module, make sure that module doesn't silently return an empty array in production because of a path or environment difference. Add a temporary log inside generateStaticParams and check the Vercel build logs — not the runtime logs, the build logs — to see what it actually returned during the build step.

5. Look at the Vercel build output directly

Under a deployment's Build Logs, Next.js prints every route it generated, along with whether it's static or dynamic. If /courses/sap-mm isn't in that list, the problem is upstream of runtime entirely — it's a build-time generation issue, not a routing issue, and no amount of checking your next.config will fix it.

The pattern behind all of this

Every one of these is a mismatch between what your local machine believes and what actually ships. Locally, you have every file, the casing rarely bites you, and your data layer often has more lenient fallbacks. Production has none of that generosity. When a dynamic route works everywhere except production, start from "what's different about this environment" rather than "what's wrong with my routing code" — the routing code is very often fine.