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
- Build and serve a Payload 3.75 + Next 16 app (
next build && next start) — this does not reproduce reliably under next dev.
- Request the admin route without an auth cookie:
curl -sS -o /dev/null -D - http://localhost:3000/admin
- 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?
Summary
On Next.js 16, an unauthenticated request to
/adminreturns HTTP 200 carrying the loading shell and adata-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@payloadcms/next@payloadcms/uinextreactReproduction
next build && next start) — this does not reproduce reliably undernext dev.HTTP/1.1 307 Temporary Redirectwith alocationheader.Actual:
HTTP/1.1 200 OK, body containingdata-dgst="NEXT_REDIRECT;…;307;".Analysis
The redirect is thrown here:
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:
So
@payloadcms/nextcontains no Suspense boundary of its own; the boundary that intercepts the throw comes from the@payloadcms/uicomponents rendered beneathRootPage.This is the same class of problem as an application-level route group placing a
loading.tsxabove routes that callnotFound()orredirect()— 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
/adminas 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
/adminin 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
RootPageintentional? 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?