Transverse11 min de lecture
Relier Laravel et Next.js
L'API Laravel fait autorité, le serveur Next.js est son seul client. Un contrat clair, une authentification sans jeton exposé au navigateur et des erreurs qui arrivent au bon champ du formulaire.
Le contrat d'API
Les deux équipes se mettent d'accord sur ces règles une fois, puis ne les rediscutent plus.
| Sujet | Règle |
|---|---|
| Versionnement | Préfixe /api/v1. Un changement cassant crée v2, jamais une modification silencieuse. |
| Format | Toujours une JsonResource : les données sous data, la pagination sous meta. |
| Dates | ISO 8601 en UTC (2026-09-16T09:00:00Z). Le front formate selon la langue. |
| Montants | Entiers en centimes (45050), jamais de flottants. |
| Langue | Le front envoie Accept-Language : les messages d'erreur Laravel arrivent traduits. |
| Documentation | Générée depuis le code avec Scramble (OpenAPI), consultable sur /docs/api en local. |
Authentification
Notre standard : le navigateur ne voit jamais le jeton Sanctum. Le serveur Next.js le garde dans un cookie httpOnly et l'ajoute lui-même à chaque appel vers Laravel. Avance pas à pas avec les flèches.
1.Le formulaire de connexion appelle une Server Action. Les identifiants sont validés par 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]);
}
}Un seul client d'API
Tous les appels passent par apiFetch : URL de base, jeton, langue, et conversion des erreurs 422 en erreurs de champs. Il importe server-only : impossible de l'utiliser par erreur dans un Client Component.
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());Les erreurs de bout en bout
Une erreur de validation Laravel (422) doit s'afficher sous le bon champ, exactement comme une erreur Zod. Envoie le formulaire de démonstration.
Réponse de Laravel
En attente d'envoi…
Formulaire Next.js
Client customer_id
Échéance due_at
Quantité, ligne 1 lines.0.quantity
| Code | Signification | Réaction du front |
|---|---|---|
401 | Non connecté ou jeton expiré | Supprimer le cookie, rediriger vers /login |
403 | Connecté mais pas autorisé | Message clair, pas de bouton d'action |
404 | Ressource introuvable | Appeler notFound() |
422 | Validation échouée | Erreurs affichées sous chaque champ |
429 | Trop de requêtes | Message « réessaie dans un instant » |
500 | Erreur serveur | error.tsx avec bouton Réessayer, erreur journalisée |
<?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();Valider ce que renvoie l'API
Les réponses sont parsées avec Zod à l'entrée du front. Si Laravel renomme un champ, l'erreur est immédiate et explicite. La conversion snake_case vers camelCase se fait ici, une seule fois.
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>;Variables d'environnement
En local : Laravel sur le port 8000, Next.js sur le 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"