Advanced Laravel
Inertia.js: Modern Monolith
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