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.comIf 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.









