Single-page applications deliver a smooth, app-like experience. Traditionally, building an SPA meant designing a REST or GraphQL API, handling token authentication, and maintaining separate client and server codebases. Laravel Breeze with Inertia.js eliminates nearly all of that overhead.
Inertia lets you build server-side rendered SPAs using JavaScript components instead of Blade templates. You get the client-side experience without building a dedicated API. Itâs not a framework â itâs a client-side routing library that uses XHR requests to enable page visits without full page reloads.
Getting Started with Breeze
Create a new Laravel application and install Breeze:
$ composer create-project laravel/laravel inertia
$ cd inertia/
$ composer require laravel/breeze --dev
$ php artisan migrate
$ php artisan breeze:install vue
$ npm run devThatâs it. Visit your app, click Register, and you get a fully functional registration form styled and ready to go.
The breeze:install vue command scaffolds Vue.js components for login, registration, password reset, email verification, and profile management. React is also supported:
$ php artisan breeze:install reactHow Inertia Works
Open routes/web.php. Instead of rendering Blade templates, your routes return Inertia page components:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('Welcome', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'laravelVersion' => Application::VERSION,
'phpVersion' => PHP_VERSION,
]);
});
Route::get('/dashboard', function () {
return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
require __DIR__.'/auth.php';The Inertia::render() call returns data to a Vue component. The Dashboard route maps to resources/js/Pages/Dashboard.vue. Inertia handles the rest â no API endpoints, no JSON serialization, no client-side routing configuration.
Component Structure
Breeze ships with two layout files. Authenticated users get AuthenticatedLayout.vue with navigation and user menus. Unauthenticated users see GuestLayout.vue with minimal chrome:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head } from '@inertiajs/inertia-vue3';
</script>
<template>
<Head title="Dashboard" />
<AuthenticatedLayout>
<template #header>
<h2 class="...">Dashboard</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="...">
You're logged in!
</div>
</div>
</div>
</AuthenticatedLayout>
</template>Using the correct layout matters. Put an admin dashboard behind GuestLayout and you might expose sensitive data. Use AuthenticatedLayout for public pages and authenticated users will see navigation elements they shouldnât.
Building a Tasks Feature
Letâs add a practical feature: user-owned tasks. Create the model, migration, and controller:
$ php artisan make:model --migration --resource --controller TaskAdd the resource route in web.php:
use App\Http\Controllers\TaskController;
Route::resource('tasks', TaskController::class)
->only(['index', 'store', 'update', 'destroy'])
->middleware(['auth', 'verified']);The migration creates a tasks table linked to users:
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
});Define the model with a user relationship:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Task extends Model
{
use HasFactory;
protected $fillable = [
'name',
];
public function user()
{
return $this->belongsTo(User::class);
}
}Add the inverse relationship to User:
public function tasks()
{
return $this->hasMany(Task::class);
}Task Controller with Inertia
The store method validates and creates tasks, then redirects. Inertia handles the client-side update:
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$request->user()->tasks()->create($validated);
return redirect(route('tasks.index'));
}Authorization with Policies
Create a policy so users can only edit their own tasks:
$ php artisan make:policy TaskPolicy --model=Taskpublic function update(User $user, Task $task)
{
return $task->user()->is($user);
}
public function delete(User $user, Task $task)
{
return $this->update($user, $task);
}The update and destroy methods in the controller authorize against this policy:
public function update(Request $request, Task $task)
{
$this->authorize('update', $task);
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$task->update($validated);
return redirect(route('tasks.index'));
}
public function destroy(Task $task)
{
$this->authorize('delete', $task);
$task->delete();
return redirect(route('tasks.index'));
}Building the Vue Components
The resources/js/Pages/Tasks/Index.vue page handles listing and creating tasks:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head } from '@inertiajs/inertia-vue3';
import { useForm } from '@inertiajs/inertia-vue3';
import Task from '@/Components/Task.vue';
defineProps(['tasks']);
const form = useForm({
name: '',
});
</script>
<template>
<Head title="Tasks" />
<AuthenticatedLayout>
<div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
<form @submit.prevent="form.post(route('tasks.store'), { onSuccess: () => form.reset() })">
<input v-model="form.name" type="text" placeholder="New task" class="...">
<button type="submit" class="...">Add</button>
</form>
<div class="mt-6 ... divide-y">
<Task
v-for="task in tasks"
:key="task.id"
:task="task"
/>
</div>
</div>
</AuthenticatedLayout>
</template>The Task.vue component renders each item with edit and delete controls, gated by ownership:
<script setup>
import { useForm } from '@inertiajs/inertia-vue3';
import { ref } from 'vue';
const props = defineProps(['task']);
const form = useForm({
name: props.task.name,
});
const editing = ref(false);
</script>
<template>
<div class="p-6 flex space-x-2">
<div class="flex-1">
<Dropdown v-if="task.user.id === $page.props.auth.user.id">
<template #trigger>
<button>...</button>
</template>
</Dropdown>
<form v-if="editing"
@submit.prevent="form.put(route('tasks.update', task.id),
{ onSuccess: editing = false })">
<input v-model="form.name" class="...">
</form>
<p v-else class="...">{{ task.name }}</p>
</div>
</div>
</template>The v-if="task.user.id === $page.props.auth.user.id" check ensures users only see edit/delete controls for their own tasks. Create a second user and youâll see the difference immediately.
Why This Matters
Throughout this entire feature, we never built a single API endpoint. No REST routes. No GraphQL schema. No token handling. Inertia handles data flow between the server and Vue components transparently.
When you delete a task, Inertia processes the redirect, fetches the updated list from the tasks.index route, and swaps the DOM â all without a full page reload. The result is an SPA experience built with server-side Laravel patterns you already know.
If youâre not ready to dive into Vue.js, php artisan breeze:install accepts blade as an option for traditional server-rendered templates. But after seeing how little JavaScript is required to get the SPA experience, you might find yourself giving Vue or React a serious look.