AIScan fix guide cover: headline 'A 404 that says 404', a Next.js logo chip and a broken page changing to a fixed page.
AIScan fix guide cover: headline 'A 404 that says 404', a Next.js logo chip and a broken page changing to a fixed page.
AI Readiness

How to return a real 404 with a recoverable body on Next.js

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.

AAIScan Content Team 10 Oct 2026 5 min read
#Next.js#App Router#404#soft 404#proxy.ts#AI readiness

This guide covers E1 · Discoverability — for Next.js / Vercel.

Table of contents

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

SituationWhat Next.js returnsFix
Unmatched URL, root app/not-found.tsx404 with your pageNone needed
notFound() in a page with no loading.tsx404 with your pageNone needed
notFound() in a segment that has loading.tsx200, not-found UI, noindexCheck in proxy.ts and rewrite
Not-found page with no links404, dead-end bodyAdd 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 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 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.

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:

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.

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 for this. Create proxy.ts in the project root (next to app/):

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:

npx aiscan-cli yoursite.com

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

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. For the content-negotiation side of a good 404 body, see correct 404 handling for AI agents. The rest of the Next.js setup lives in the Next.js AI-readiness setup guide. Check details are on the discoverability checks page and the Next.js platform page. More fixes are in the AIScan guides.

Frequently asked questions

Why does my Next.js not-found page return 200 instead of 404?

Because the status line was sent before notFound() ran. A loading.tsx in the same segment streams the shell first, so the status stays 200. Next.js still adds a noindex meta tag. Move the check into proxy.ts or remove loading.tsx.

Does the noindex tag fix a soft 404?

It keeps the page out of Google's results, but an agent reading only the status line still sees 200. AIScan's E1 check grades the status code, so the noindex tag does not make the check pass.

curl returns 404 for /nowhere but 200 for /shop/nope. Is that a bug?

No. An unmatched URL never enters a Suspense boundary, so it gets 404. A dynamic segment with loading.tsx streams first. Fix the dynamic route with a proxy.ts rewrite.

What does E1 need in the 404 body?

A body that names the site and links somewhere useful, such as the homepage, sitemap or blog. An empty page fails even with a 404 status. Put the links in app/not-found.tsx.

My proxy.ts rewrite does nothing. What should I check?

Confirm the file sits next to the app folder, exports a function named proxy, and that config.matcher covers the path. In Next.js 16 the build output lists it as Proxy (Middleware). Older versions used middleware.ts.

The build says my proxy.ts is deprecated or ignored. Why?

In Next.js 16 the middleware file is called proxy.ts with a proxy export. Check your installed version. Earlier releases read middleware.ts with a middleware export.

Is a Cloudflare or Vercel error page hiding my 404?

It can be. If a CDN rule rewrites 404s to a custom 200 page, the origin is fine but the public URL fails. Run curl against the live domain, not localhost.

Do I need a paid tool to check this?

No. The curl commands in step 4 are enough. AIScan runs the same check for free with npx aiscan-cli.

Related guides