Skip to content
Lantorian

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.

TopicRule
Versioning/api/v1 prefix. A breaking change creates v2, never a silent edit.
FormatAlways a JsonResource: data under data, pagination under meta.
DatesISO 8601 in UTC (2026-09-16T09:00:00Z). The front end formats per language.
AmountsInteger cents (45050), never floats.
LanguageThe front end sends Accept-Language: Laravel error messages come back translated.
DocumentationGenerated 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.

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

1.The sign-in form calls a Server Action. Credentials are validated with Zod.

Calls to Laravel leave from the Next.js server: no CORS to configure, no token readable from 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');
}

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.

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());

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

Keys in errors match field names. apiFetch converts them to camelCase for React Hook Form.
CodeMeaningFront-end reaction
401Signed out or token expiredDelete the cookie, redirect to /login
403Signed in but not allowedClear message, no action button
404Resource not foundCall notFound()
422Validation failedErrors shown under each field
429Too many requests"Try again in a moment" message
500Server errorerror.tsx with a Retry button, error logged
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();

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.

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>;

Environment variables

Locally: Laravel on port 8000, Next.js on port 3000.

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