TechnicalFeb 19, 2026

How We Killed Client-Side Token Refresh in Next.js

A journey from complex client-side token management to elegant server-side authentication

Before we talk about token refresh, we need to talk about architecture. Token strategy is never isolated—it's a consequence of how your system is structured. This is the story of how we simplified our authentication layer by moving responsibility to the right boundary.

The Starting Point: A Modern But Complicated Stack

Our setup was fairly common in the Next.js ecosystem:

  • Framework: Next.js with the App Router
  • Authentication: NextAuth (Auth.js) handling OAuth flows
  • API Consumption: REST client for internal services + Apollo Client for GraphQL APIs
  • Rendering: Mix of Server Components and Client Components

The authentication flow looked clean at first glance:

  1. User signs in through an OAuth provider
  2. NextAuth stores accessToken, refreshToken, and accessTokenExpires in the JWT
  3. Session lives in an HttpOnly cookie
  4. Both REST and GraphQL clients use the access token for backend communication

Authentication was centralized through NextAuth, APIs were modularized, and the App Router let us fetch data server-side when needed. Everything looked properly structured.

The problem was subtle: we had two API clients on the frontend that both depended on a valid access token, and we were refreshing that token on the client.

The Initial Strategy: Client-Side Token Management

Our first implementation handled token refresh entirely in the browser. We stored the access token expiry timestamp in the session and implemented logic that:

  • Set a timer to refresh the token 5 minutes before expiration
  • Triggered refresh on window focus events
  • Triggered refresh on visibility change
  • Intercepted 401 responses in both REST and Apollo clients
  • Attempted token refresh when a 401 occurred
  • Retried failed requests after successful refresh

On paper, this looked robust. We had proactive refresh (timers) and reactive refresh (401 handling). We covered multiple edge cases. We had retry logic in the REST client and retry links in Apollo.

In reality, we had distributed token management logic across multiple clients running in an unpredictable environment: the browser.

The Problems We Hit in Production

1. Multi-Tab Race Conditions

If a user opened three tabs and the token was close to expiring, each tab would trigger a refresh around the same time.

Here's where things got messy: many OAuth providers rotate refresh tokens. When you successfully refresh, you get a new refresh token and the old one is invalidated.

  • Tab 1: Refresh succeeds ✓
  • Tab 2: Attempts refresh with now-invalidated token ✗
  • Tab 3: Attempts refresh with now-invalidated token ✗

From the user's perspective, some tabs would randomly log out while others kept working. The UX was unpredictable and confusing.

2. 401 Storms

If a token expired unexpectedly (laptop went to sleep, tab got suspended), multiple API calls would fire when the user returned. All of them would receive 401 responses simultaneously.

Each client would attempt a refresh. Some would retry requests while others were still refreshing. Under load, this created a cascade of retries and duplicated refresh calls. Our frontend was effectively DDoSing our own authentication layer.

3. Stale Sessions and Unreliable Timers

Browser timers are not reliable:

  • Tabs get suspended by the browser
  • Laptops go to sleep
  • Users lose network connectivity
  • Background tabs get throttled

When users returned, the token might already be expired. The first API call would fail. The UI would flicker while refresh logic kicked in. Sometimes it would redirect unexpectedly. Sometimes it would partially recover. It never felt deterministic.

4. Architectural Leakage

We were solving an infrastructure concern in the UI layer. Token lifecycle management is not a rendering concern, yet our React codebase contained:

  • Focus event listeners
  • Visibility change handlers
  • Timer management logic
  • Retry wrappers around API calls
  • Cross-tab synchronization code

The frontend was carrying responsibility that belonged elsewhere.

The Realization

At some point, we asked ourselves a simple question:

Why are we refreshing tokens in the least reliable environment possible?

Let's compare:

Browsers are:

  • Unpredictable
  • Multi-tabbed
  • Suspended randomly
  • Rate-limited
  • User-controlled

Servers are:

  • Deterministic
  • Execute once per request
  • Centralized
  • Easier to reason about

The answer became obvious: move token refresh to the server.

The New Architecture: Server-Driven Token Rotation

We removed all client-side refresh logic:

  • ❌ No more timers
  • ❌ No more focus listeners
  • ❌ No more visibility change handlers
  • ❌ No more 401 retry wrappers in the REST client
  • ❌ No more Apollo refresh links

Instead, we made the server responsible for token lifecycle.

Implementation Details

1. JWT Callback Handles Refresh

In NextAuth, we moved refresh logic into the JWT callback. This callback runs during session validation:

typescript
// Conceptual implementation
async jwt({ token, account }) {
  // Initial sign in
  if (account) {
    return {
      accessToken: account.access_token,
      refreshToken: account.refresh_token,
      accessTokenExpires: account.expires_at * 1000,
    }
  }

  // Return token if it has more than 5 minutes remaining
  const REFRESH_BUFFER = 5 * 60 * 1000 // 5 minutes in milliseconds
  if (Date.now() < token.accessTokenExpires - REFRESH_BUFFER) {
    return token
  }

  // Token is expired or will expire soon, refresh it
  return refreshAccessToken(token)
}

When the user logs in, we store credentials in the JWT. On every request that requires session validation, the JWT callback runs. If the token is still valid, we return it as-is. If it's close to expiring, we refresh it on the server before the request proceeds.

We use a 5-minute buffer before expiration to ensure tokens remain valid throughout the request lifecycle. This accounts for:

  • Network latency between services
  • Clock drift between servers
  • Request processing time

Token refresh happens as part of session resolution, not as part of API consumption.

2. Middleware Guards Protected Routes

typescript
// middleware.ts
export async function middleware(request: NextRequest) {
  const session = await getToken({ req: request })
  
  if (!session) {
    return NextResponse.redirect(new URL('/signin', request.url))
  }
  
  return NextResponse.next()
}

If session validation or refresh fails, middleware redirects to sign-in immediately. There's no partial UI state and no inconsistent client behavior.

3. Backend Calls at Server Boundaries

Data fetching happens in:

  • Server Components
  • Route Handlers
  • Server Actions

In these environments, we retrieve the session using getServerSession(). If the token is expired, the JWT callback refreshes it automatically before the API call is made.

typescript
// In a Server Component
async function DashboardPage() {
  const session = await getServerSession(authOptions)
  const data = await fetchDashboardData(session.accessToken)
  
  return <Dashboard data={data} />
}

From the component's perspective, nothing special happens. It simply receives data.

What This Fixed

Multi-Tab Race Conditions → Gone

Each request handles its own token validation on the server. Even if multiple tabs trigger requests simultaneously, refresh logic executes in a controlled environment rather than competing browser contexts.

401 Storms → Gone

Backend API calls no longer receive expired tokens. The server ensures freshness before making upstream requests. Retry logic at the client level became unnecessary.

Frontend Complexity → Dramatically Reduced

We deleted large sections of defensive authentication code:

  • No more timers
  • No more event listeners
  • No more retry abstractions in our data clients

The UI became responsible only for rendering and redirecting when middleware denied access.

Debugging → Much Easier

Instead of tracing refresh logic across React hooks, Apollo links, and REST interceptors, we could focus on a single auth boundary: the NextAuth JWT callback.

Trade-Offs and Considerations

This approach works best when:

  • ✅ You're using Next.js App Router as intended
  • ✅ Sessions are stored in HttpOnly cookies
  • ✅ Access tokens are not unnecessarily exposed to the client
  • ✅ You're comfortable with server-centric data fetching

What about Client Components that need to make API calls?

We route all client-side API calls through Next.js API Routes. This ensures the server always handles token management before proxying to your backend.

Here's the pattern:

typescript
// app/api/dashboard/route.ts
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"

export async function GET() {
  const session = await getServerSession(authOptions)
  
  if (!session) {
    return new Response("Unauthorized", { status: 401 })
  }

  // Token is automatically refreshed if needed via JWT callback
  const response = await fetch("https://api.example.com/dashboard", {
    headers: {
      Authorization: `Bearer ${session.accessToken}`
    }
  })
  
  const data = await response.json()
  return Response.json(data)
}
typescript
// components/DashboardClient.tsx
"use client"

export function DashboardClient() {
  const [data, setData] = useState(null)
  
  useEffect(() => {
    // Client calls our Next.js API route, not the external API directly
    fetch('/api/dashboard')
      .then(res => res.json())
      .then(setData)
  }, [])
  
  return <div>{/* render data */}</div>
}

The Bigger Lesson

This wasn't just about token refresh. It was about responsibility and boundaries.

We had allowed infrastructure concerns to leak into the UI layer. The result was complexity, fragility, and subtle production bugs that only appeared under specific conditions.

By moving token lifecycle management to the server, we restored separation of concerns:

  • Server owns: Authentication state, token lifecycle, session validation
  • Client owns: Rendering, user interaction, presentation logic
  • APIs trust: The server's authority

Sometimes the senior move is not adding smarter retry logic or more defensive abstractions.

Sometimes it's deleting code and moving responsibility to the correct boundary.

Questions to Ask Yourself

If your frontend contains:

  • ⚠️ Token expiry timers
  • ⚠️ Refresh event listeners (focus, visibility)
  • ⚠️ Cross-tab synchronization logic
  • ⚠️ Defensive 401 wrappers and retry logic

Ask yourself: Do these responsibilities truly belong in the UI?

In a modern Next.js application with App Router and Server Components, they probably don't.

Key Takeaways

  1. Authentication is infrastructure, not presentation - Keep it server-side
  2. Browsers are unreliable execution environments - Don't trust timers or events for critical operations
  3. Centralize token management - One source of truth prevents race conditions
  4. Leverage the framework - Next.js App Router is designed for server-side session handling
  5. Delete code, not add abstractions - Sometimes less is more

Have you dealt with similar authentication challenges? What patterns worked (or didn't work) for you? I'd love to hear about your experiences in the comments.

Additional Resources

More posts in the Writing section.