Aller au contenu
Lantorian

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.

SujetRègle
VersionnementPréfixe /api/v1. Un changement cassant crée v2, jamais une modification silencieuse.
FormatToujours une JsonResource : les données sous data, la pagination sous meta.
DatesISO 8601 en UTC (2026-09-16T09:00:00Z). Le front formate selon la langue.
MontantsEntiers en centimes (45050), jamais de flottants.
LangueLe front envoie Accept-Language : les messages d'erreur Laravel arrivent traduits.
DocumentationGé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.

NavigateurServeur Next.jsAPI LaravelloginAction(email, password)POST /api/v1/login200 { token }Set-Cookie: session=…; HttpOnly; SecureGET /fr/invoices (cookie)Authorization: Bearer <token>200 InvoiceResource[]

1.Le formulaire de connexion appelle une Server Action. Les identifiants sont validés par Zod.

Les appels vers Laravel partent du serveur Next.js : pas de CORS à configurer, pas de jeton lisible en JavaScript.1/7
src/features/auth/actions.ts
'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');
}

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.

src/lib/api-client.ts
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

Les clés de errors correspondent aux noms des champs. apiFetch les convertit en camelCase pour React Hook Form.
CodeSignificationRéaction du front
401Non connecté ou jeton expiréSupprimer le cookie, rediriger vers /login
403Connecté mais pas autoriséMessage clair, pas de bouton d'action
404Ressource introuvableAppeler notFound()
422Validation échouéeErreurs affichées sous chaque champ
429Trop de requêtesMessage « réessaie dans un instant »
500Erreur serveurerror.tsx avec bouton Réessayer, erreur journalisée
bootstrap/app.php
<?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.

src/lib/api-schemas.ts
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.

api/.env
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
SANCTUM_EXPIRATION=480