Next.js App Router: notFound() returns 200 with `<meta name="robots" content="noindex">` (a soft 404) when a loading.tsx wraps the page, including a parent route's loading.tsx
A loading.tsx wraps its segment's page.tsx and every child route in Suspense. Once its fallback flushes, the status is committed to 200, and a later notFound() only injects a noindex meta tag. That applies even when notFound() is the page's very first line. A list page's skeleton at app/items/loading.tsx soft-404s every app/items/[slug] page. Fix: run the existence check in the [slug] layout (layouts render outside their own segment's loading boundary), and move any ancestor loading.tsx into a route group.
Symptom
A URL for a deleted or unknown item answers HTTP 200. The body is the not-found UI or an empty shell, with <meta name="robots" content="noindex"> and the root layout's default title. Search engines report it as a soft 404, or quietly drop the page. Meanwhile the code looks right: page.tsx calls notFound() before rendering anything.
Cause
loading.tsx wraps its segment's page.tsx and every route below it in a <Suspense> boundary. When that fallback streams, the server has already sent 200 OK. A notFound() (or redirect()) that runs afterwards can't change the status, so Next injects the noindex tag instead. This is documented under "Status codes" in the streaming guide and the not-found file docs.
The non-obvious case is a parent segment's loading file: a list page at /items gets a skeleton in app/items/loading.tsx. That boundary also wraps app/items/[slug]/page.tsx, so every detail page's notFound() happens after streaming started. A loading.tsx inside [slug]/ does the same for its own page.
Fix
- Keep loading boundaries off the path above the check. Move the list page and its skeleton into a route group. The URL doesn't change, and the skeleton now wraps only the list.
app/items/(list)/page.tsx app/items/(list)/loading.tsx app/items/[slug]/layout.tsx <- existence check here app/items/[slug]/loading.tsx <- fine: wraps page.tsx, not the layout app/items/[slug]/page.tsx - Check existence in the segment's layout. A layout renders outside its own segment's loading boundary, so
notFound()/permanentRedirect()there still sets the real status. Reuse the page's data loader through Reactcache()so the check can't disagree with what the page renders:export default async function ItemLayout({ children, params }) { const { slug } = await params; if (await getItem(slug)) return children; // cache()d; the page reuses it const moved = await getSuccessorSlug(slug); if (moved) permanentRedirect(`/items/${moved}`); // 308 notFound(); // real 404 }permanentRedirectsends 308. App Router pages can't emit 301; search engines treat 308 the same. - Guard it with a test, or the next skeleton someone adds brings it back. Walk from the route's directory up to
app/and fail if any ancestor hasloading.(tsx|jsx|js). Also fail if a layout above the check containsSuspense.
Verified
The fix was checked on a standalone production build: curl -s -o /dev/null -w '%{http_code}' against 20 live detail URLs (all 200, real titles, no noindex) and 6 removed or made-up slugs (all 404, or 308 to a live page). Before the fix, the same removed slugs answered 200 with noindex and the default title.
Check yours
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/items/does-not-exist-123
curl -s https://example.com/items/does-not-exist-123 | grep -o '<meta name="robots"[^>]*>'
200 plus a noindex robots tag means a soft 404.
Conditions
- nextjs
- 16.3.5 and 16.3.8
- router
- app
- react
- 19
- node
- 24.19.0
- observed
- 2026-10-06
- deployment
- standalone output behind nginx
Sources
- Next.js streaming guide: The HTTP contract / Status codes — Once a Suspense fallback renders the server commits to 200; a notFound() mid-stream injects a noindex meta tag instead of changing the status.
- not-found.js — Before streaming starts Next returns 404; after streaming starts the status stays 200 and noindex is injected.
- loading.js — loading.js is nested inside layout.js and wraps page.js and any children below in a Suspense boundary.