What Actually Happened When We Migrated to Auth.js v5 -- Blueprint vs Reality

Jee-eun Kang
Jee-eun Kang April 20, 2026

In the previous post, I shared the full architecture blueprint for migrating our production Next.js app from client-side keycloak-js to server-side Auth.js v5. That post was written before implementation — a research document about what we planned to build and why.

This post is the sequel: what actually happened during the 12 days of implementation. Where the blueprint was right, where it was wrong, and the one planning lesson I will carry into every future migration.


The Scoreboard

10 stories, 7 PRs, 12 active days. Zero production incidents.

StoryWhat It DidMerged
8-1 Server TokenAuth.js foundation, PKCE, encrypted cookiesApr 9
8-2 MiddlewareEdge Runtime route protectionApr 13
8-3 AxiosToken source migration with feature flagApr 14
8-4a Provider & LayoutRoot layout restructuringApr 15
8-4b Verification TestsAuth.js mode test coverageApr 15
8-4c Login/Logout E2EE2E specs for auth flowsApr 17
8-5 Cleanup & CutoverRemove keycloak-js entirelyApr 20

Story 8-5 alone touched 52 files: +392 lines added, 2,240 lines removed. The codebase got lighter.

The blueprint’s external research was solid. Auth.js callbacks, PKCE flows, Keycloak configuration, cookie encryption, federated logout — all of that worked as documented. Not a single surprise came from Auth.js or Keycloak.

Every surprise came from our own code.


Surprise 1: Edge Runtime Ate My Logger

The blueprint designed auth.ts as a single file. Simple enough.

What I did not know: Next.js middleware runs in Edge Runtime, which is not Node.js. It cannot import Pino, fs, streams, or anything that touches the Node.js standard library. The auth config file imported Pino for structured logging. Middleware imported the auth config. Build failed.

The fix was straightforward — split into auth.config.ts (Edge-safe, provider config only) and auth.ts (full config with Pino). But this was not discoverable from reading documentation. Edge Runtime compatibility is an implicit constraint — nothing in the codebase says “this file will be imported by middleware, which runs in Edge Runtime.”

This was caught by the adversarial code review, not during implementation. If we had started Story 8-2 (middleware) without the review, we would have hit a wall and needed to backtrack into 8-1’s territory.

Lesson: Runtime boundaries in Next.js are invisible. The only way to find them is to actually try the import chain.


Surprise 2: The Provider Tree Was Already Broken

The blueprint designed the new provider tree based on reading the existing root.tsx. It looked correct. TokenAxiosIntercepter was outside AuthProvider, and both appeared to work fine.

They did not work fine.

TokenAxiosIntercepter uses useAuth() to read the current token. But because it was rendered outside AuthProvider, it was receiving the default context value — not the real auth state. This “worked” in production because the token was also being read from sessionStorage as a fallback. Nobody noticed the context was wrong because the fallback masked the bug.

We only found this when restructuring the provider tree in 8-4a. Moving TokenAxiosIntercepter inside the auth provider was part of fixing the ordering, and suddenly the context-based token read actually worked correctly.

Lesson: Code that “works” is not the same as code that works correctly. CSR apps with deeply nested provider trees accumulate invisible dependency bugs that only surface when you restructure.


Surprise 3: One Hook Saved a Week of Work

The original plan for Story 8-4b was to migrate approximately 22 components that called useAuth() — changing each one to use useSession() instead. A mechanical but tedious task that would touch files across the entire codebase.

During 8-4a, we made a different decision: create a dual-mode useAuth() adapter hook. When the feature flag says authjs, the hook internally calls useSession() and maps the result to the same shape. When it says keycloak-js, it delegates to the old context. Same interface, different backend.

Result: all 22 consumers got zero-change migration. Story 8-4b went from “migrate every consumer” to “verify they all work” — a scope reduction from days to hours.

This was not planned in the blueprint. The blueprint assumed a linear, per-consumer migration. The adapter pattern only became obvious when you looked at the 22 consumers and realized they all needed the same 6 properties: isInitialized, isAuthenticated, user, token, login, logout.

Lesson: When planning migrations, always ask “can an adapter layer eliminate the per-consumer work?” If there are more than 10 consumers with similar shapes, the answer is probably yes.


Surprise 4: The God Component

AuthProvider was 408 lines. The blueprint treated it as a single responsibility: “authentication.” It was not.

It handled:

  • Keycloak initialization and token management
  • Menu data fetching (duplicate of what MenuDataProvider already did)
  • Route validation (redirecting invalid routes to 404)
  • Post-auth navigation (restoring pre-login path)
  • Loading screen rendering

Decomposing it meant extracting useRouteValidation as a standalone hook, removing the duplicate menu fetch, removing post-auth navigation (Auth.js handles this with callbackUrl), and reducing what remained to a thin provider shell. Each extraction pulled on unexpected threads — the menu fetch was interleaved with auth state checks, the route validation depended on auth initialization timing.

Lesson: Reading a large component tells you what it does. Only pulling it apart tells you how tightly coupled the pieces are. For components over 200 lines, do a dependency mapping exercise before writing story tasks.


The Adversarial Review That Prevented a Production Bug

For Story 8-4a, we ran a 3-layer adversarial code review: Blind Hunter (finds issues without seeing the plan), Edge Case Hunter (targets race conditions and state), and Acceptance Auditor (validates against acceptance criteria).

Round 1 surfaced 27 raw findings, distilled to 4 patch items. The most critical: a signInTriggeredRef in the Auth.js login flow that could deadlock under a specific race condition — useSession returning unauthenticated after signIn() was called but before the redirect completed. The ref prevented the second signIn() call, leaving the user stuck on a loading screen.

Without the adversarial review, this would have shipped. It was not covered by the unit tests because it required a specific timing sequence between useSession polling and the OAuth redirect. This is the kind of bug that appears in production at 2 AM when someone’s network is slow.


The Planning Lesson

We spent significant time on external research: Auth.js v5 API deep-dive, PKCE RFC analysis, Keycloak configuration, cookie security model. All of that was valuable and paid off — zero surprises from external dependencies.

But every implementation surprise came from our own codebase. And none of those surprises were discoverable by reading the code. They were only discoverable by changing it.

The lesson: for migration epics, add a spike story. Not research — a spike. A throwaway branch where you actually wire the new thing into the existing code. Half a day, no polish, no tests — just plug it in and see what breaks.

For Epic 8, that spike would have been: import SessionProvider into root.tsx, import auth.config.ts from middleware.ts, add useSession() to one component. Three files, maybe two hours. Every surprise in this post would have surfaced in that spike.

External research tells you how the new thing works. A spike tells you how the new thing collides with your existing code. You need both.


Final Numbers

Stories completed:     10/11 (8-6 deferred intentionally)
Active development:    12 days
PRs merged:            7
Lines removed (8-5):   2,240
Lines added (8-5):     392
Test suites:           96
Tests:                 1,293
Type errors:           0
Production incidents:  0

The auth migration is live in dev. The browser no longer manages tokens. sessionStorage is empty. The old keycloak-js public client still exists in Keycloak but the codebase no longer references it. After a 2-week monitoring period, we will deactivate it.


This is Part 4 of the auth series: Keycloak SSO Part 1Keycloak SSO Part 2Auth.js Blueprint — Implementation Retrospective (this post)