laravel-inertia

Auto-invoked skill

Build SPAs with Laravel and Inertia.js using Vue or React

Trigger Keywords

This skill automatically activates when Claude detects these keywords:

inertia inertia.js vue spa react spa single page app ziggy breeze inertia

Overview

The laravel-inertia skill provides expertise for building modern single-page applications with Laravel and Inertia.js. Inertia allows you to build SPAs using classic server-side routing and controllers, without building an API.

What This Skill Provides

  • Framework Support - Vue 3, React, or Svelte integration
  • Server-Side Routing - No client-side router needed
  • Form Handling - useForm helper with validation
  • Partial Reloads - Optimize data fetching
  • SSR Support - Server-side rendering for SEO
  • File Uploads - Native FormData support with progress

Example Conversations

# Building a SPA
"Create a product management SPA with Inertia and Vue"

# Forms
"How do I handle form validation with Inertia?"

# Optimization
"Implement partial reloads for better performance"

# SEO
"Set up server-side rendering for my Inertia app"

Quick Start with Breeze

The fastest way to start with Inertia is using Laravel Breeze:

# Install Breeze with Vue
composer require laravel/breeze --dev
php artisan breeze:install vue

# Or with React
php artisan breeze:install react

# Or with TypeScript
php artisan breeze:install vue --typescript

# Build assets
npm install
npm run dev

Controller Pattern

Return Inertia responses from your controllers:

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Inertia\Inertia;
use Inertia\Response;

class ProductController extends Controller
{
    public function index(): Response
    {
        return Inertia::render('Products/Index', [
            'products' => Product::query()
                ->with('category')
                ->latest()
                ->paginate(15)
                ->through(fn ($product) => [
                    'id' => $product->id,
                    'name' => $product->name,
                    'price' => $product->price_formatted,
                    'category' => $product->category->name,
                ]),
            'filters' => request()->only(['search', 'category']),
        ]);
    }

    public function store(StoreProductRequest $request): RedirectResponse
    {
        Product::create($request->validated());

        return redirect()
            ->route('products.index')
            ->with('success', 'Product created successfully.');
    }
}

Vue Page Component

<!-- resources/js/Pages/Products/Index.vue -->
<script setup>
import { Head, Link, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'

defineProps({
    products: Object,
    filters: Object,
})

const deleteProduct = (id) => {
    if (confirm('Are you sure?')) {
        router.delete(route('products.destroy', id))
    }
}
</script>

<template>
    <AppLayout>
        <Head title="Products" />

        <div class="flex justify-between mb-6">
            <h1 class="text-2xl font-bold">Products</h1>
            <Link :href="route('products.create')" class="btn">
                Create Product
            </Link>
        </div>

        <table>
            <tbody>
                <tr v-for="product in products.data" :key="product.id">
                    <td></td>
                    <td></td>
                    <td>
                        <Link :href="route('products.edit', product.id)">
                            Edit
                        </Link>
                        <button @click="deleteProduct(product.id)">
                            Delete
                        </button>
                    </td>
                </tr>
            </tbody>
        </table>

        <!-- Pagination -->
        <div class="flex justify-between">
            <Link v-if="products.prev_page_url" :href="products.prev_page_url">
                Previous
            </Link>
            <Link v-if="products.next_page_url" :href="products.next_page_url">
                Next
            </Link>
        </div>
    </AppLayout>
</template>

Forms with useForm

The useForm helper provides form state management and validation:

<script setup>
import { useForm } from '@inertiajs/vue3'

const form = useForm({
    name: '',
    price: '',
    category_id: null,
})

const submit = () => {
    form.post(route('products.store'), {
        preserveScroll: true,
        onSuccess: () => form.reset(),
    })
}
</script>

<template>
    <form @submit.prevent="submit">
        <input
            v-model="form.name"
            type="text"
            :class="{ 'border-red-500': form.errors.name }"
        />
        <div v-if="form.errors.name" class="text-red-500">
            
        </div>

        <input v-model="form.price" type="number" step="0.01" />

        <button type="submit" :disabled="form.processing">
            <span v-if="form.processing">Creating...</span>
            <span v-else>Create Product</span>
        </button>
    </form>
</template>

File Uploads

<script setup>
import { useForm } from '@inertiajs/vue3'

const form = useForm({
    name: '',
    image: null,
})

const handleFileChange = (e) => {
    form.image = e.target.files[0]
}

const submit = () => {
    form.post(route('products.store'), {
        forceFormData: true, // Required for file uploads
        preserveScroll: true,
    })
}
</script>

<template>
    <form @submit.prevent="submit">
        <input type="file" @change="handleFileChange" accept="image/*" />

        <!-- Preview -->
        <img
            v-if="form.image"
            :src="URL.createObjectURL(form.image)"
            class="h-32 w-32"
        />

        <!-- Upload Progress -->
        <div v-if="form.progress">
            <div class="bg-gray-200 rounded-full h-2">
                <div
                    class="bg-blue-600 h-2 rounded-full"
                    :style="{ width: `${form.progress.percentage}%` }"
                ></div>
            </div>
            <p>% uploaded</p>
        </div>

        <button type="submit" :disabled="form.processing">Upload</button>
    </form>
</template>

Shared Data (Middleware)

Share data across all pages using HandleInertiaRequests middleware:

<?php

namespace App\Http\Middleware;

use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user() ? [
                    'id' => $request->user()->id,
                    'name' => $request->user()->name,
                    'email' => $request->user()->email,
                    'permissions' => $request->user()->permissions,
                ] : null,
            ],
            'flash' => [
                'success' => fn () => $request->session()->get('success'),
                'error' => fn () => $request->session()->get('error'),
            ],
            'errors' => fn () => $request->session()->get('errors')
                ?->getBag('default')?->getMessages() ?? (object) [],
        ];
    }
}

Partial Reloads

Only reload specific props for better performance:

<script setup>
import { router } from '@inertiajs/vue3'

const filterByCategory = (categoryId) => {
    router.get(
        route('products.index'),
        { category: categoryId },
        {
            only: ['products', 'filters'], // Only reload these props
            preserveState: true,
        }
    )
}

const loadMore = () => {
    router.reload({
        only: ['products'],
        preserveScroll: true,
        preserveState: true,
    })
}
</script>

Lazy Props

Defer expensive data loading until explicitly requested:

// Controller
return Inertia::render('Products/Show', [
    'product' => $product,
    // Only load reviews when explicitly requested
    'reviews' => Inertia::lazy(fn () => $product->reviews()->latest()->get()),
]);
<script setup>
import { router } from '@inertiajs/vue3'

const loadReviews = () => {
    router.reload({ only: ['reviews'] })
}
</script>

SEO with Head Component

<script setup>
import { Head } from '@inertiajs/vue3'

defineProps({
    product: Object,
})
</script>

<template>
    <div>
        <Head>
            <title></title>
            <meta name="description" :content="product.description" />
            <meta property="og:title" :content="product.name" />
            <meta property="og:image" :content="product.image_url" />
        </Head>

        <!-- Page content -->
    </div>
</template>

Testing Inertia Apps

<?php

use Inertia\Testing\AssertableInertia as Assert;

it('renders products index page', function () {
    Product::factory()->count(3)->create();

    $this->get(route('products.index'))
        ->assertInertia(fn (Assert $page) => $page
            ->component('Products/Index')
            ->has('products.data', 3)
            ->has('products.data.0', fn (Assert $page) => $page
                ->where('id', Product::first()->id)
                ->has('name')
                ->has('price')
                ->etc()
            )
        );
});

it('shares auth user data', function () {
    $this->actingAs($user = User::factory()->create());

    $this->get(route('products.index'))
        ->assertInertia(fn (Assert $page) => $page
            ->has('auth.user', fn (Assert $page) => $page
                ->where('id', $user->id)
                ->where('name', $user->name)
                ->etc()
            )
        );
});

Common Pitfalls

1. Not Using router for Navigation

Problem: Using regular <a> tags breaks SPA experience

<!-- Bad -->
<a href="/products">Products</a>

<!-- Good -->
<Link :href="route('products.index')">Products</Link>

2. Forgetting forceFormData for File Uploads

Problem: Files aren't uploaded without FormData

// Bad
form.post(route('products.store'))

// Good
form.post(route('products.store'), {
    forceFormData: true,
})

3. Not Preserving Scroll on Updates

Problem: User loses position after actions

// Bad
router.delete(route('products.destroy', id))

// Good
router.delete(route('products.destroy', id), {
    preserveScroll: true,
})

4. Returning Full Models to Frontend

Problem: Exposes sensitive data and increases payload

// Bad
return Inertia::render('Products/Index', [
    'products' => Product::all(),
]);

// Good
return Inertia::render('Products/Index', [
    'products' => Product::all()->map(fn ($p) => [
        'id' => $p->id,
        'name' => $p->name,
        'price' => $p->price_formatted,
    ]),
]);

5. N+1 Queries in Props

Problem: Causes performance issues

// Bad
return Inertia::render('Products/Index', [
    'products' => Product::all(), // N+1 when accessing relations
]);

// Good
return Inertia::render('Products/Index', [
    'products' => Product::with('category')->get(),
]);

6. Not Using Lazy Props for Heavy Data

Problem: Slows initial page load

// Bad
return Inertia::render('Dashboard', [
    'stats' => $this->calculateHeavyStats(),
]);

// Good
return Inertia::render('Dashboard', [
    'stats' => Inertia::lazy(fn () => $this->calculateHeavyStats()),
]);

Best Practices

  • Transform Data in Controllers - Don't expose raw models to the frontend
  • Use Partial Reloads - Only reload necessary data for better performance
  • Implement Loading States - Show feedback during form submissions
  • Preserve Scroll Intelligently - Better UX for list updates
  • Use Lazy Props for Heavy Data - Faster initial page loads
  • Share Only Necessary User Data - Security and performance
  • Use Ziggy for Route Helpers - Type-safe routing in JavaScript
  • Implement Proper Error Handling - Display validation errors properly
  • Use SSR for SEO-Critical Pages - Better search rankings
  • Test Inertia Assertions - Verify props and components are correct

Package Integration

  • tightenco/ziggy - Laravel routes in JavaScript
  • spatie/laravel-query-builder - Advanced filtering and sorting
  • spatie/laravel-medialibrary - File uploads and media management
  • laravel/sanctum - API authentication if needed

Related Commands

  • /laravel-agent:inertia:make - Create Inertia page components
  • /laravel-agent:inertia:install - Setup Inertia with Vue or React
  • /laravel-agent:breeze:install - Install Laravel Breeze with Inertia

Related Skills

  • laravel-api - Building REST APIs (alternative approach)
  • laravel-livewire - Alternative reactive framework
  • laravel-feature - Feature structure patterns
  • laravel-testing - Testing strategies

Learn More