Skip to content

notFound() inside generateMetadata responds 200 instead of 404 #2725

Description

@eashish93

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions