Building a modern full-stack application with Laravel, Inertia.js, and Vue gives you the best of both worlds: Laravel's powerful backend with Vue's reactive frontend - without building a separate API. Inertia.js acts as the glue, letting you build single-page apps using classic server-side routing.
What is Inertia.js?
Inertia.js is not a framework - it's a routing library that connects your server-side framework (Laravel) with your client-side framework (Vue). Instead of building an API + SPA separately, Inertia lets you:
- Use Laravel routes and controllers as usual
- Return Vue pages instead of Blade views
- Get SPA-like navigation without page reloads
- No need to build a REST/GraphQL API
1. Project Setup
Start by creating a new Laravel project and installing the Inertia.js server-side adapter, then set up Vue with the client-side adapter.
Install Laravel & Inertia Server-Side
# Create a new Laravel project
composer create-project laravel/laravel my-app
cd my-app
# Install Inertia server-side
composer require inertiajs/inertia-laravel
# Publish the middleware
php artisan inertia:middlewareInstall Vue & Inertia Client-Side
# Install Vue 3, Inertia client adapter & Vite plugin
npm install vue@3 @inertiajs/vue3
npm install -D @vitejs/plugin-vue2. Configuration
Root Blade Template
Create the root template that Inertia uses to boot your Vue app. This replaces your usual Blade layout.
<!-- resources/views/app.blade.php -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@vite('resources/js/app.js')
@inertiaHead
</head>
<body>
@inertia
</body>
</html>Vite Configuration
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: ['resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
});Vue App Entry Point
// resources/js/app.js
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
createInertiaApp({
resolve: (name) => resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue')
),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});3. Routing & Controllers
With Inertia, you use standard Laravel routes. Instead of returning a Blade view, you return an Inertia::render() response that maps to a Vue page component.
Routes
// routes/web.php
use App\Http\Controllers\PostController;
Route::get('/', function () {
return Inertia::render('Home');
});
Route::resource('posts', PostController::class);Controller
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PostController extends Controller
{
public function index()
{
return Inertia::render('Posts/Index', [
'posts' => Post::latest()
->paginate(10)
->through(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'date' => $post->created_at->format('M d, Y'),
]),
]);
}
public function show(Post $post)
{
return Inertia::render('Posts/Show', [
'post' => $post->only('id', 'title', 'body', 'created_at'),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
Post::create($validated);
return redirect()->route('posts.index')
->with('success', 'Post created!');
}
}4. Vue Page Components
Vue pages live in resources/js/Pages/. Props passed from the controller are automatically available. Use defineProps to type them.
Posts List Page
<!-- resources/js/Pages/Posts/Index.vue -->
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
posts: Object,
});
</script>
<template>
<div class="max-w-4xl mx-auto py-12 px-6">
<h1 class="text-3xl font-bold mb-8">All Posts</h1>
<div v-for="post in posts.data" :key="post.id"
class="mb-4 p-6 bg-white rounded-xl shadow-sm">
<Link :href="`/posts/${post.id}`"
class="text-xl font-semibold hover:text-blue-600">
{{ post.title }}
</Link>
<p class="text-gray-500 text-sm mt-1">{{ post.date }}</p>
</div>
</div>
</template>Create Post Page with Form
<!-- resources/js/Pages/Posts/Create.vue -->
<script setup>
import { useForm } from '@inertiajs/vue3';
const form = useForm({
title: '',
body: '',
});
const submit = () => {
form.post('/posts');
};
</script>
<template>
<div class="max-w-2xl mx-auto py-12 px-6">
<h1 class="text-3xl font-bold mb-8">Create Post</h1>
<form @submit.prevent="submit" class="space-y-6">
<div>
<label class="block font-medium mb-1">Title</label>
<input v-model="form.title" type="text"
class="w-full border rounded-lg px-4 py-2" />
<p v-if="form.errors.title"
class="text-red-500 text-sm mt-1">
{{ form.errors.title }}
</p>
</div>
<div>
<label class="block font-medium mb-1">Body</label>
<textarea v-model="form.body" rows="6"
class="w-full border rounded-lg px-4 py-2" />
<p v-if="form.errors.body"
class="text-red-500 text-sm mt-1">
{{ form.errors.body }}
</p>
</div>
<button type="submit"
:disabled="form.processing"
class="bg-blue-600 text-white px-6 py-2 rounded-lg">
{{ form.processing ? 'Saving...' : 'Create Post' }}
</button>
</form>
</div>
</template>5. Shared Data & Persistent Layouts
Share data globally (like the authenticated user) using Inertia's HandleInertiaRequests middleware, and use persistent layouts to keep UI state between page visits.
Shared Data (Middleware)
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user()
? $request->user()->only('id', 'name', 'email')
: null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
];
}Persistent Layout
<!-- resources/js/Layouts/AppLayout.vue -->
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
const { auth, flash } = usePage().props;
</script>
<template>
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow px-6 py-4 flex justify-between">
<Link href="/" class="font-bold text-xl">MyApp</Link>
<div v-if="auth.user">{{ auth.user.name }}</div>
</nav>
<!-- Flash Messages -->
<div v-if="flash.success"
class="bg-green-100 text-green-700 px-6 py-3">
{{ flash.success }}
</div>
<main>
<slot />
</main>
</div>
</template>
<!-- Use it in a page: -->
<!-- Posts/Index.vue -->
<script>
import AppLayout from '@/Layouts/AppLayout.vue';
export default { layout: AppLayout };
</script>6. Key Inertia Features
Preserve Scroll & State
<!-- Keep scroll position on navigation -->
<Link href="/posts" preserve-scroll>Posts</Link>
<!-- Partial reloads - only refresh specific props -->
import { router } from '@inertiajs/vue3';
router.reload({ only: ['posts'] }); // Only refresh posts data
// Programmatic navigation
router.visit('/posts');
router.post('/posts', { title: 'Hello', body: 'World' });
router.delete(`/posts/${id}`);Head & SEO
<script setup>
import { Head } from '@inertiajs/vue3';
</script>
<template>
<Head>
<title>My Page Title</title>
<meta name="description" content="Page description for SEO" />
</Head>
<!-- Page content -->
</template>Project Structure
my-app/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── PostController.php
│ │ └── Middleware/
│ │ └── HandleInertiaRequests.php
│ └── Models/
│ └── Post.php
├── resources/
│ ├── js/
│ │ ├── app.js # Vue entry point
│ │ ├── Pages/
│ │ │ ├── Home.vue
│ │ │ └── Posts/
│ │ │ ├── Index.vue
│ │ │ ├── Show.vue
│ │ │ └── Create.vue
│ │ ├── Components/
│ │ │ ├── Pagination.vue
│ │ │ └── FlashMessage.vue
│ │ └── Layouts/
│ │ └── AppLayout.vue
│ └── views/
│ └── app.blade.php # Inertia root template
├── routes/
│ └── web.php
└── vite.config.jsWhy Choose Laravel + Inertia + Vue?
- No API needed - Controllers pass data directly to Vue pages. No REST endpoints, no serialization boilerplate.
- Server-side routing - Use Laravel's route model binding, middleware, and policies as normal. No client-side router needed.
- SPA experience - Page visits happen via XHR, no full page reloads. Browser history, scroll position, and component state are preserved.
- Form handling - The
useForm()helper handles validation errors, progress state, and file uploads out of the box. - Full Laravel ecosystem - Authentication (Breeze/Jetstream), queues, notifications, broadcasting - everything works seamlessly with Inertia.
Summary
- Inertia.js bridges Laravel and Vue without building an API
- Controllers return Inertia::render() with props passed to Vue pages
- useForm() handles forms, validation errors, and loading states
- Share global data via HandleInertiaRequests middleware
- Use persistent layouts to keep state across navigations
- Partial reloads for efficient data fetching