Tech Verse Logo
Enable dark mode
Next.js 16 and Laravel 12 Auth: Sanctum vs Cookies

Next.js 16 and Laravel 12 Auth: Sanctum vs Cookies

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Tokens vs Cookies: The Real Trade-off in Next.js 16

When you pair Next.js 16 with Laravel, you hit a clear architectural choice: issue personal access tokens (Bearer tokens) or rely on stateful HTTP-only cookies. I've seen teams pick Bearer tokens because it feels like standard API design, store the access token in localStorage, and then spend weeks fixing XSS vulnerabilities and page flicker on hydration. Don't do that.

If your Next.js application serves end users through a browser, stateful cookies via Laravel Sanctum are almost always the right call. Browsers manage HTTP-only cookies automatically, hiding them from client-side scripts. However, Next.js isn't just a client SPA anymore. With Server-Side Rendering (SSR) in React 19 Server Components, your Node server acts as an intermediate proxy. The browser sends cookies to Next.js, but Next.js won't automatically forward those cookies to Laravel when fetching data on the server side.

If you choose Bearer tokens, you must store the token in an HTTP-only cookie anyway so the Next.js server can read it during SSR. At that point, you're managing token storage, token expiration, and refresh tokens manually. Stateful cookies skip that entire layer of boilerplate if your app and API share a root domain.

Configuring Laravel for Stateful Request Handling

Laravel simplifies middleware registration in bootstrap/app.php. To accept stateful requests from Next.js, you need Sanctum's stateful middleware attached to your API route group, alongside proper CORS settings.

First, verify your environment configuration. If Next.js runs on app.example.com and Laravel runs on api.example.com, set your session domain to .example.com so the browser attaches the session cookie across both subdomains.

Here's how to configure stateful middleware in Laravel 12's bootstrap/app.php file:

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__."/../routes/web.php",
        api: __DIR__."/../routes/api.php",
        commands: __DIR__."/../routes/console.php",
        health: "/up",
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->statefulApi();
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

The $middleware->statefulApi() helper injects Sanctum's EnsureFrontendRequestsAreStateful middleware into your API pipeline running under PHP 8.3. Next, configure your .env file in Laravel:

SANCTUM_STATEFUL_DOMAINS=app.example.com:3000,localhost:3000,app.example.com
SESSION_DOMAIN=.example.com
APP_URL=https://api.example.com
FRONTEND_URL=https://app.example.com

If you miss SESSION_DOMAIN, Laravel defaults to the exact API hostname, and the browser won't include the session cookie on requests made from the Next.js frontend.

SSR Cookie Forwarding in Next.js 16 Server Components

In Next.js 16 and React 19, Server Components execute strictly on the server. When a user requests a route like /dashboard, Next.js receives the incoming browser request containing the laravel_session cookie. But when you issue a fetch() request inside a Server Component to Laravel, Node's network client won't attach incoming browser cookies automatically.

You must explicitly extract cookies using Next.js 16's asynchronous cookies() function and pass them in the headers. Here's a Server Component example that fetches authenticated user profile data from Laravel:

import { cookies } from "next/headers";
import { redirect } from "next/navigation";

interface User {
  id: number;
  name: string;
  email: string;
}

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const rawCookies = cookieStore.getAll()
    .map((c) => `${c.name}=${c.value}`)
    .join("; ");

  const res = await fetch("https://api.example.com/api/user", {
    headers: {
      "Accept": "application/json",
      "Cookie": rawCookies,
    },
    cache: "no-store",
  });

  if (res.status === 401) {
    redirect("/login");
  }

  if (!res.ok) {
    throw new Error(`Failed to fetch user: ${res.status}`);
  }

  const user: User = await res.json();

  return (
    <div className="p-6">
      <h2 className="text-xl font-bold">Welcome back, {user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}

Notice that await cookies() is mandatory in Next.js 16. If you forget await, Next.js throws a runtime error. Passing cache: 'no-store' prevents Next.js from caching authenticated API responses globally across different user requests.

Handling Client-Side Data Fetching and CSRF

While Server Components handle initial page renders, interactive features like forms or Client Component updates run directly in the browser. Stateful Sanctum authentication requires a CSRF handshake before mutating state via POST, PUT, or DELETE requests.

For client-side calls, request a fresh CSRF token from Laravel's /sanctum/csrf-cookie route before sending a login or mutation request. Laravel sets an XSRF-TOKEN cookie in the browser. You must read that cookie and set it as the X-XSRF-TOKEN header on subsequent requests.

'use client';

import { useState } from 'react';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  function getCookie(name: string): string | null {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
    return null;
  }

  async function handleLogin(e: React.FormEvent) {
    e.preventDefault();
    setError('');

    // Step 1: Request CSRF cookie
    await fetch('https://api.example.com/sanctum/csrf-cookie', {
      method: 'GET',
      credentials: 'include',
    });

    const xsrfToken = decodeURIComponent(getCookie('XSRF-TOKEN') || '');

    // Step 2: Submit credentials
    const res = await fetch('https://api.example.com/api/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-XSRF-TOKEN': xsrfToken,
      },
      credentials: 'include',
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) {
      const data = await res.json();
      setError(data.message || 'Login failed');
      return;
    }

    window.location.href = '/dashboard';
  }

  return (
    <form onSubmit={handleLogin} className="space-y-4">
      {error && <p className="text-red-500">{error}</p>}
      <input 
        type="email" 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
        placeholder="Email" 
        className="border p-2 w-full"
      />
      <input 
        type="password" 
        value={password} 
        onChange={(e) => setPassword(e.target.value)} 
        placeholder="Password" 
        className="border p-2 w-full"
      />
      <button type="submit" className="bg-blue-600 text-white p-2 rounded">
        Sign In
      </button>
    </form>
  );
}

Always specify credentials: 'include' on both client fetch calls. Without this setting, the browser strips cookies from cross-origin requests, causing Laravel to respond with a 401 Unauthenticated status every time.

Production Gotchas: CORS, SameSite, and Localhost

Setting up stateful authentication between Next.js and Laravel breaks in predictable ways. Here are the common failure modes and how to resolve them.

CORS and Credentials

Laravel's config/cors.php must allow credentials. Ensure 'supports_credentials' => true is active. If set to false, browser security blocks cross-origin cookies. Also, you cannot use wildcard origins ('allowed_origins' => ['*']) when credentials are allowed; list explicit domains like https://app.example.com.

SameSite Cookie Attributes

In config/session.php, check 'same_site'. If your Next.js app and Laravel API share a parent domain (like app.domain.com and api.domain.com), set 'same_site' => 'lax'. If they run on completely distinct domains (like myapp.com and laravel-api.com), set 'same_site' => 'none' and ensure 'secure' => true. Note that SameSite=None without HTTPS causes browsers to reject the cookie entirely.

Local Development Hostnames

Mixing localhost and 127.0.0.1 will break your auth. Browsers treat localhost:3000 and 127.0.0.1:8000 as different origins, and Sanctum domain matching fails. Stick to localhost for both services, or use a tool like Laravel Herd to assign local subdomains like app.test and api.test.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Database Transactions: Deadlocks and Retries

    Laravel Database Transactions: Deadlocks and Retries

    Laravel Task Scheduling: Locks, Timezones, Cron Drift

    Laravel Task Scheduling: Locks, Timezones, Cron Drift

    Next.js 16 and Laravel 12 Auth: Sanctum vs Cookies

    Next.js 16 and Laravel 12 Auth: Sanctum vs Cookies

    Testing Queued Jobs and Events in Laravel

    Testing Queued Jobs and Events in Laravel

    Structuring a Laravel and Next.js Monorepo

    Structuring a Laravel and Next.js Monorepo

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Laravel API Versioning: URI vs Header Strategies

    Laravel API Versioning: URI vs Header Strategies

    Server Components vs Client Components: Boundary Rules

    Server Components vs Client Components: Boundary Rules

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Service Container: When DI Helps and When It Hurts