Two frontends, two contracts, one backend stack
Most of my production work lives in one configuration: a Next.js frontend talking to a Laravel API. Over the years I've shipped it with REST (jamtangan.com) and with GraphQL (voila.id). Both worked. Both taught me different things about where the contract between frontend and backend should live.
This isn't a REST-vs-GraphQL verdict piece. It's the set of patterns I keep reusing, and the trade-offs I actually weigh when a project starts.
The REST patterns that survived every project
One fetch wrapper, no exceptions
The first thing I set up on any Next.js project is a single fetch wrapper. Every request goes through it — no fetch() calls scattered through components. It's where base URL, credentials, error normalization, and headers live:
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${process.env.API_BASE_URL}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new ApiError(res.status, body?.message ?? res.statusText)
}
return res.json() as Promise<T>
}
Two details worth defending here. First, credentials: 'include' — session-based auth between Laravel and Next.js only works if cookies cross the boundary deliberately. Second, error normalization: components should catch one ApiError type, not interpret every backend response shape.
Auth: session or token, choose consciously
Laravel gives you both — cookie sessions via Sanctum's SPA mode, or bearer tokens. I've used sessions for server-rendered storefronts where the browser is the only consumer, and tokens where an admin SPA and mobile clients shared the same API.
The mistake isn't picking the wrong one. It's picking one by default. Sessions keep CSRF concerns and cookie handling in Laravel's wheelhouse; tokens push revocation and multi-client concerns onto you. Decide based on who consumes the API, then write the decision down.
Shape errors so the UI can react
A 500 with an HTML error page is useless to a React component. Every Laravel API I maintain returns a consistent error envelope — status, machine-readable code, human message — and the frontend maps that to user-facing states: retryable vs. fatal, validation vs. server error. Empty, loading, and error states are part of the contract, not an afterthought.
The GraphQL patterns that made voila.id work
Schema as the living contract
With GraphQL, the schema does the heavy lifting that types do in REST. The frontend gets a typed client from the schema, and breaking changes surface at codegen time instead of at 2 a.m. in production:
query CatalogPage($filters: ProductFilterInput) {
products(filters: $filters) {
edges {
node {
id
name
slug
price { amount currency }
thumbnail { url width height }
}
}
pageInfo { hasNextPage endCursor }
}
}
When the backend renames price to finalPrice, the frontend build breaks before the deploy does. That's the entire value proposition, and it only holds if codegen runs in CI.
Keep server cache and UI state in separate lanes
Apollo's cache and my client state store (Zustand, in both projects) can quietly become entangled — UI state leaking into the normalized cache, or cached server data re-implemented as local state. The rule I settled on: Apollo owns anything the server truthfully knows. Zustand owns anything the server doesn't — cart drafts, filter UI state, wizard progress. A piece of state lives in exactly one of the two.
Pagination and nested reads are the real win
GraphQL earns its complexity on catalog pages: one query fetches the product grid, its facets, and its pagination metadata. Under REST that's often three endpoints and a waterfall. If your page shape is "one resource, deeply nested, consumed one way," REST with a BFF endpoint is honestly simpler. If it's "many shapes of the same graph, consumed by multiple clients," GraphQL starts paying rent.
How I choose now
The questions that decide it for me:
- How many consumers? One frontend, simple resources → REST. Multiple clients or wildly varying read shapes → GraphQL.
- Who owns caching? With REST, HTTP caching and simple request-level caches are easy to reason about. With GraphQL, you're committing to a normalized client cache — powerful, but it's a system you maintain.
- How strong is your schema discipline? REST without a shared contract (OpenAPI, or at minimum typed transformers on the Laravel side) drifts. GraphQL enforces more discipline by default.
- Team familiarity. A team fluent in REST will ship GraphQL slowly and vice versa. The theoretical superiority of a contract doesn't compensate for a team fighting its tools.
One rule underneath both
Whatever the protocol, the contract is the product. On the REST projects I've maintained Laravel transformers and request validation as the contract; on GraphQL, the schema. Types flow from that contract into the Next.js side, and anything that bypasses the contract — ad-hoc any casts, hand-rolled response parsing in a component — is debt with interest.
Pick the protocol your problem deserves. Then treat the contract as the part you never skip.