payloadcms / payloadcms/payload

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

Open
#17,775 0 comments 0 reactions 0 assignees View on GitHub

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
  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?

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.