~ / insights / guides

Guide · September 2026

Your 404 Page Belongs to the Framework.

A branded 404 page sat unused in the web root while every missing URL got the JavaScript framework's default error. The cause was one nginx fall-through, and the fix is four lines.

Amit TiwariGuide5 min read

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

try_files is a queue, and the last entry catches everything 1 try $uri: is there a file at exactly this path? 2 try $uri.html: is there a file with .html appended? 3 try $uri/index.html: is there a directory index? 4 @next: hand everything else to the application, typos included
Figure 1. The convenience and the problem are the same line.

Interactive: type a path and see which try_files branch answers. Needs JavaScript.

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

The three things a missing URL can return Users see your brand Google sees a real 404 Action 404 status, framework default body Cosmetic: fix ownership 404 status, branded body Done 200 status, any not-found body Soft 404: fix the status first
Figure 2. Test the status, not the look. The bottom row is the one that costs rankings.

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

Who owns the unknown URL Fall-through (as found) location / { try_files … @next; } Every unknown URL reaches the app App answers with its default not-found page Branded 404.html never served Explicit ownership (fixed) location ^~ /app-prefix/ { proxy_pass …; } location / { try_files … =404; } error_page 404 /404.html; location = /404.html { internal; } Or fix the framework side with app/not-found.js when the app genuinely needs root-level catch-all routes.
Figure 3. Four lines of nginx. The static tree’s 404.html starts answering.

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

How to cite this guide

Amit Tiwari (2026). Your 404 Page Belongs to the Framework. Guide, September 2026. amittiwari.net. https://amittiwari.net/guides/your-404-page-belongs-to-the-framework

Discuss in the community ↗