Tech Verse Logo
Enable dark mode
Next.js 16 App Router Auth Patterns with Laravel 12

Next.js 16 App Router Auth Patterns with Laravel 12

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

The Server Component Read Boundary

In Next.js 16 and React 19, Server Components execute strictly on the server during the render phase. You get access to incoming request metadata through cookies() and headers() from next/headers. The trap most developers fall into is trying to rewrite or refresh authentication tokens inside these components when an API call to Laravel returns a 401 Unauthorized response.

You can't set cookies inside a Server Component. Next.js explicitly throws a runtime error if you attempt to call cookies().set() outside of a Server Action or a Route Handler. This restriction exists because response headers are already locked down once component streaming begins. If your user's access token expires five minutes into a session, a Server Component fetch will fail, and you won't be able to issue a token refresh in place.

To handle session reads cleanly without triggering duplicate API calls during a single page render, wrap your authentication read helper in React 19's cache() wrapper. This guarantees that whether five different Server Components call getCurrentUser() during a single request, Next.js only executes the network call to your Laravel 12 backend once.

import { cache } from 'react';
import { cookies } from 'next/headers';

export const getCurrentUser = cache(async () => {
  const cookieStore = await cookies();
  const token = cookieStore.get('access_token')?.value;

  if (!token) {
    return null;
  }

  const response = await fetch('https://api.yourdomain.com/v1/user', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/json',
    },
    next: { revalidate: 0 },
  });

  if (!response.ok) {
    return null;
  }

  return response.json();
});

Notice the await cookies() call. In Next.js 16, asynchronous cookie reads are standard. Calling cookies() synchronously triggers deprecation warnings or full runtime errors depending on your exact build configuration. Also, setting next: { revalidate: 0 } ensures Next.js doesn't cache user session payloads across different request contexts.

Middleware Guards and Token Refreshes

Since Server Components can't mutate cookies, your middleware is the only place in the HTTP lifecycle capable of intercepting requests, checking JWT expiration, executing a refresh handshake with Laravel 12, and updating cookies before rendering starts.

When an incoming request hits your Next.js application, middleware runs before any route matching or component execution occurs. Here, you have mutable access to both request headers and response cookies via NextResponse. If the access token is missing or expired, but a valid refresh token cookie exists, middleware can issue a server-to-server request to Laravel 12's PHP 8.3 auth endpoint, obtain a new token, set the cookie on the response, and rewrite request headers so downstream Server Components immediately see the new token.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  const accessToken = request.cookies.get('access_token')?.value;
  const refreshToken = request.cookies.get('refresh_token')?.value;
  const pathname = request.nextUrl.pathname;

  if (pathname.startsWith('/login') || pathname.startsWith('/_next')) {
    return NextResponse.next();
  }

  if (!accessToken && refreshToken) {
    try {
      const refreshResponse = await fetch('https://api.yourdomain.com/v1/auth/refresh', {
        method: 'POST',
        headers: {
          'Cookie': `refresh_token=${refreshToken}`,
          'Accept': 'application/json',
        },
      });

      if (refreshResponse.ok) {
        const data = await refreshResponse.json();
        const newAccessToken = data.access_token;

        const requestHeaders = new Headers(request.headers);
        requestHeaders.set('authorization', `Bearer ${newAccessToken}`);

        const response = NextResponse.next({
          request: {
            headers: requestHeaders,
          },
        });

        response.cookies.set('access_token', newAccessToken, {
          httpOnly: true,
          secure: process.env.NODE_ENV === 'production',
          sameSite: 'lax',
          path: '/',
          maxAge: 900,
        });

        return response;
      }
    } catch (error) {
      console.error('Failed to refresh token in middleware:', error);
    }
  }

  if (!accessToken && !refreshToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

There's a critical detail here that breaks many production builds: when you call response.cookies.set(), it sets the Set-Cookie header on the outgoing response back to the client browser. But downstream Server Components rendering during that exact same request won't see that new cookie in cookies().get() because cookies() reads from incoming request headers. That's why you must explicitly modify incoming request headers with requestHeaders.set('authorization', `Bearer ${newAccessToken}`) inside NextResponse.next(). Downstream components can then extract the bearer token from headers() if the cookie header hasn't updated yet.

Backend Handshake in Laravel 12

On the backend, your PHP 8.3 Laravel 12 API handles the refresh route. Avoid heavy OAuth packages if you're building a standard SPA or Next.js front end; Sanctum with custom refresh tokens or lightweight JWTs works far better. Here's a clean Laravel 12 controller implementation that accepts a refresh cookie and issues a short-lived access token.

namespace App\Http\Controllers;

use App\Models\RefreshToken;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;

class AuthController extends Controller
{
    public function refresh(Request $request): JsonResponse
    {
        $refreshTokenValue = $request->cookie('refresh_token');

        if (!$refreshTokenValue) {
            return response()->json(['message' => 'Unauthenticated'], 401);
        }

        $tokenRecord = RefreshToken::where('token', hash('sha256', $refreshTokenValue))
            ->where('expires_at', '>', now())
            ->first();

        if (!$tokenRecord) {
            return response()->json(['message' => 'Invalid or expired token'], 401);
        }

        $user = $tokenRecord->user;
        $newAccessToken = $user->createToken('access_token', ['*'], now()->addMinutes(15))->plainTextToken;

        return response()->json([
            'access_token' => $newAccessToken,
            'token_type' => 'Bearer',
            'expires_in' => 900,
        ]);
    }
}

Gotchas and Production Realities

Three distinct issues will hit you when scaling this architecture across production environments.

1. Middleware Race Conditions

When a page triggers multiple parallel client-side fetches (for example, React Server Components streaming separate suspended UI blocks), three concurrent requests might hit Next.js middleware at the exact same millisecond with an expired access token. If your Laravel 12 backend revokes the refresh token immediately upon first use, request #1 will succeed, but requests #2 and #3 will fail with 401 errors because their refresh token was invalidated 50ms earlier.

Fix this on the Laravel side by giving refresh tokens a grace period of about 10 to 15 seconds, during which an invalidated refresh token can still issue the same active access token payload without triggering reuse security locks.

2. Edge Runtime Limitations

Next.js 16 middleware runs in the Edge Runtime by default unless configured otherwise. The Edge Runtime lacks full Node.js standard modules. If you rely on Node's native crypto module for offline JWT signature verification inside middleware, your build will crash. Use jose instead of jsonwebtoken for verifying JWT signatures inside middleware, as jose uses Web Crypto APIs native to the Edge Runtime.

3. Cookie Size Caps

Browsers cap total HTTP headers around 8KB or 16KB depending on the web server. If you store large session payloads or multiple tokens in cookies, request headers will quickly exceed server bounds. Keep your access tokens lean: store only the user ID, tenant ID, and expiration timestamp in the JWT payload. Leave profile details, permission trees, and preferences to Laravel database lookups cached in Redis.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Fixing Next.js Hydration Mismatch Errors in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Generating Open Graph Images at the Edge in Next.js 16

    Generating Open Graph Images at the Edge in Next.js 16

    Fix Next.js Third Party Script Performance

    Fix Next.js Third Party Script Performance

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Next.js 16 App Router Auth Patterns with Laravel 12

    Next.js 16 App Router Auth Patterns with Laravel 12

    Next.js i18n Routing: Locales, Hreflang, and Metadata

    Next.js i18n Routing: Locales, Hreflang, and Metadata

    Next.js 16 Caching Architecture: The Four Layers

    Next.js 16 Caching Architecture: The Four Layers

    Dynamic Imports and Code Splitting in Next.js 16

    Dynamic Imports and Code Splitting in Next.js 16

    Next.js Image Optimization: Sizes and Remote Patterns

    Next.js Image Optimization: Sizes and Remote Patterns