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
| 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 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 thenotFoundreference. - Maintenance. Re-run the
/shop/nopecheck after adding any newloading.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
What a Failing AIScan Check Really Says: 2,961 Fail Lines Audited
A failing check on a scan report is supposed to be an instruction. The site owner reads the line, knows what the scanner asked for, knows what came back, and knows what to change. We audited whether…
How to return a real 404 with a recoverable body on Wix
Wix gets the status code right and leaves the page empty. Request a URL that does not exist on a Wix site and you get a genuine 404, but the body is a scriptbuilt shell with no text and no links in…
Which Agent-Readiness Checks Travel Together: 739 Sites, Three Bundles
Fix one agentreadiness check and a second one often moves with it. Fix another and nothing else changes. A scan report lists every check as its own line, so the reader cannot see which lines are…
How to return a real 404 with a recoverable body on Docusaurus
Docusaurus builds a real 404.html for you, and then the place you test it hides it. Run npm start, type a madeup URL and you get a friendly page with a 200 status. The production build behaves…
