Skip to content

16.2.3 / 16.3.0-canary.2: notFound() returns HTTP 200 when sibling loading.tsx is present (HTTPAccessFallbackBoundary swallows 404 status under streaming) #93253

@holdenjrussell

Description

@holdenjrussell

Link to the code that reproduces this issue

https://stackblitz.com/github/holdenjrussell/next-93008-repro

(Backed by the GitHub repo at https://github.com/holdenjrussell/next-93008-repro, which is the same minimal app the StackBlitz loads. StackBlitz used per the bug-report template and to clear the auto-close bot heuristic — three previous re-files of this verification with raw github.com links were rejected by github-actions[bot] with the invalid link label even though the linked repo contains the full, runnable, three-file repro.)

To Reproduce

In the StackBlitz / repo:

pnpm install
pnpm build
pnpm start --port 3199
curl -sI http://localhost:3199/missing   # any id !== "real"
curl -sI http://localhost:3199/real

The repo contains three files:

app/[id]/page.tsx

import { notFound } from "next/navigation";

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  if (id !== "real") notFound();
  return <h1>ok</h1>;
}

app/[id]/not-found.tsx

export default function NotFound() {
  return <h1>not found</h1>;
}

app/[id]/loading.tsx

export default function Loading() {
  return null;
}

/missing returns:

HTTP/1.1 200 OK

…with the rendered body being the correct not-found.tsx content. /real correctly returns 200. The 404 status is silently swallowed.

Current vs. Expected behavior

Actual: any id other than real renders the not-found.tsx tree as the response body, but HTTP status is 200 OK.

Expected: HTTP status is 404 Not Found, regardless of whether the response body is streamed.

Tracing the code path in next@16.2.3 (also reproduces on 16.3.0-canary.2):

  • app-render.js:1894continueFizzStream flushes the response headers as soon as the shell renders.
  • The server component calls notFound(). The nearest HTTPAccessFallbackBoundary (auto-injected around not-found.tsx) catches the throw and renders the not-found tree inline.
  • Because the boundary swallows the error, the framework-level handler at app-render.js:1949 that would set res.statusCode = 404 never runs.
  • export const dynamic = 'force-dynamic' does NOT flip generateStaticHTML off this branch.
  • Removing loading.tsx (which is what triggers Suspense wrapping) is the only framework-level workaround.

Prior art

Why this matters

Any app with streaming enabled (i.e., loading.tsx present anywhere in the segment tree) is forced to implement a middleware/proxy-layer 404 shim that does its own existence check against the DB before the route renders. We wrote one here:

https://github.com/holden-cgk/cgk-dashboard/blob/main/src/proxy.ts

…which duplicates work the page.tsx already does on valid loads. A framework-native path (e.g., "if a component throws notFound() inside the boundary, persist that to the response status even on streamed responses") would let us retire the shim and avoid the duplicate DB round-trip per detail-page load.

Provide environment information

Operating System:
  Platform: linux
  Arch: x64
Binaries:
  Node: v24.14.1
Relevant Packages:
  next: 16.2.3 (also reproduces on 16.3.0-canary.2)
  react: 19.2.5

Which area(s) are affected? (Select all that apply)

Dynamic Routes, Linking and Navigating, Not Found

Which stage(s) are affected? (Select all that apply)

next dev (local), next start (local), Vercel (Deployed)

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release (next@16.3.0-canary.2).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions