payloadcms / payloadcms/payload
/admin auth redirect answers HTTP 200 with a suppressed NEXT_REDIRECT marker instead of 307
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 44.8k
- Forks
- 4.2k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 53
Description
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 undernext 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 Redirectwith alocationheader.
Actual:HTTP/1.1 200 OK, body containingdata-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?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with @payloadcms/next/dist/views/Root/index.js at the auth redirect call, then inspect the Suspense usage under @payloadcms/ui/dist. Reproduce with a production build using curl against /admin without an auth cookie and compare the response status, location header, and body marker. Done means the unauthenticated route returns a real HTTP 307 without breaking the admin UI or supported consumer behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nextjs, react, typescript
- Domain
- authentication, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100