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
| Cause | How to recognise it | Fix |
|---|---|---|
| `output: "standalone"` without assets | HTML 200, CSS and JS 404 | copy `.next/static` and `public` |
| Different `basePath` | every internal URL is wrong | align links and configuration |
| `trailingSlash` changed | redirect or 404 on URLs with and without the slash | pick one form and keep it |
| dynamic route not generated | 404 on some slugs only | add `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
publicand.next/staticexist 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.