---
title: "How to return a real 404 with a recoverable body on Next.js"
slug: fix-soft-404-nextjs
published: 2026-10-10T13:16:15.200104+00:00
updated: 2026-10-10T13:16:15.200104+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: check:E1, platform:nextjs, Next.js, App Router, 404, soft 404, proxy.ts, AI readiness
description: "Next.js returns 200 for notFound() when loading.tsx streams first. Reproduce the soft 404, fix it with proxy.ts, then verify with curl and the AIScan E1 check."
url: https://aiscan.site/blog/fix-soft-404-nextjs
---

Next.js has a built-in 404 path, and it works until a `loading.tsx` file sits in the same route segment. Then a missing record returns **HTTP 200** with a "not found" page and a `noindex` tag. Agents that read only the status line (AIScan check **E1**) record that URL as valid. This guide shows the failing case, the fix, and the `curl` that proves it, all measured on Next.js 16.4.0 (App Router) on 10 October 2026.

## Quick summary

| Situation | What Next.js returns | Fix |
|---|---|---|
| Unmatched URL, root `app/not-found.tsx` | 404 with your page | None needed |
| `notFound()` in a page with no `loading.tsx` | 404 with your page | None needed |
| `notFound()` in a segment that has `loading.tsx` | **200**, not-found UI, `noindex` | Check in `proxy.ts` and rewrite |
| Not-found page with no links | 404, dead-end body | Add recovery links |

**Fastest route:** run `npx aiscan-cli yoursite.com` and read **E1 — Correct 404 handling**. The by-hand check is at the end.

## Why the status code goes wrong

HTTP sends the status line before the body. [The Next.js `not-found.js` reference](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) says that before streaming starts Next.js returns a `404`, and after streaming starts the status stays `200` while the not-found UI renders. A `loading.tsx` file wraps the page in a Suspense boundary, so the shell streams first. The 200 is already on the wire when `notFound()` throws. The [`loading.js` reference](https://nextjs.org/docs/app/api-reference/file-conventions/loading#status-codes) adds that the `noindex` tag keeps these pages out of search results, which helps Google but does nothing for an agent that never reads the HTML head.

## Step 1: Reproduce it

I built a minimal app with two dynamic routes. Both call `notFound()` for unknown IDs; only `app/shop/[id]/` has a `loading.tsx`.

```text
app/not-found.tsx
app/blog/[slug]/page.tsx      # notFound() for unknown slug
app/shop/[id]/page.tsx        # notFound() for unknown id
app/shop/[id]/loading.tsx
```

After `npx next build` and `npx next start`:

```bash
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/nowhere     # 404
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/blog/nope   # 404
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/shop/nope   # 200  <- soft 404
```

The unmatched URL and the `/blog/` miss both return 404. The `/shop/` miss returns 200 and still carries `<meta name="robots" content="noindex"/>`.

## Step 2: Write a not-found page agents can use

Create or edit **app/not-found.tsx**. AIScan's E1 asks for a body that names the site and links somewhere useful, so an empty "Not Found" fails even with a correct status.

```tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <main>
      <h1>Page not found</h1>
      <p>This page is not on example.com. Try these instead:</p>
      <ul>
        <li><Link href="/">Home</Link></li>
        <li><Link href="/blog">Blog</Link></li>
        <li><Link href="/sitemap.xml">Sitemap</Link></li>
      </ul>
    </main>
  )
}
```

Next.js renders this for both unmatched URLs and `notFound()` calls. It is a Server Component by default, so the links appear in the raw HTML.

## Step 3: Pick the fix for the 200

**Branch A: you can drop `loading.tsx`.** Delete **app/shop/[id]/loading.tsx**. In my test the sibling `/blog/[slug]` route, which had none, returned a true 404. Choose this if the loading skeleton is not essential.

**Branch B: you need the loading state.** Decide before streaming begins. The `loading.js` reference points to [Proxy](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) for this. Create **proxy.ts** in the project root (next to `app/`):

```ts
import { NextResponse, type NextRequest } from 'next/server'

const known = new Set(['1', '2']) // your real IDs or a fast lookup

export function proxy(req: NextRequest) {
  const id = req.nextUrl.pathname.split('/')[2]
  if (!known.has(id)) {
    return NextResponse.rewrite(new URL('/not-a-route', req.url))
  }
}

export const config = { matcher: '/shop/:id' }
```

The rewrite sends the request to a path no route matches, so Next.js serves `app/not-found.tsx` with a real 404. Keep the lookup cheap: the docs warn against fetching full content in Proxy. In Next.js 16 the file is `proxy.ts` and the export is `proxy`; the build output still labels it "Middleware".

After this change, `/shop/nope` returned `404` with the recovery links, and `/shop/1` still returned `200` with its loading state.

## Step 4: Verify

Run the scan first. It requests a random path and grades the status and body:

```bash
npx aiscan-cli yoursite.com
```

Look for **E1 — Correct 404 handling** passing. Then, by hand:

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://yoursite.com/nope-123
curl -s https://yoursite.com/nope-123 | grep -c '<a href'
```

Expected: `404`, then a count of 3 or more. Repeat for one real dynamic URL that does not exist, such as `/shop/nope`. The random path alone passes while the soft 404 hides inside a `loading.tsx` segment.

## Edge cases

- **Hosts and CDNs.** A reverse proxy that rewrites 404s to a friendly 200 page undoes all of this. Test the public URL, not `localhost`.
- **A `notFound()` call nobody awaits** throws where nothing catches it, and no not-found UI renders, per the `notFound` reference.
- **Maintenance.** Re-run the `/shop/nope` check after adding any new `loading.tsx`.

## Next steps

Run the scan on your own domain and confirm E1. For why agents treat a 200 error page as content, read [the soft-404 discovery problem](https://aiscan.site/blog/soft-404-agent-discovery-problem). For the content-negotiation side of a good 404 body, see [correct 404 handling for AI agents](https://aiscan.site/blog/correct-404-for-ai-agents). The rest of the Next.js setup lives in [the Next.js AI-readiness setup guide](https://aiscan.site/blog/ai-readiness-setup-nextjs). Check details are on the [discoverability checks page](https://aiscan.site/docs/checks/discoverability) and the [Next.js platform page](https://aiscan.site/docs/platforms/nextjs). More fixes are in the [AIScan guides](https://aiscan.site/guides).
