How to Build a Laravel REST API with a React Frontend: Auth, CRUD and Deployment
Build a Laravel REST API with React: Sanctum SPA auth, API Resources, Form Request validation, policies, a typed React client, pagination and deployment.
By Harsh Thummar 12–15 min read

Laravel and React are one of the most practical pairings for business applications. Laravel gives you routing, validation, an ORM, queues and authentication out of the box. React gives you a fast, interactive UI.
The hard part is not either framework on its own. It is the seam between them: cookies, CORS, CSRF tokens, validation errors and role checks that have to work across two separate apps.
In this guide, I will walk through how to build a Laravel REST API with React end to end. We will set up the API, model a resource, validate input, protect routes with Sanctum, enforce roles with policies, and then build a typed React client with protected routes, pagination and error handling.
By the end, you will have a pattern you can reuse for dashboards, portals and internal tools.
When a Decoupled Laravel API + React SPA Makes Sense
Not every project needs a separate frontend. If your app is mostly forms and server-rendered pages, a single Laravel app with Blade, Livewire or Inertia is often simpler.
A decoupled architecture earns its complexity when:
- The UI is highly interactive (filters, live tables, multi-step flows, drag and drop)
- Several user roles each get their own dashboard
- The same API will later serve a mobile app or third-party integrations
- Frontend and backend are deployed, scaled or owned separately
This is exactly the setup we used for VettedPool, a recruitment platform built with React, Laravel and MySQL. It has four role-based dashboards, and keeping the API separate made each dashboard easier to build and reason about.
If you are still deciding on the frontend side, my React vs Next.js comparison covers when a plain React SPA is the better fit.
The Architecture at a Glance
React SPA (Vite + TypeScript) → app.example.com
│ fetch with credentials + X-XSRF-TOKEN
▼
Laravel API (Sanctum) → api.example.com
│ Form Requests → Policies → Eloquent
▼
MySQLThe browser holds a Laravel session cookie. The React app never touches a token. Laravel decides who the user is and what they can do on every request.
Step 1: Set Up the Laravel API
Create a new project and install the API scaffolding:
composer create-project laravel/laravel api
cd api
php artisan install:apiOn current Laravel versions, install:api installs Laravel Sanctum, publishes its migration and creates routes/api.php. Routes in that file are automatically prefixed with /api.
Next, generate the pieces we need for a Project resource:
php artisan make:model Project -m
php artisan make:controller Api/ProjectController --api --model=Project
php artisan make:request StoreProjectRequest
php artisan make:request UpdateProjectRequest
php artisan make:resource ProjectResource
php artisan make:policy ProjectPolicy --model=ProjectStep 2: Migrations and Models
Add a role column to users and create the projects table:
// database/migrations/xxxx_create_projects_table.php
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('draft');
$table->timestamps();
});
}// app/Models/Project.php
class Project extends Model
{
protected $fillable = ['title', 'description', 'status'];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}Notice that user_id is not fillable. The owner comes from the authenticated user, never from the request body.
Step 3: API Resources Shape Your JSON
Returning Eloquent models directly leaks every column, including ones you add later. API Resources give you an explicit contract:
// app/Http/Resources/ProjectResource.php
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'owner' => [
'id' => $this->owner->id,
'name' => $this->owner->name,
],
'createdAt' => $this->created_at->toIso8601String(),
];
}
}This JSON shape is what your TypeScript types will mirror on the React side.
Step 4: Form Request Validation
Form Requests keep validation out of controllers. When the client sends Accept: application/json, a failed validation automatically returns a 422 response with a message and an errors object keyed by field.
// app/Http/Requests/StoreProjectRequest.php
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return true; // authorization is handled by the policy
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:160'],
'description' => ['nullable', 'string', 'max:5000'],
'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
];
}
}UpdateProjectRequest can use the same rules with sometimes added, so partial updates work.
Step 5: Controllers and apiResource Routes
// app/Http/Controllers/Api/ProjectController.php
class ProjectController extends Controller
{
public function index(Request $request)
{
Gate::authorize('viewAny', Project::class);
$projects = Project::query()
->with('owner')
->when(! $request->user()->isAdmin(), fn ($q) => $q->where('user_id', $request->user()->id))
->latest()
->paginate(15);
return ProjectResource::collection($projects);
}
public function store(StoreProjectRequest $request)
{
Gate::authorize('create', Project::class);
$project = $request->user()->projects()->create($request->validated());
return ProjectResource::make($project->load('owner'))
->response()
->setStatusCode(201);
}
public function show(Project $project)
{
Gate::authorize('view', $project);
return ProjectResource::make($project->load('owner'));
}
public function update(UpdateProjectRequest $request, Project $project)
{
Gate::authorize('update', $project);
$project->update($request->validated());
return ProjectResource::make($project->load('owner'));
}
public function destroy(Project $project)
{
Gate::authorize('delete', $project);
$project->delete();
return response()->noContent();
}
}The projects() relationship is a simple hasMany(Project::class) on the User model.
Now register the routes:
// routes/api.php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ProjectController;
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [AuthController::class, 'me']);
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('projects', ProjectController::class);
});apiResource registers index, store, show, update and destroy, without the create and edit routes that only make sense for HTML forms.
Step 6: Sanctum SPA Cookie Auth vs API Tokens
Sanctum supports two authentication styles, and choosing the right one matters.
- SPA authentication (cookies): For your own first-party frontend. Uses Laravel's session, gets CSRF protection, and keeps credentials out of JavaScript.
- API tokens: For mobile apps, CLI tools and third parties. Sent as
Authorization: Bearer <token>.
The Laravel docs are explicit: do not use API tokens to authenticate your own first-party SPA. Use cookie-based SPA auth instead. Storing a bearer token in localStorage exposes it to any XSS bug, which I also cover in my Next.js authentication guide.
Configure stateful requests
Enable stateful API requests in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})Then set your environment so the SPA domain is treated as first-party:
# .env (local)
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:5173
SANCTUM_STATEFUL_DOMAINS=localhost:5173
SESSION_DOMAIN=localhostThe SPA and the API must share the same top-level domain. Subdomains such as app.example.com and api.example.com are fine.
Configure CORS
Publish the CORS config and allow credentials:
php artisan config:publish cors// config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'supports_credentials' => true,With credentials enabled, the allowed origin must be an explicit URL. A wildcard * will not work.
The login controller
// app/Http/Controllers/Api/AuthController.php
class AuthController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::guard('web')->attempt($credentials)) {
throw ValidationException::withMessages([
'email' => ['Invalid email or password.'],
]);
}
$request->session()->regenerate();
return new UserResource($request->user());
}
public function me(Request $request)
{
return new UserResource($request->user());
}
public function logout(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
}The flow from the browser is always the same:
GET /sanctum/csrf-cookie → Laravel sets XSRF-TOKEN cookie
POST /api/login → session cookie issued
GET /api/user → authenticated via session cookieStep 7: Role-Based Authorization with Policies and Gates
Authentication tells you who the user is. Authorization decides what they can touch. Keep the role on the user and cast it to an enum:
// app/Enums/UserRole.php
enum UserRole: string
{
case Admin = 'admin';
case Manager = 'manager';
case Member = 'member';
}
// app/Models/User.php
protected function casts(): array
{
return [
'password' => 'hashed',
'role' => UserRole::class,
];
}
public function isAdmin(): bool
{
return $this->role === UserRole::Admin;
}Then put the rules in the policy:
// app/Policies/ProjectPolicy.php
class ProjectPolicy
{
public function before(User $user): ?bool
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Project $project): bool
{
return $user->id === $project->user_id;
}
public function create(User $user): bool
{
return in_array($user->role, [UserRole::Manager, UserRole::Member], true);
}
public function update(User $user, Project $project): bool
{
return $user->id === $project->user_id;
}
public function delete(User $user, Project $project): bool
{
return $user->role === UserRole::Manager && $user->id === $project->user_id;
}
}Laravel discovers ProjectPolicy automatically because it follows the naming convention. For checks that are not tied to a model, such as "can see the reports dashboard", define a Gate in AppServiceProvider::boot():
Gate::define('view-reports', fn (User $user) => $user->isAdmin());A failed check returns 403. That is the single source of truth. The React app may hide buttons, but the API enforces the rules.
Step 8: The React Side (Vite + TypeScript)
Create the frontend:
npm create vite@latest web -- --template react-ts
cd web
npm install react-routerA typed API client with credentials
Sanctum needs two things from every request: the cookies (credentials: "include") and the CSRF token copied from the XSRF-TOKEN cookie into an X-XSRF-TOKEN header. Axios does the header part for you when withCredentials and withXSRFToken are enabled. With fetch, you do it yourself:
// src/lib/api.ts
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public errors: Record<string, string[]> = {},
) {
super(message);
}
}
function getXsrfToken(): string | undefined {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
if (init.body) headers.set("Content-Type", "application/json");
const xsrf = getXsrfToken();
if (xsrf) headers.set("X-XSRF-TOKEN", xsrf);
const res = await fetch(`${API_URL}${path}`, {
...init,
headers,
credentials: "include",
});
if (res.status === 204) return undefined as T;
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(res.status, body.message ?? "Request failed", body.errors);
}
return body as T;
}
export const csrf = () => api<void>("/sanctum/csrf-cookie");Typed endpoints that mirror your Resources
// src/lib/projects.ts
import { api } from "./api";
export type ProjectStatus = "draft" | "active" | "archived";
export interface Project {
id: number;
title: string;
description: string | null;
status: ProjectStatus;
owner: { id: number; name: string };
createdAt: string;
}
export interface Paginated<T> {
data: T[];
links: { first: string | null; last: string | null; prev: string | null; next: string | null };
meta: { current_page: number; last_page: number; per_page: number; total: number };
}
export const projectsApi = {
list: (page = 1) => api<Paginated<Project>>(`/api/projects?page=${page}`),
get: (id: number) => api<{ data: Project }>(`/api/projects/${id}`),
create: (input: Pick<Project, "title" | "description" | "status">) =>
api<{ data: Project }>("/api/projects", { method: "POST", body: JSON.stringify(input) }),
remove: (id: number) => api<void>(`/api/projects/${id}`, { method: "DELETE" }),
};Single resources are wrapped in a data key by default. Paginated collections return data, links and meta. If you want more on keeping types honest across both layers, see my TypeScript with React and Node.js guide.
Auth context and protected routes
// src/auth/AuthProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { api, csrf } from "../lib/api";
export interface User { id: number; name: string; email: string; role: "admin" | "manager" | "member" }
interface AuthState {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<{ data: User }>("/api/user")
.then((res) => setUser(res.data))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
async function login(email: string, password: string) {
await csrf();
const res = await api<{ data: User }>("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
setUser(res.data);
}
async function logout() {
await api<void>("/api/logout", { method: "POST" });
setUser(null);
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}// src/auth/RequireAuth.tsx
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth, type User } from "./AuthProvider";
export function RequireAuth({ roles }: { roles?: User["role"][] }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <p>Loading…</p>;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
if (roles && !roles.includes(user.role)) return <Navigate to="/403" replace />;
return <Outlet />;
}Wrap your dashboard routes in <RequireAuth />, and admin-only routes in <RequireAuth roles={["admin"]} />. Remember that this is a UX guard. The real protection is the policy on the server.
Step 9: Pagination and Error Handling in the UI
Laravel's paginator gives you everything a table needs:
// src/pages/ProjectsPage.tsx
import { useEffect, useState } from "react";
import { projectsApi, type Paginated, type Project } from "../lib/projects";
import { ApiError } from "../lib/api";
export function ProjectsPage() {
const [page, setPage] = useState(1);
const [result, setResult] = useState<Paginated<Project> | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
projectsApi
.list(page)
.then(setResult)
.catch((err) => setError(err instanceof ApiError ? err.message : "Something went wrong"));
}, [page]);
if (error) return <p role="alert">{error}</p>;
if (!result) return <p>Loading…</p>;
return (
<section>
<ul>
{result.data.map((p) => (
<li key={p.id}>{p.title} · {p.status}</li>
))}
</ul>
<button disabled={!result.links.prev} onClick={() => setPage((n) => n - 1)}>Previous</button>
<span> Page {result.meta.current_page} of {result.meta.last_page} </span>
<button disabled={!result.links.next} onClick={() => setPage((n) => n + 1)}>Next</button>
</section>
);
}Handle status codes deliberately:
- 401: Session missing or expired. Clear the user and redirect to login.
- 403: Authenticated but not allowed. Show a "no access" message.
- 419: CSRF token mismatch or expired session. Call
/sanctum/csrf-cookieagain, then retry or send the user to login. - 422: Validation failed. Map
error.errors.title[0]to the matching form field.
On the Laravel side, make sure API routes always render JSON errors, even if a client forgets the Accept header:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson()
);
})Deployment Notes
A setup that works on localhost can break in production because of domains and cookies. Things to check:
- Host the SPA and API on the same root domain, for example
app.example.comandapi.example.com. - Set
SESSION_DOMAIN=.example.comandSANCTUM_STATEFUL_DOMAINS=app.example.com. - Set
FRONTEND_URL=https://app.example.comso CORS allows the real origin. - Serve both over HTTPS and set
SESSION_SECURE_COOKIE=true. - Run
php artisan migrate --forceandphp artisan optimizeon each deploy. - Build the React app with
VITE_API_URL=https://api.example.comand serve it with a fallback toindex.htmlso client-side routes work on refresh. - Run queue workers under a process manager if you send email or notifications.
For frontend speed after launch, my JavaScript performance techniques apply directly to a Vite SPA.
Common Mistakes
- Using bearer tokens in localStorage for your own SPA. Use Sanctum's cookie-based SPA auth instead.
- Forgetting
credentials: "include". Without it, the browser never sends or stores the session cookie. - Wildcard CORS origins with credentials. Browsers reject
*when credentials are allowed. - Skipping
/sanctum/csrf-cookiebefore login. The result is a 419 on the login request. - Mismatched stateful domains.
localhost:5173and127.0.0.1:5173are different origins. Pick one and use it everywhere, including the port. - Making
user_idfillable. Users could assign records to someone else. Set ownership from$request->user(). - Hiding buttons instead of authorizing. Every controller action needs a policy check.
- Returning raw models. Use API Resources so new columns never leak by accident.
Laravel + React API Checklist
php artisan install:apirun and Sanctum migration appliedstatefulApi()enabled inbootstrap/app.phpSANCTUM_STATEFUL_DOMAINSandSESSION_DOMAINmatch your real domains- CORS published with
supports_credentialsset totrueand explicit origins - Login calls
/sanctum/csrf-cookiefirst and regenerates the session - Every request sends
Accept: application/jsonand cookies - Form Requests validate all write endpoints
- Policies authorize every controller action
- API Resources define every JSON response
- React handles 401, 403, 419 and 422 explicitly
- HTTPS and secure cookies enabled in production
Related Full-Stack Guides
- How to Build a Secure REST API with Node.js & MongoDB →The same API security ideas applied to Express, JWT and MongoDB.
- Next.js 16 Authentication →Sessions, protected routes and role-based access in Next.js.
- Build a Full-Stack App with React, Node.js and MongoDB →A complete JavaScript-only alternative stack.
- TypeScript with React and Node.js →Keeping types consistent across frontend and backend.
- React vs Next.js →Choosing between a plain React SPA and a full-stack framework.
Frequently Asked Questions
Should I use Sanctum or Passport for a React SPA?
Use Sanctum. It is designed for first-party SPAs and simple token APIs. Passport is a full OAuth2 server and only makes sense when third parties need OAuth flows.
Why do I get a 419 error when logging in from React?
A 419 means the CSRF token is missing or invalid. Call /sanctum/csrf-cookie before login, send cookies with every request, and copy the XSRF-TOKEN cookie into the X-XSRF-TOKEN header.
Can the React app and Laravel API be on different domains?
For cookie-based SPA auth, they must share the same top-level domain, though different subdomains are fine. For completely different domains, use API tokens instead.
Where should role checks live in a Laravel and React app?
In Laravel policies and gates. React can hide UI based on the user's role, but the API must enforce every permission because client-side checks can be bypassed.
How does Laravel pagination work with React?
Returning a paginated query through ProjectResource::collection() produces data, links and meta keys. React reads meta.current_page and meta.last_page and requests ?page=n.
Do I need Axios, or is fetch enough?
Fetch is enough. Axios sets the X-XSRF-TOKEN header automatically, but with fetch you only need a small helper that reads the cookie and sets the header.
My Recommendation
Start with Sanctum SPA cookie auth, API Resources, Form Requests and policies from day one. Adding them later means touching every endpoint.
Keep a single typed API layer in React and never call fetch directly from components. When the backend contract changes, you update one file and TypeScript shows you everything else that broke.
Conclusion
Building a Laravel REST API with React is mostly about getting the boundary right. Laravel handles validation, authentication and authorization. React handles the experience.
Use install:api and Sanctum's cookie-based SPA auth, shape responses with API Resources, validate with Form Requests, and enforce roles with policies. On the React side, send credentials and the CSRF header on every request, mirror your resources in TypeScript, and handle 401, 403, 419 and 422 deliberately.
That is the same foundation behind multi-role platforms like VettedPool, and it scales well from a single dashboard to many.