Errors and fixes

Next.js App Router: 404 after deploy with no build errors

The build passes, the page does not exist in production. Usually one of three causes: missing static files, a different path prefix, an ungenerated route.

Key points

  • With `output: "standalone"` you must copy `.next/static` and `public` yourself, or the page loads without its assets.
  • `basePath` and `trailingSlash` change the URLs: check the real path, not the one you see locally.
  • Dynamic routes with `dynamicParams = false` answer 404 for every parameter you did not generate.

Short answer: the page exists in the build but not in the deployment. Check three things in this order: static files copied, the real path with basePath and trailingSlash, and the dynamic route generated.

The causes, by frequency

CauseHow to recognise itFix
`output: "standalone"` without assetsHTML 200, CSS and JS 404copy `.next/static` and `public`
Different `basePath`every internal URL is wrongalign links and configuration
`trailingSlash` changedredirect or 404 on URLs with and without the slashpick one form and keep it
dynamic route not generated404 on some slugs onlyadd `generateStaticParams`

The mechanism: three layers that can answer 404

A request crosses three layers and each can close it with the same code. The first is infrastructure: a reverse proxy, CDN or ingress that does not know the path, or knows it with a different prefix. The second is the Next.js middleware: a rewrite or a matcher can divert the request before the segment router sees it. The third is the segment itself: no page.tsx matches, or the code calls notFound() because the data is absent. The three cases produce the same HTML body and the same status, but the logs speak different languages: the first leaves no trace in the application, the third does.

A diagnostic step before you rebuild

Before changing configuration, compare the list of generated routes with what the server answers. next build writes .next/prerender-manifest.json, which lists the prerendered routes: if the page answering 404 is not in there, the problem sits above the deployment. If it is in there and still answers 404, the problem is the path you are calling.

# 1. quante rotte ha davvero generato la build
node -e "const m=require('./.next/prerender-manifest.json');const r=Object.keys(m.routes||{});console.log(r.length,r.slice(0,3))"

# 2. quale percorso risponde davvero
curl -o /dev/null -s -w "%{http_code} %{redirect_url}\n" https://example.com/blog/mio-post

The fix for standalone

# dopo next build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
node .next/standalone/server.js

Those are two copies Next.js does not make for you. In a Dockerfile the same lines belong in the final layer, otherwise the container starts with the HTML and without the assets.

The fix for dynamic routes

// app/blog/[slug]/page.tsx
export const dynamicParams = false;

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

With dynamicParams = false any slug missing from the list answers 404. That is the intended behaviour, but remember it when you add content without rebuilding.

Diagnosis in three commands

next build            # elenca le rotte generate
curl -I https://example.com/la-pagina   # stato reale
curl -I https://example.com/_next/static/...   # asset

If the HTML answers 200 and the CSS answers 404, the problem is asset copying. If the HTML answers 404, the problem is routing or the path.

When the fix does not work

The quiet case is file case: on Windows About.tsx and about.tsx coexist, on Linux they do not. An import with the wrong case works locally and fails in production. A second case is a middleware rule that rewrites the path before routing: the 404 comes from the middleware, not from the page.

When the common fix makes things worse

The fix that circulates for every 404 is a catch-all route: app/[...slug]/page.tsx returning a not-found page with status 200. The symptom leaves the monitors, the problem stays. Google calls these soft 404s and treats them as errors; visitors see content that looks real; the test that checked the status code stops failing and the regression goes quiet. A catch-all is right for a search page, wrong for masking broken routing: if you keep it, call notFound() inside so the status stays 404.

A limitation with a name: the staleness window

With generateStaticParams the list of pages is fixed at build time. An article published after the deploy has no generated route and, with dynamicParams = false, answers 404 until the next deployment. That is not a bug, it is the definition of the mode. The limitation is called staleness and you handle it by choosing: incremental regeneration through revalidate, a webhook that rebuilds, or dynamicParams = true at the cost of rendering on request.

How to prevent it

  • In the Dockerfile, check that public and .next/static exist in the final layer.
  • Test URLs with and without the slash after every configuration change.
  • Add a post-deploy smoke test on three routes: home, one static page, one dynamic page.

A three-route smoke test covers the general case, not the page you just added. Add a deploy step that diffs the routes in the manifest against a list of expected URLs and fails on a missing entry: it is the only check that sees new content without a route before a visitor does.

Where to go next

In the catalogue: dashboard with a shell, components and blocks. The guides: the App Router structure and the weight of a UI library; the comparison: alternatives to MUI.

Frequently asked questions

Why does it work locally?

Locally Next.js serves public and .next/static from the development folder. In production with standalone those files are not included automatically.

How do I see the generated routes?

The next build output lists every route with its type (static, dynamic, SSG). If yours is missing, the file is not where Next.js looks for it.

Isn't page.tsx in the right folder enough?

The folder must also sit under app. A file in src/pages coexists with App Router but follows a different routing system.

Why a 404 on some pages only?

Usually those are dynamic routes without generateStaticParams. When dynamicParams is false, anything not listed answers 404.

Can the file case be the problem?

Yes. On Linux the file system is case sensitive, on Windows it is not. An import with the wrong case works locally and fails in production.