Advanced Laravel

Inertia.js: Modern Monolith

18 min Lesson 38 of 40

Inertia.js: Modern Monolith

Inertia.js allows you to build modern single-page applications (SPAs) using classic server-side routing and controllers. It bridges the gap between traditional Laravel applications and modern frontend frameworks like Vue.js, React, or Svelte without needing to build a separate API.

Why Inertia.js?

  • No API required: Use Laravel controllers and routes directly
  • Server-side routing: Define routes in Laravel, not JavaScript
  • Modern UI: Build with Vue, React, or Svelte components
  • Automatic code splitting: Only load JavaScript needed for each page
  • Asset versioning: Automatic cache busting
  • Shared data: Pass data from middleware to all pages
Note: Inertia is not a replacement for Laravel or Vue/React. It's a glue that connects them, allowing you to use Laravel as your backend and Vue/React as your frontend without the complexity of building a separate API.

Installation and Setup

Install Inertia's server-side and client-side adapters:

# Install server-side adapter composer require inertiajs/inertia-laravel # Install client-side adapter (Vue 3 example) npm install @inertiajs/vue3 # Install Vue 3 npm install vue@next # Install Vite plugin for Vue npm install @vitejs/plugin-vue

Configure the root template and middleware:

<?php // app/Http/Middleware/HandleInertiaRequests.php namespace App\Http\Middleware; use Illuminate\Http\Request; use Inertia\Middleware; class HandleInertiaRequests extends Middleware { /** * The root template that's loaded on the first page visit */ protected $rootView = 'app'; /** * Determines current asset version */ public function version(Request $request): ?string { return parent::version($request); } /** * Define props shared with all pages */ public function share(Request $request): array { return array_merge(parent::share($request), [ 'auth' => [ 'user' => $request->user() ? [ 'id' => $request->user()->id, 'name' => $request->user()->name, 'email' => $request->user()->email, 'avatar' => $request->user()->avatar_url, 'roles' => $request->user()->roles->pluck('name'), ] : null, ], 'flash' => [ 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), 'warning' => fn () => $request->session()->get('warning'), ], 'errors' => fn () => $request->session()->get('errors') ? $request->session()->get('errors')->getBag('default')->getMessages() : (object) [], 'app' => [ 'name' => config('app.name'), 'locale' => app()->getLocale(), 'timezone' => config('app.timezone'), ], ]); } } // Register middleware in app/Http/Kernel.php protected $middlewareGroups = [ 'web' => [ // ... other middleware \App\Http\Middleware\HandleInertiaRequests::class, ], ];

Root Template (Blade)

Create a Blade template that serves as the app shell:

<!-- resources/views/app.blade.php --> <!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title inertia>{{ config('app.name') }}</title> <!-- Fonts --> <link rel="preconnect" href="https://fonts.googleapis.com"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet"> <!-- Scripts --> @routes @vite(['resources/js/app.js', "resources/js/Pages/{$page['component']}.vue"]) @inertiaHead </head> <body class="antialiased"> @inertia </body> </html>

Frontend Setup (Vue 3)

Configure your JavaScript entry point:

// resources/js/app.js import { createApp, h } from 'vue' import { createInertiaApp } from '@inertiajs/vue3' import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers' import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m' createInertiaApp({ title: (title) => title ? `${title} - ${import.meta.env.VITE_APP_NAME}` : import.meta.env.VITE_APP_NAME, resolve: (name) => resolvePageComponent( `./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue') ), setup({ el, App, props, plugin }) { return createApp({ render: () => h(App, props) }) .use(plugin) .use(ZiggyVue) .mount(el) }, progress: { color: '#4B5563', showSpinner: true, }, })
Tip: Use resolvePageComponent() with Vite's dynamic imports for automatic code splitting. Each page component will be loaded only when needed.

Controllers and Responses

Return Inertia responses from your controllers:

<?php namespace App\Http\Controllers; use Inertia\Inertia; use App\Models\Post; use Illuminate\Http\Request; class PostController extends Controller { /** * Display a listing of posts */ public function index() { return Inertia::render('Posts/Index', [ 'posts' => Post::with('author:id,name,avatar') ->latest() ->paginate(15) ->through(fn ($post) => [ 'id' => $post->id, 'title' => $post->title, 'excerpt' => $post->excerpt, 'published_at' => $post->published_at->format('M d, Y'), 'author' => [ 'name' => $post->author->name, 'avatar' => $post->author->avatar, ], 'can' => [ 'edit' => auth()->user()?->can('update', $post), 'delete' => auth()->user()?->can('delete', $post), ], ]), 'filters' => request()->only(['search', 'status', 'category']), ]); } /** * Show the form for creating a new post */ public function create() { return Inertia::render('Posts/Create', [ 'categories' => Category::select('id', 'name')->get(), 'tags' => Tag::select('id', 'name')->get(), ]); } /** * Store a newly created post */ public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|max:255', 'content' => 'required', 'category_id' => 'required|exists:categories,id', 'tags' => 'array', 'tags.*' => 'exists:tags,id', 'featured_image' => 'nullable|image|max:2048', ]); $post = auth()->user()->posts()->create($validated); if ($request->hasFile('featured_image')) { $post->addMedia($request->file('featured_image')) ->toMediaCollection('featured'); } $post->tags()->sync($request->tags); return redirect() ->route('posts.show', $post) ->with('success', 'Post created successfully!'); } /** * Display the specified post */ public function show(Post $post) { return Inertia::render('Posts/Show', [ 'post' => [ 'id' => $post->id, 'title' => $post->title, 'content' => $post->content, 'published_at' => $post->published_at->format('F j, Y'), 'author' => [ 'name' => $post->author->name, 'avatar' => $post->author->avatar_url, 'bio' => $post->author->bio, ], 'category' => $post->category->only('id', 'name', 'slug'), 'tags' => $post->tags->map->only('id', 'name', 'slug'), 'featured_image' => $post->getFirstMediaUrl('featured'), 'can' => [ 'edit' => auth()->user()?->can('update', $post), 'delete' => auth()->user()?->can('delete', $post), ], ], 'related_posts' => Post::where('category_id', $post->category_id) ->where('id', '!=', $post->id) ->limit(3) ->get() ->map->only('id', 'title', 'excerpt', 'published_at'), ]); } }

Vue Components (Pages)

Create Vue components for your Inertia pages:

<!-- resources/js/Pages/Posts/Index.vue --> <script setup> import { ref, watch } from 'vue' import { router } from '@inertiajs/vue3' import AppLayout from '@/Layouts/AppLayout.vue' import Pagination from '@/Components/Pagination.vue' import SearchFilter from '@/Components/SearchFilter.vue' const props = defineProps({ posts: Object, filters: Object, }) const search = ref(props.filters.search || '') const status = ref(props.filters.status || '') const category = ref(props.filters.category || '') // Watch for filter changes and update URL watch([search, status, category], () => { router.get('/posts', { search: search.value, status: status.value, category: category.value, }, { preserveState: true, preserveScroll: true, replace: true, }) }, { debounce: 300 }) function deletePost(post) { if (confirm('Are you sure you want to delete this post?')) { router.delete(route('posts.destroy', post.id)) } } </script> <template> <AppLayout title="Posts"> <div class="max-w-7xl mx-auto py-6"> <div class="flex justify-between items-center mb-6"> <h1 class="text-3xl font-bold">Blog Posts</h1> <Link :href="route('posts.create')" class="btn btn-primary" > Create Post </Link> </div> <!-- Filters --> <SearchFilter v-model:search="search" v-model:status="status" v-model:category="category" /> <!-- Posts Grid --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div v-for="post in posts.data" :key="post.id" class="card" > <Link :href="route('posts.show', post.id)"> <h3 class="font-bold text-xl mb-2">{{ post.title }}</h3> <p class="text-gray-600 mb-4">{{ post.excerpt }}</p> </Link> <div class="flex items-center justify-between"> <div class="flex items-center"> <img :src="post.author.avatar" :alt="post.author.name" class="w-8 h-8 rounded-full mr-2" > <span class="text-sm text-gray-500"> {{ post.author.name }} </span> </div> <div v-if="post.can.edit || post.can.delete" class="flex gap-2"> <Link v-if="post.can.edit" :href="route('posts.edit', post.id)" class="btn btn-sm" > Edit </Link> <button v-if="post.can.delete" @click="deletePost(post)" class="btn btn-sm btn-danger" > Delete </button> </div> </div> </div> </div> <!-- Pagination --> <Pagination :links="posts.links" class="mt-6" /> </div> </AppLayout> </template>
Note: Use preserveState and preserveScroll options when filtering to maintain the current page state and scroll position. Use replace: true to replace the history entry instead of adding a new one.

Form Handling

Inertia provides a form helper for handling form submissions:

<!-- resources/js/Pages/Posts/Create.vue --> <script setup> import { useForm } from '@inertiajs/vue3' import AppLayout from '@/Layouts/AppLayout.vue' import TextInput from '@/Components/TextInput.vue' import TextArea from '@/Components/TextArea.vue' import SelectInput from '@/Components/SelectInput.vue' const props = defineProps({ categories: Array, tags: Array, }) const form = useForm({ title: '', content: '', category_id: null, tags: [], featured_image: null, }) function submit() { form.post(route('posts.store'), { preserveScroll: true, onSuccess: () => form.reset(), onError: (errors) => { console.error('Validation errors:', errors) }, }) } function handleFileChange(event) { form.featured_image = event.target.files[0] } </script> <template> <AppLayout title="Create Post"> <div class="max-w-3xl mx-auto py-6"> <h1 class="text-3xl font-bold mb-6">Create New Post</h1> <form @submit.prevent="submit" class="space-y-6"> <!-- Title --> <TextInput v-model="form.title" label="Title" :error="form.errors.title" required /> <!-- Category --> <SelectInput v-model="form.category_id" label="Category" :options="categories" :error="form.errors.category_id" required /> <!-- Content --> <TextArea v-model="form.content" label="Content" :error="form.errors.content" rows="10" required /> <!-- Tags (Multi-select) --> <div> <label class="block font-medium mb-2">Tags</label> <div class="flex flex-wrap gap-2"> <label v-for="tag in tags" :key="tag.id" class="inline-flex items-center" > <input type="checkbox" :value="tag.id" v-model="form.tags" class="rounded" > <span class="ml-2">{{ tag.name }}</span> </label> </div> <div v-if="form.errors.tags" class="text-red-600 text-sm mt-1"> {{ form.errors.tags }} </div> </div> <!-- Featured Image --> <div> <label class="block font-medium mb-2">Featured Image</label> <input type="file" @change="handleFileChange" accept="image/*" class="block w-full" > <div v-if="form.errors.featured_image" class="text-red-600 text-sm mt-1"> {{ form.errors.featured_image }} </div> <!-- Progress bar --> <progress v-if="form.progress" :value="form.progress.percentage" max="100" class="w-full mt-2" > {{ form.progress.percentage }}% </progress> </div> <!-- Submit Buttons --> <div class="flex gap-4"> <button type="submit" :disabled="form.processing" class="btn btn-primary" > <span v-if="form.processing">Creating...</span> <span v-else>Create Post</span> </button> <Link :href="route('posts.index')" class="btn" > Cancel </Link> </div> </form> </div> </AppLayout> </template>
Tip: The useForm() helper tracks form state, validation errors, and provides useful properties like processing, progress, and methods like reset().

Server-Side Rendering (SSR)

Enable SSR for improved SEO and initial page load performance:

# Install SSR dependencies npm install @inertiajs/vue3-ssr @vue/server-renderer # Create SSR entry point // resources/js/ssr.js import { createSSRApp, h } from 'vue' import { renderToString } from '@vue/server-renderer' import { createInertiaApp } from '@inertiajs/vue3' import createServer from '@inertiajs/vue3/server' import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers' import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m' createServer((page) => createInertiaApp({ page, render: renderToString, resolve: (name) => resolvePageComponent( `./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue') ), setup({ App, props, plugin }) { return createSSRApp({ render: () => h(App, props) }) .use(plugin) .use(ZiggyVue, { ...page.props.ziggy, location: new URL(page.props.ziggy.location), }) }, }) ) # Update 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', 'resources/js/ssr.js'], ssr: 'resources/js/ssr.js', refresh: true, }), vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false, }, }, }), ], }) # Build SSR assets npm run build # Start SSR server php artisan inertia:start-ssr
Warning: SSR requires a Node.js server running alongside your PHP application. Make sure to start the SSR server with php artisan inertia:start-ssr in production and configure it to auto-restart on server boot.
Exercise 1: Build a product management system with Inertia.js. Create pages for: listing products (with search, filter by category, pagination), creating a product (with image upload), editing a product, and viewing product details. Include authorization checks (can.edit, can.delete) in the shared data.
Exercise 2: Implement a shopping cart system where users can add/remove items, update quantities, and proceed to checkout. Use Inertia's form helper for the checkout form with validation. Display real-time cart totals without page refresh using preserve state.
Exercise 3: Create a dashboard with real-time data that polls every 30 seconds for updates. Include charts (using a library like Chart.js), statistics cards, and a recent activity feed. Use Inertia's router.reload() with only option to refresh specific parts of the page without re-fetching everything.

Summary

In this lesson, you learned:

  • What Inertia.js is and why it's useful for modern monolithic applications
  • How to install and configure Inertia with Laravel and Vue 3
  • Creating controllers that return Inertia responses with data
  • Building Vue components as Inertia pages
  • Handling forms with validation and file uploads
  • Implementing server-side rendering for improved SEO