Repository: vercel/next.js
Pull request: Use metadata for cache entry status code

This avoids relying on the `res.statusCode` for generating a cache entry during a background revalidate as it shouldn't be coupled to the current response. 

x-ref: [slack thread](https://vercel.slack.com/archives/C08TP4DCGAG/p1747936529140749)

Changed files:
filename: packages/next/src/server/app-render/action-handler.ts
patch: @@ -23,7 +23,9 @@ import {
   isRedirectError,
   type RedirectType,
 } from '../../client/components/redirect-error'
-import RenderResult from '../render-result'
+import RenderResult, {
+  type AppPageRenderResultMetadata,
+} from '../render-result'
 import type { WorkStore } from '../app-render/work-async-storage.external'
 import { FlightRenderResult } from './flight-render-result'
 import {
@@ -454,6 +456,7 @@ export async function handleAction({
   requestStore,
   serverActions,
   ctx,
+  metadata,
 }: {
   req: BaseNextRequest
   res: BaseNextResponse
@@ -464,6 +467,7 @@ export async function handleAction({
   requestStore: RequestStore
   serverActions?: ServerActionsConfig
   ctx: AppRenderContext
+  metadata: AppPageRenderResultMetadata
 }): Promise<
   | undefined
   | {
@@ -552,6 +556,7 @@ export async function handleAction({
 
       if (isFetchAction) {
         res.statusCode = 500
+        metadata.statusCode = 500
 
         const promise = Promise.reject(error)
         try {
@@ -950,6 +955,7 @@ export async function handleAction({
       // if it's a fetch action, we'll set the status code for logging/debugging purposes
       // but we won't set a Location header, as the redirect will be handled by the client router
       res.statusCode = RedirectStatusCode.SeeOther
+      metadata.statusCode = RedirectStatusCode.SeeOther
 
       if (isFetchAction) {
         return {
@@ -973,6 +979,7 @@ export async function handleAction({
       }
     } else if (isHTTPAccessFallbackError(err)) {
       res.statusCode = getAccessFallbackHTTPStatus(err)
+      metadata.statusCode = res.statusCode
 
       if (isFetchAction) {
         const promise = Promise.reject(err)
@@ -1004,6 +1011,7 @@ export async function handleAction({
       // so that we can respond with a 413 to requests that break the body size limit
       // (but if we do that, we also need to make sure that whatever handles the non-fetch error path below does the same)
       res.statusCode = 500
+      metadata.statusCode = 500
       const promise = Promise.reject(err)
       try {
         // we need to await the promise to trigger the rejection early

filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -1287,7 +1287,9 @@ async function renderToHTMLOrFlightImpl(
     })
   }
 
-  const metadata: AppPageRenderResultMetadata = {}
+  const metadata: AppPageRenderResultMetadata = {
+    statusCode: isNotFoundPath ? 404 : undefined,
+  }
 
   const appUsingSizeAdjustment = !!nextFontManifest?.appUsingSizeAdjust
 
@@ -1569,20 +1571,23 @@ async function renderToHTMLOrFlightImpl(
         requestStore,
         serverActions,
         ctx,
+        metadata,
       })
 
       if (actionRequestResult) {
         if (actionRequestResult.type === 'not-found') {
           const notFoundLoaderTree = createNotFoundLoaderTree(loaderTree)
           res.statusCode = 404
+          metadata.statusCode = 404
           const stream = await renderToStreamWithTracing(
             requestStore,
             req,
             res,
             ctx,
             notFoundLoaderTree,
             formState,
-            postponedState
+            postponedState,
+            metadata
           )
 
           return new RenderResult(stream, { metadata })
@@ -1608,7 +1613,8 @@ async function renderToHTMLOrFlightImpl(
       ctx,
       loaderTree,
       formState,
-      postponedState
+      postponedState,
+      metadata
     )
 
     if (workStore.invalidDynamicUsageError) {
@@ -1743,7 +1749,8 @@ async function renderToStream(
   ctx: AppRenderContext,
   tree: LoaderTree,
   formState: any,
-  postponedState: PostponedState | null
+  postponedState: PostponedState | null,
+  metadata: AppPageRenderResultMetadata
 ): Promise<ReadableStream<Uint8Array>> {
   const { assetPrefix, nonce, pagePath, renderOpts } = ctx
 
@@ -2093,10 +2100,12 @@ async function renderToStream(
 
     if (isHTTPAccessFallbackError(err)) {
       res.statusCode = getAccessFallbackHTTPStatus(err)
+      metadata.statusCode = res.statusCode
       errorType = getAccessFallbackErrorTypeByStatus(res.statusCode)
     } else if (isRedirectError(err)) {
       errorType = 'redirect'
       res.statusCode = getRedirectStatusCodeFromError(err)
+      metadata.statusCode = res.statusCode
 
       const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath)
 
@@ -2110,6 +2119,7 @@ async function renderToStream(
       setHeader('location', redirectUrl)
     } else if (!shouldBailoutToCSR) {
       res.statusCode = 500
+      metadata.statusCode = res.statusCode
     }
 
     const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts(
@@ -3726,16 +3736,19 @@ async function prerenderToStream(
 
     if (isHTTPAccessFallbackError(err)) {
       res.statusCode = getAccessFallbackHTTPStatus(err)
+      metadata.statusCode = res.statusCode
       errorType = getAccessFallbackErrorTypeByStatus(res.statusCode)
     } else if (isRedirectError(err)) {
       errorType = 'redirect'
       res.statusCode = getRedirectStatusCodeFromError(err)
+      metadata.statusCode = res.statusCode
 
       const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath)
 
       setHeader('location', redirectUrl)
     } else if (!shouldBailoutToCSR) {
       res.statusCode = 500
+      metadata.statusCode = res.statusCode
     }
 
     const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts(

filename: packages/next/src/server/base-server.ts
patch: @@ -2960,7 +2960,7 @@ export default abstract class Server<
             headers,
             rscData: metadata.flightData,
             postponed: metadata.postponed,
-            status: res.statusCode,
+            status: metadata.statusCode,
             segmentData: metadata.segmentData,
           } satisfies CachedAppPageValue,
           cacheControl,

filename: packages/next/src/server/render-result.ts
patch: @@ -31,6 +31,7 @@ export type AppPageRenderResultMetadata = {
    * The headers to set on the response that were added by the render.
    */
   headers?: OutgoingHttpHeaders
+  statusCode?: number
   fetchTags?: string
   fetchMetrics?: FetchMetrics