Summary
When notFound() is called inside generateMetadata, the not-found boundary renders but the HTTP status stays 200. Calling notFound() from the page component gives a correct 404.
The result is a soft 404. The page looks like a normal page to crawlers, so bad URLs get treated as real content.
Reproduction
Whole app is four files. No data layer, no await needed.
package.json
{
"name": "vinext-notfound-repro",
"private": true,
"type": "module"
}
npm i react react-dom next
npm i -D vinext vite @vitejs/plugin-react @vitejs/plugin-rsc react-server-dom-webpack
vite.config.ts
import { defineConfig } from 'vite';
import vinext from 'vinext';
export default defineConfig({
plugins: [vinext()]
});
app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
app/not-found.tsx
export default function NotFound() {
return <h1>404</h1>;
}
app/from-metadata/page.tsx — wrong status
import { notFound } from 'next/navigation';
export async function generateMetadata() {
notFound();
}
export default function Page() {
return <p>should not render</p>;
}
app/from-page/page.tsx — correct status
import { notFound } from 'next/navigation';
export default async function Page() {
notFound();
}
Run it:
npx vinext build
npx vinext start
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/from-metadata # 200 <- wrong
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/from-page # 404 <- correct
/from-metadata returns 200 but the body is the rendered not-found boundary (<h1>404</h1>), so the boundary itself works. Only the status is wrong.
What I checked
Where notFound() is called |
Status |
generateMetadata |
200 |
| Page component |
404 |
Page component, after an await |
404 |
Page component, alongside a generateMetadata that resolves normally |
404 |
Only the first row is wrong. These make no difference to the result:
vinext start (Node) vs wrangler dev (Workers, via @cloudflare/vite-plugin)
- an
await before notFound()
- a server component vs
'use client' not-found.tsx
Where it looks like it comes from
src/server/app-page-execution.ts tags redirects and notFound thrown from generateMetadata with a marker:
const APP_PAGE_METADATA_ERROR_MARKER = Symbol.for("vinext.appPage.metadataError");
resolveAppPageSpecialError() then carries that flag through for both error kinds:
const httpError = parseNextHttpErrorDigest(digest);
if (httpError) return {
kind: "http-access-fallback",
statusCode: httpError.status,
...fromMetadata ? { fromMetadata: true } : {}
};
The comment above the marker explains the redirect case: metadata is streamed, so once the head has flushed the response cannot switch to a 307, and a 200 with a refresh meta tag is used instead.
That reasoning is clear for redirect(). For notFound() the same fromMetadata path leaves a 200, and here the status is the part that matters, since a soft 404 can get crawled and indexed where a real 404 would not.
Question
Is the 200 intended for notFound() as well, or only for redirect()?
If the streamed-head limit applies to both, it would be worth documenting, because the workaround in app code is to move the check out of generateMetadata and into the page component. If it is not intended, the http-access-fallback branch may need to be handled separately from the redirect branch so the status still gets applied.
Environment
- vinext
1.0.0-beta.4
- vite
8.1.5, next 16.2.12, react 19.2.8
- Node
v25.2.1
- App Router,
cacheComponents not enabled
- Also reproduced on Cloudflare Workers with
@cloudflare/vite-plugin 1.47.0
Summary
When
notFound()is called insidegenerateMetadata, the not-found boundary renders but the HTTP status stays200. CallingnotFound()from the page component gives a correct404.The result is a soft 404. The page looks like a normal page to crawlers, so bad URLs get treated as real content.
Reproduction
Whole app is four files. No data layer, no
awaitneeded.package.json{ "name": "vinext-notfound-repro", "private": true, "type": "module" }vite.config.tsapp/layout.tsxapp/not-found.tsxapp/from-metadata/page.tsx— wrong statusapp/from-page/page.tsx— correct statusRun it:
/from-metadatareturns200but the body is the rendered not-found boundary (<h1>404</h1>), so the boundary itself works. Only the status is wrong.What I checked
notFound()is calledgenerateMetadataawaitgenerateMetadatathat resolves normallyOnly the first row is wrong. These make no difference to the result:
vinext start(Node) vswrangler dev(Workers, via@cloudflare/vite-plugin)awaitbeforenotFound()'use client'not-found.tsxWhere it looks like it comes from
src/server/app-page-execution.tstags redirects andnotFoundthrown fromgenerateMetadatawith a marker:resolveAppPageSpecialError()then carries that flag through for both error kinds:The comment above the marker explains the redirect case: metadata is streamed, so once the head has flushed the response cannot switch to a 307, and a
200with a refresh meta tag is used instead.That reasoning is clear for
redirect(). FornotFound()the samefromMetadatapath leaves a200, and here the status is the part that matters, since a soft 404 can get crawled and indexed where a real 404 would not.Question
Is the
200intended fornotFound()as well, or only forredirect()?If the streamed-head limit applies to both, it would be worth documenting, because the workaround in app code is to move the check out of
generateMetadataand into the page component. If it is not intended, thehttp-access-fallbackbranch may need to be handled separately from the redirect branch so the status still gets applied.Environment
1.0.0-beta.48.1.5, next16.2.12, react19.2.8v25.2.1cacheComponentsnot enabled@cloudflare/vite-plugin1.47.0