Skip to content

/admin auth redirect answers HTTP 200 with a suppressed NEXT_REDIRECT marker instead of 307 #17775

Description

@wudomlarp

Summary

On Next.js 16, an unauthenticated request to /admin returns HTTP 200 carrying the loading shell and a data-dgst="NEXT_REDIRECT;…;307;" marker in the payload, rather than a real 307. The redirect only takes effect after client hydration, so the browser lands on the login screen and the behaviour looks correct interactively — but any non-browser consumer (crawler, uptime check, CDN, curl) sees a 200 for a route that should be redirecting.

Environment

payload 3.75.0
@payloadcms/next 3.75.0
@payloadcms/ui 3.75.0
next 16.2.10
react 19.2.0

Reproduction

  1. Build and serve a Payload 3.75 + Next 16 app (next build && next start) — this does not reproduce reliably under next dev.
  2. Request the admin route without an auth cookie:
    curl -sS -o /dev/null -D - http://localhost:3000/admin
  3. Expected: HTTP/1.1 307 Temporary Redirect with a location header.
    Actual: HTTP/1.1 200 OK, body containing data-dgst="NEXT_REDIRECT;…;307;".

Analysis

The redirect is thrown here:

@payloadcms/next/dist/views/Root/index.js:127
    redirect(handleAuthRedirect({ config, route, searchParams, user: req.user }));

In the App Router, redirect() throws a control-flow error that Next assigns a status to only when the Fizz shell render rejects — which happens only when the throwing task has no enclosing Suspense boundary. With a boundary above it, the throw errors that boundary instead of the render, the status is never assigned, and the response is a 200 carrying the marker.

What localises the boundary to the UI package rather than the routing package is a measurable asymmetry between the two:

grep -rl "Suspense" node_modules/@payloadcms/next/dist | wc -l   # 0
grep -rl "Suspense" node_modules/@payloadcms/ui/dist   | wc -l   # 6

So @payloadcms/next contains no Suspense boundary of its own; the boundary that intercepts the throw comes from the @payloadcms/ui components rendered beneath RootPage.

This is the same class of problem as an application-level route group placing a loading.tsx above routes that call notFound() or redirect() — we hit and fixed exactly that in our own (frontend) group, where removing the group-level boundary restored real 404s and 307s. The difference is that here the boundary is inside the library, and the host files Payload generates (src/app/(payload)/…) carry "DO NOT MODIFY" headers, so there is no supported place for a consumer to intervene.

Impact

Low severity for interactive users — the redirect still happens after hydration. It matters for anything reading status codes rather than rendering: crawlers may index /admin as a 200, uptime and security checks report the route as publicly reachable, and CDN caching of a 200 at an auth-gated URL is undesirable.

Workaround, and why we did not take it

Gating /admin in middleware/proxy produces the correct 307, because middleware runs before routing and nothing can intercept it. We deliberately did not do this: our proxy is kept free of database access so that a coming-soon mode opens no MongoDB connections, and Payload's auth check needs exactly that. We have accepted the current behaviour as vendor behaviour rather than contort the proxy around it.

Question

Is the Suspense boundary above RootPage intentional? If so, would you consider performing the auth redirect above it — or exposing a supported hook that lets a consumer redirect before the boundary is established — so the status reaches the wire?

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