Exploration of workarounds for vercel/next.js#92919 — "I need a way to read POST body on a page route." Two paths are implemented; both preserve step-to-step state without cookies, localStorage, query params, or hidden client state machines.
Works with JavaScript disabled. Native browser form POST → route.ts extracts the body → internally fetches a sibling page with the body forwarded in a header → streams HTML back. The browser sees a standard POST/response pair; URL bar reads /form/confirm.
app/form/page.tsx # email form, action="/form/confirm"
app/form/confirm/route.ts # POST: read body → fetch view → return Response
# GET: redirect to /form
app/form/confirm/view/page.tsx # reads header via next/headers; HMAC-gated
app/form/confirm/proxy-token.ts # sign() / verify() — short-lived HMAC
app/form/verify/route.ts # OTP verify; success redirects, failure re-renders view
Why confirm/view/ and not confirm/? route.ts and page.tsx cannot share a URL segment. The HMAC proxy token stops direct hits to the internal view.
Tradeoffs: extra internal HTTP hop per submit; body has to fit in a header (~8KB ceiling on most servers).
Requires JavaScript. Server action receives FormData, returns the JSX of the next step (an RSC fragment). useActionState replaces the form contents with whatever the server returned. No URL change, no page swap, no cookies.
app/form-ui/page.tsx # server component, mounts <Flow initialUi={<EmailStep />} />
app/form-ui/flow.tsx # 'use client' — useActionState(stepAction, initialUi)
app/form-ui/actions.tsx # 'use server' — returns <EmailStep /> or <OtpStep /> based on formData
app/form-ui/steps.tsx # step components (form contents only, no <form> wrapper)
The returned JSX is the state. Inter-step data travels via hidden inputs inside the returned fragment.
Tradeoffs: needs JS; single history entry (no back-button per step).
Proxy POST (/form) |
Server-action UI (/form-ui) |
|
|---|---|---|
| Works without JS | yes | no |
| URL changes per step | yes | no |
| History entry per step | yes | no |
| Server state | none | none |
| Extra server hop | one internal fetch | none |
pnpm install
pnpm devThen open http://localhost:3000. Token 0000 is the only valid OTP.
Set FORM_PROXY_SECRET in production — the HMAC falls back to a dev-only constant otherwise.