The setup is a common one. A static site carries the marketing pages, and an application server on localhost handles a handful of dynamic routes, with nginx deciding who answers what. The deciding line usually looks like this:
location / {
try_files $uri $uri.html $uri/index.html @next;
}
Read it as a queue. For each request, nginx tries the exact file, then the file with .html appended, then a directory index, and if none exists it hands the request to the named location, here a proxy to the application. That last hop is the convenience and the problem. It means “anything I do not recognise belongs to the app”, and one thing the static tree does not recognise is a typo. So every missing URL on the marketing site becomes the application’s problem, and the application answers with its own default not-found page.
Step 1. See what a missing URL actually returns
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/no-such-page
curl -s https://example.com/no-such-page | head -c 300
Three outcomes are possible, and each means something different. A 404 status with the framework’s default body is the cosmetic version of the problem. A 404 with your branded body means you are done. And a 200 with any not-found body is the serious version: URLs that claim to exist while saying “not found” are soft 404s, and Google treats a site that answers 200 for everything accordingly. Next.js’s built-in not-found page does send a real 404 status by default [VERIFY: confirm on the version in production], but a custom server, a catch-all route or a misconfigured rewrite can turn it into a 200, so test the status rather than assuming it.
Step 2. Decide which layer should own the 404
There are two clean options.
Let nginx own it, so the static tree’s branded 404.html answers for the whole site. This is right when the marketing site is the bulk of the URLs and the application handles a few known prefixes.
Let the framework own it, by building the branded page inside the application (in the Next.js App Router, an app/not-found.js file). This is right when the application handles most routes and the static pages are the exception. Either is fine; the failure mode is nobody owning it, which is how the default ships.
Step 3. The nginx fix
This takes two changes. First, stop sending unknown URLs to the application by making the app’s territory explicit instead of leaving it as the fall-through:
location ^~ /app-prefix/ {
proxy_pass http://127.0.0.1:3000;
}
location / {
try_files $uri $uri.html $uri/index.html =404;
}
^~ gives the prefix block priority over regex locations, and =404 replaces the hand-off so nginx generates the error itself. Second, point the error at the branded page:
error_page 404 /404.html;
location = /404.html { internal; }
internal stops the page being requested directly as a 200 at /404.html, which would otherwise create a live duplicate of your error page in the index.
If the application genuinely needs URLs at the root that cannot be listed as prefixes, keep the fall-through but fix the framework side instead, because nginx then has no way to know which unknown URLs are the app’s.
Step 4. The framework fix
In the Next.js App Router, create app/not-found.js with the branded markup, and the framework serves it with a 404 status for every unmatched route it receives. Equivalent hooks exist in every framework; the point is that whichever layer receives unknown URLs must serve the branded page with the correct status, and after step 2 you know which layer that is.
Check it worked
Run four requests. A made-up URL under the marketing pages returns 404 with the branded body. A made-up URL under the application’s prefix returns 404 with the branded body from whichever layer owns it. A real application route still returns 200. And /404.html itself returns 404, not 200, because of the internal directive. Then run one of the made-up URLs through Search Console’s URL Inspection and confirm Google reports “Not found (404)” rather than “Soft 404”.
Where I could be wrong
The guide assumes the nginx and Next.js pairing because that is the setup it came from; Apache’s ErrorDocument and other frameworks’ not-found hooks are direct equivalents, but the priority rules around ^~ are nginx-specific and do not transfer.
There is also a judgement call hiding in step 2. Consolidating unknown URLs onto the static layer means the application never sees them, which is what you want until the day the application legitimately needs a catch-all route. If that day comes, the ownership decision has to be made again, and the test in step 1 is the thing to re-run.
Sources
- nginx documentation, try_files directive
- nginx documentation, error_page directive
- nginx documentation, location matching
- Next.js documentation, not-found file convention
- Google Search Central, soft 404 errors