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:middleware
Install 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.js
Why 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