Across the stack11 min read
Connecting Laravel and Next.js
The Laravel API is the source of truth, and the Next.js server is its only client. A clear contract, authentication with no token exposed to the browser, and errors that land on the right form field.
The API contract
Both teams agree on these rules once, then stop debating them.
| Topic | Rule |
|---|---|
| Versioning | /api/v1 prefix. A breaking change creates v2, never a silent edit. |
| Format | Always a JsonResource: data under data, pagination under meta. |
| Dates | ISO 8601 in UTC (2026-09-16T09:00:00Z). The front end formats per language. |
| Amounts | Integer cents (45050), never floats. |
| Language | The front end sends Accept-Language: Laravel error messages come back translated. |
| Documentation | Generated from code with Scramble (OpenAPI), available at /docs/api locally. |
Authentication
Our standard: the browser never sees the Sanctum token. The Next.js server keeps it in an httpOnly cookie and attaches it to every call to Laravel. Step through with the arrows.
1.The sign-in form calls a Server Action. Credentials are validated with Zod.
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { loginSchema } from './schemas';
export async function login(_prev: LoginState, input: unknown): Promise<LoginState> {
const parsed = loginSchema.safeParse(input);
if (!parsed.success) return { status: 'invalid' };
const response = await fetch(`${process.env.API_URL}/api/v1/login`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ ...parsed.data, device_name: 'web' }),
});
if (!response.ok) return { status: 'invalid' };
const { token } = (await response.json()) as { token: string };
(await cookies()).set('session', token, {
httpOnly: true, // unreadable from JavaScript: XSS can't steal it
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 8,
});
redirect('/invoices');
}<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\LoginRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class LoginController
{
public function __invoke(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->validated('email'))->first();
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$token = $user
->createToken($request->validated('device_name'), ['*'], now()->addHours(8))
->plainTextToken;
return response()->json(['token' => $token]);
}
}One API client
Every call goes through apiFetch: base URL, token, language, and turning 422 errors into field errors. It imports server-only: you can't use it in a Client Component by mistake.
import 'server-only';
import { cookies } from 'next/headers';
import { env } from './env';
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export class ApiValidationError extends ApiError {
constructor(public fieldErrors: Record<string, { type: string; message: string }>) {
super(422, 'Validation failed');
}
}
type Options = Omit<RequestInit, 'body'> & { body?: unknown };
export async function apiFetch<T = unknown>(path: string, { body, headers, ...init }: Options = {}): Promise<T> {
const token = (await cookies()).get('session')?.value;
const response = await fetch(`${env.API_URL}/api${path}`, {
...init,
headers: {
Accept: 'application/json', // Laravel answers errors in JSON, not HTML
'Content-Type': 'application/json',
'Accept-Language': (await cookies()).get('NEXT_LOCALE')?.value ?? 'fr',
...(token && { Authorization: `Bearer ${token}` }),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 422) {
const { errors } = (await response.json()) as { errors: Record<string, string[]> };
throw new ApiValidationError(
Object.fromEntries(Object.entries(errors).map(([field, [message = '']]) => [toCamel(field), { type: 'server', message }])),
);
}
if (!response.ok) throw new ApiError(response.status, response.statusText);
return (response.status === 204 ? null : await response.json()) as T;
}
const toCamel = (field: string) => field.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());Errors end to end
A Laravel validation error (422) must show up under the right field, exactly like a Zod error. Submit the demo form.
Laravel response
Waiting for submit…
Next.js form
Customer customer_id
Due date due_at
Quantity, line 1 lines.0.quantity
| Code | Meaning | Front-end reaction |
|---|---|---|
401 | Signed out or token expired | Delete the cookie, redirect to /login |
403 | Signed in but not allowed | Clear message, no action button |
404 | Resource not found | Call notFound() |
422 | Validation failed | Errors shown under each field |
429 | Too many requests | "Try again in a moment" message |
500 | Server error | error.tsx with a Retry button, error logged |
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->throttleApi('api'); // 429 when abused
})
->withExceptions(function (Exceptions $exceptions) {
// Every /api/* error is JSON: 401, 403, 404, 422, 500
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
})
->create();Validate what the API returns
Responses are parsed with Zod as they enter the front end. If Laravel renames a field, the error is immediate and explicit. The snake_case to camelCase conversion happens here, once.
import { z } from 'zod';
export const paginated = <T extends z.ZodType>(item: T) =>
z.object({
data: z.array(item),
meta: z.object({
current_page: z.number(),
last_page: z.number(),
per_page: z.number(),
total: z.number(),
}),
});
export const invoiceSchema = z
.object({ id: z.number(), number: z.string(), total: z.number(), due_at: z.iso.date() })
.transform(({ due_at, ...rest }) => ({ ...rest, dueAt: due_at })); // snake_case stops here
export const invoiceListSchema = paginated(invoiceSchema);
export type Invoice = z.output<typeof invoiceSchema>;Environment variables
Locally: Laravel on port 8000, Next.js on port 3000.
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
SANCTUM_EXPIRATION=480# Server only: never prefixed with NEXT_PUBLIC_
API_URL=http://localhost:8000
# Public: shipped to the browser
NEXT_PUBLIC_APP_NAME="Lantorian Billing"