Oliver White
25 May 2026 · 7 min read
A user submits their username handle. The API saves it to the database. The button disables. And then nothing happens. The form just sits there — no error, no redirect, no way to click anything. The only way out is a hard browser refresh, which dumps you back on the same page. Three separate bugs, diagnosed in a single session, all from reading network responses rather than logs.
The onboarding page called session.update() from useSession() after a successful API response, then navigated to the homepage. The intent was clear: tell NextAuth the username is now set, then redirect. The middleware guard checks session.user.username — if it is null, redirect back to onboarding. The assumption was that update() would refresh the JWT cookie so the middleware would see the new value.
That assumption is wrong. NextAuth v5 update() is a client-side only operation. It updates the in-memory session object that React components read via useSession(). It makes no HTTP request. It writes nothing to the server. The HTTP-only JWT cookie — the thing middleware reads — is completely unchanged.
NextAuth v5 useSession().update() updates the client-side React session cache only. It does not refresh the JWT cookie. Any middleware or server-side code that reads the session will still see the pre-update token until the next full HTTP request triggers the jwt callback.
So when window.location.href fired and the browser hit the homepage, middleware called auth(), read the JWT cookie, found username: null, and redirected straight back to /onboarding/username. The user never left. The form appeared to reset. The button sat there disabled. Infinite loop, invisible to the user as anything other than the page not working.
The auth.ts jwt callback has three conditions that trigger a database re-fetch. The third one is the key:
The third condition — token.dbId exists but token.username is null — was designed exactly for this scenario. It fires on every incoming request while a user is signed in but has not completed onboarding. This means the fix required no changes to the JWT callback at all. The mechanism was already there.
window.location.replace() vs href: both trigger a full HTTP request (required for the JWT callback to run). replace() also removes the onboarding page from browser history, so the back button skips it. Always use replace() for one-way gate redirects.
In Next.js 15, params and searchParams in Server Components became asynchronous — they return a Promise, not a plain object. Every dynamic route in this project had already been updated to the new pattern except one: app/archive/[week]/page.tsx. Accessing params.week synchronously in Next.js 16 throws a runtime error that the nearest error boundary catches silently.
| Route | Pattern | Status |
|---|---|---|
| app/contests/ai-art/[id]/page.tsx | params: Promise<{ id: string }> | Correct |
| app/contests/photo/[id]/page.tsx | params: Promise<{ id: string }> | Correct |
| app/artwork/[id]/page.tsx | params: Promise<{ id: string }> | Correct |
| app/blog/[slug]/page.tsx | params: Promise<{ slug: string }> | Correct |
| app/profile/[username]/page.tsx | params: Promise<{ username: string }> | Correct |
| app/archive/[week]/page.tsx | params: { week: string } | Bug — synchronous access |
The fix is mechanical — update the type signature and await the params object. The reason for destructuring to weekStr in the page function rather than directly to week is that parseInt produces a number, and having two consts named week in the same scope (the string from params and the parsed integer) would require one to shadow the other. weekStr makes the types explicit.
The archive page links to past contests. Both an ai_art contest and a photo contest were archived. The archive cards were linking to /archive/1 and /archive/2 — the contest number — instead of /contests/photo/{uuid} or /contests/ai-art/{uuid}. The same bug appeared in the LastWinner component on the homepage.
LastWinner already computed the correct path — typePath was declared from contestType on line 17. It just was never used in the href. The component had all the information it needed and linked somewhere else entirely.
The archive page query also did not include contest_type in the select, so ArchiveCard had no way to build the correct URL even after the href was fixed. That required adding contest_type to the Supabase select alongside the existing id field.
None of these bugs produced Sentry events or Vercel function logs. The archive page bug in particular looked like a hanging Suspense boundary — the loading skeleton rendered but the page content never streamed in. The diagnosis came from opening the Network tab, clicking the /archive/1 request, and reading the raw RSC payload in the Response tab.
The payload showed the archive listing page metadata — title: Archive, canonical: /archive — rendering at the /archive/1 URL. The [week] page generateMetadata had never run. That proved the page component was throwing before it could produce output, not hanging. The async params fix resolved it. The ISR cache theory (that a stale error response was being served) turned out to be a red herring — the wrong URL in ArchiveCard was the actual problem, and the [week] page crash was a separate bug that happened to coexist.
When a Next.js page appears to hang in a Suspense boundary, check the raw RSC payload in the Network tab before assuming a DB or cache issue. If the metadata in the response belongs to a parent route rather than the current route, the page component is throwing before generateMetadata runs.
Bugs fixed
3
one session
Files changed
4
ArchiveCard, LastWinner, [week], onboarding
Lines removed
5
net deletion — update() call + import
Logs consulted
0
diagnosed from Network tab only
Each bug was a gap between what the code appeared to do and what it actually did. session.update() looked like it updated the session — it updated a cache. params.week looked like a string property — it was a Promise. typePath looked like it was being used in the href — it was declared and ignored. None of these required deep debugging tools. They required reading the mechanism, not just the symptom. That is the core of Directed Output: before reaching for a workaround, understand what the thing you are calling actually does.
Built with this methodology
Submitting a username left the app stuck on the onboarding page with no way to proceed — and the fix exposed two more routing bugs hiding in the same codebase. Here is exactly what broke and why.
From the build log