Inertia and Starter Kits

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Inertia 3 (inertiajs/inertia-laravel 3.5.1, @inertiajs/react 3.8.0) and the React starter kit for Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The examples run in a React starter kit application created from the kit’s teams branch, with a small book catalogue added to it. Inertia lets a Laravel application use React, Vue or Svelte for its pages while keeping Laravel’s routing, controllers, validation and sessions: there is no separate JSON API. The React side itself is covered on React and Vue on Vue.

How Inertia works

A controller returns Inertia::render('books/index', $props) instead of a view. On the first visit Laravel sends a full HTML page containing the page object (component name, props, URL and asset version) as JSON, and the client renders the component. Clicking an Inertia link or submitting an Inertia form then sends an XHR with X-Inertia: true; the server answers with just the page object, and the client swaps the component:

sequenceDiagram autonumber participant B as Browser (React page) participant L as Laravel (HandleInertiaRequests, BookController) B->>L: GET /books (first visit, no X-Inertia header) L-->>B: 200 HTML, root view with the page object as JSON, deferred prop stats listed B->>L: GET /books (X-Inertia, X-Inertia-Partial-Data: stats) L-->>B: 200 JSON page object with only stats (and errors) B->>L: GET /books?search=kin (router.reload, only books and filters) L-->>B: 200 JSON with books and filters, other props kept client-side B->>L: POST /books (useForm submit, invalid) L-->>B: 302 back, the next GET returns props.errors B->>L: GET /books (X-Inertia-Version is stale after a deploy) L-->>B: 409 Conflict with X-Inertia-Location, the client does a full page load

The same interaction, adding a book, in the three server-driven front ends Laravel supports:

flowchart LR subgraph Blade B1[Form POST] --> B2[Controller validates and saves] --> B3[302 redirect] --> B4[GET renders a whole HTML page] end subgraph Livewire L1[wire:submit] --> L2[POST /livewire/update with the snapshot] --> L3[Component action runs] --> L4[JSON with the changed HTML, morphed into the DOM] end subgraph Inertia I1[useForm submit, XHR] --> I2[Controller validates and saves] --> I3[302 redirect] --> I4[JSON page object, React re-renders the page] end

All three keep routing, validation and sessions in Laravel. Blade reloads the page, Livewire re-renders one component on the server, and Inertia returns data that a JavaScript framework renders, which is why Inertia pages can use TypeScript end to end (TypeScript).

Pages and props on the server

<?php
// app/Http/Controllers/BookController.php
namespace App\Http\Controllers;

use App\Models\Book;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class BookController extends Controller
{
    public function index(Request $request): Response
    {
        $search = $request->string('search')->toString();

        return Inertia::render('books/index', [
            'filters' => ['search' => $search],
            'books' => fn () => Book::query()                              // evaluated only when requested
                ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
                ->orderBy('title')
                ->get(['id', 'title', 'author', 'published_year']),
            'stats' => Inertia::defer(fn () => [                           // fetched after the first render
                'books' => Book::count(),
                'newest' => Book::max('published_year'),
            ]),
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:200'],
            'author' => ['required', 'string', 'max:120'],
            'published_year' => ['nullable', 'integer', 'between:1450,'.now()->year],
        ]);

        Book::create($data);

        Inertia::flash('toast', ['type' => 'success', 'message' => 'Book added.']);

        return to_route('books.index');
    }
}
Prop First visit Partial reload Evaluated

'books' => Book::all()

Included

If requested

Always

'books' => fn () => Book::all()

Included

If requested

Only when sent

Inertia::optional(fn () => ...)

Never

If requested

Only when sent

Inertia::always(...)

Included

Always

Always

Inertia::defer(fn () => ..., 'group')

Listed, not sent

Fetched right after the first render

Only when sent

The protocol, checked with curl against the running application (the 32-character asset version shortened to f88c…):

$ curl -s localhost:8010/books | grep -o '<script data-page="app" type="application/json">.*</script>'
<script data-page="app" type="application/json">{"component":"books\/index","props":{"errors":{},"name":"Laravel","auth":{"user":null},
 "sidebarOpen":true,"currentTeam":null,"teams":[],"filters":{"search":""},"books":[{"id":1,"title":"Kindred",…},…]},"url":"\/books",
 "version":"f88c…","sharedProps":["errors","name","auth","sidebarOpen","currentTeam","teams"],"deferredProps":{"default":["stats"]}}</script>

$ curl -s -H 'X-Inertia: true' -H 'X-Inertia-Version: f88c…' -H 'X-Inertia-Partial-Component: books/index' -H 'X-Inertia-Partial-Data: stats' localhost:8010/books
{"component":"books\/index","props":{"errors":{},"stats":{"books":3,"newest":1993}},"url":"\/books","version":"f88c…",…}

$ curl -s -H 'X-Inertia: true' -H 'X-Inertia-Version: f88c…' -H 'X-Inertia-Partial-Component: books/index' -H 'X-Inertia-Partial-Data: books,filters' 'localhost:8010/books?search=kin'
{"component":"books\/index","props":{"errors":{},"filters":{"search":"kin"},"books":[{"id":1,"title":"Kindred","author":"Octavia E. Butler","published_year":1979}]},…}

$ curl -s -D - -o /dev/null -H 'X-Inertia: true' -H 'X-Inertia-Version: old' localhost:8010/books | grep -iE '^HTTP|^x-inertia'
HTTP/1.1 409 Conflict
X-Inertia-Location: http://localhost:8010/books
X-Inertia-Version: f88c…
  • The first response lists stats under deferredProps without computing it; the client asks for it straight after rendering, so a slow query never delays the page.

  • A partial reload returns only the requested props plus errors, which the Laravel adapter shares with Inertia::always(). Closures for the other props were not evaluated.

  • The asset version is a hash of the Vite manifest. After a deploy, an old tab sends the old version and gets 409 with X-Inertia-Location, so the client reloads the page and picks up the new JavaScript.

  • Everything in the page object is visible in the HTML source, including shared props for guests. Pass only what the page needs, never whole models with hidden attributes you rely on $hidden for, and no secrets.

Shared data

app/Http/Middleware/HandleInertiaRequests.php (in the web group) adds props to every page. The starter kit shares the application name, the user, the sidebar state and, with teams, the current team:

<?php
// app/Http/Middleware/HandleInertiaRequests.php (excerpt, as generated by the teams starter kit)
use Illuminate\Http\Request;

function share(Request $request): array
{
    $user = $request->user();

    return [
        ...parent::share($request),
        'name' => config('app.name'),
        'auth' => [
            'user' => $user,
        ],
        'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
        'currentTeam' => fn () => $user?->currentTeam ? $user->toUserTeam($user->currentTeam) : null,
        'teams' => fn () => $user?->toUserTeams(includeCurrent: true) ?? [],
    ];
}

'auth' => ['user' => $user] serializes the whole User model into every page: its $hidden attributes (password hash, remember token, two-factor secrets) are excluded, but any column added later is published unless it is hidden too. Many applications share an explicit array (id, name, email) instead.

The React page, forms and errors

// resources/js/pages/books/index.tsx
import { Deferred, Head, router, useForm } from '@inertiajs/react';
import { store } from '@/actions/App/Http/Controllers/BookController';

type Book = { id: number; title: string; author: string; published_year: number | null };

type Props = {
    filters: { search: string };
    books: Book[];
    stats?: { books: number; newest: number | null };
};

export default function BooksIndex({ filters, books, stats }: Props) {
    const form = useForm({ title: '', author: '', published_year: '' });

    function search(value: string) {
        // a partial reload: same page component, only the books prop is re-evaluated
        router.reload({ data: { search: value }, only: ['books', 'filters'] });
    }

    function submit(e: React.FormEvent) {
        e.preventDefault();
        form.submit(store(), { preserveScroll: true, onSuccess: () => form.reset() });
    }

    return (
        <>
            <Head title="Books" />
            <div className="space-y-6 p-4">
                <Deferred data="stats" fallback={<p>Counting…</p>}>
                    <p>{stats?.books} books, newest from {stats?.newest}</p>
                </Deferred>
                <input type="search" defaultValue={filters.search} onChange={(e) => search(e.target.value)} placeholder="Search titles" />
                <ul>
                    {books.map((book) => (<li key={book.id}>{book.title} · {book.author}</li>))}
                </ul>
                <form onSubmit={submit} className="grid max-w-sm gap-2">
                    <input value={form.data.title} onChange={(e) => form.setData('title', e.target.value)} placeholder="Title" />
                    {form.errors.title && <p className="text-red-600">{form.errors.title}</p>}
                    <input value={form.data.author} onChange={(e) => form.setData('author', e.target.value)} placeholder="Author" />
                    {form.errors.author && <p className="text-red-600">{form.errors.author}</p>}
                    <button type="submit" disabled={form.processing}>Add book</button>
                </form>
            </div>
        </>
    );
}

useForm keeps the field values, processing, errors and isDirty; form.submit(store()) posts to the URL that Wayfinder generated from BookController::store in resources/js/actions, so a renamed route becomes a TypeScript error instead of a 404. When validation fails, Laravel redirects back as usual; the adapter puts the errors into the errors prop and useForm exposes them per field, without any JSON error handling in the component. The kit’s own pages use the <Form> component, which does the same declaratively (<Form {...store.form()}>). Inertia::flash('toast', [...]) (Inertia 3) sends one-time data that the kit’s <Toaster> displays.

The first build of this page used the kit’s default layout resolver in app.tsx, which wraps every page except welcome and auth/* in AppLayout. Server-side rendering then failed with SSR ERROR books/index: Cannot read properties of null (reading 'avatar'): AppLayout assumes a signed-in user, and /books is public. Public pages need their own layout (the resolver now returns null for books/*) or the auth middleware.

Server-side rendering

npm run build:ssr builds a second bundle into bootstrap/ssr, and php artisan inertia:start-ssr runs the Node renderer that Laravel calls (on port 13714) for each first visit. With it running, the first response contains the rendered HTML:

<div data-server-rendered="true" id="app"><div class="space-y-6 p-4"><p>Counting…</p><input type="search" placeholder="Search titles" value=""/><ul><li>Kindred<!-- --> · <!-- -->Octavia E. Butler</li><li>Parable of the Sower<!-- --> · <!-- -->Octavia E. Butler</li><li>The Dispossessed<!-- --> · <!-- -->Ursula K. Le Guin</li></ul>…

The deferred stats were rendered as the fallback, Counting…, because they are not part of the first response. When the SSR server is down or a component throws, Laravel falls back to the empty <div id="app"> and client-side rendering, so SSR failures degrade rather than break the page. Inertia 3 also renders on the server during npm run dev without a separate Node process. The starter kit documentation mentions a composer dev:ssr script; the teams branch cloned for this page has build:ssr in package.json but no dev:ssr Composer script.

Testing Inertia responses

<?php
// tests/Feature/BooksTest.php (the cloned kit uses PHPUnit classes; laravel new --pest installs Pest instead)
namespace Tests\Feature;

use App\Models\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;

class BooksTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_renders_lazy_and_deferred_props(): void
    {
        Book::factory()->create(['title' => 'Kindred']);
        Book::factory()->create(['title' => 'Dawn']);

        $this->get('/books?search=kin')
            ->assertInertia(fn (Assert $page) => $page
                ->component('books/index')
                ->where('filters.search', 'kin')
                ->has('books', 1)
                ->where('books.0.title', 'Kindred')
                ->missing('stats')                               // deferred: not in the first response
                ->loadDeferredProps(fn (Assert $reload) => $reload
                    ->where('stats.books', 2)));
    }

    public function test_it_redirects_back_with_errors(): void
    {
        $this->from('/books')
            ->post('/books', ['title' => '', 'author' => 'Anon'])
            ->assertRedirect('/books')
            ->assertSessionHasErrors('title');
    }

    public function test_it_stores_a_book_and_flashes_a_toast(): void
    {
        $this->post('/books', ['title' => 'Dawn', 'author' => 'Octavia E. Butler', 'published_year' => 1987])
            ->assertRedirect('/books')
            ->assertInertiaFlash('toast.message', 'Book added.');

        $this->assertDatabaseHas('books', ['title' => 'Dawn']);
    }
}
$ php artisan test --filter=BooksTest
   PASS  Tests\Feature\BooksTest
  ✓ it renders lazy and deferred props                                   0.19s
  ✓ it redirects back with errors                                        0.02s
  ✓ it stores a book and flashes a toast                                 0.01s

  Tests:    3 passed (35 assertions)
  Duration: 0.32s

assertInertia() checks the component name and props without rendering any JavaScript; browser-level checks of the React side belong in end-to-end tests (Browser Testing with Dusk, End-to-End Testing in Real Browsers). The whole kit’s own suite (91 tests) passed alongside these.

The starter kits

laravel new my-app asks for a kit; --react, --vue, --svelte or --livewire choose one non-interactively, and --teams, --workos and --pest select variants. Every kit puts all of its code in the application, so it is a starting point to edit, not a dependency to upgrade:

Kit Front end

React

Inertia 3, React 19, TypeScript, Tailwind 4, shadcn/ui components in resources/js/components/ui

Vue

Inertia 3, Vue 3 Composition API, TypeScript, Tailwind 4, shadcn-vue

Svelte

Inertia 3, Svelte 5, TypeScript, Tailwind 4, shadcn-svelte

Livewire

Livewire 4 with Flux UI (Livewire); --livewire-class-components generates class components instead of single-file ones

What the React kit generated, inspected after installation:

  • Authentication through Laravel Fortify 1.40, with the features enabled in config/fortify.php: registration, password reset, e-mail verification, two-factor authentication with confirmation, and passkeys. Fortify registers the routes (/login, /register, /two-factor-challenge, …​), and app/Actions/Fortify/CreateNewUser.php and ResetUserPassword.php hold the logic to edit. Authentication is covered on Authentication.

  • Pages in resources/js/pages (auth/*, dashboard, settings/*, teams/*, welcome), layouts with a sidebar or header variant, and settings screens for profile, password, appearance, two-factor and passkeys.

  • Wayfinder 0.1 (laravel/wayfinder and its Vite plugin) generating typed route and controller functions into resources/js/routes and resources/js/actions at build time. Disabling a Fortify feature means removing its imports from the front end too, or the build fails.

  • Teams (the --teams variant): Team, Membership and TeamInvitation models, a personal team created at registration, invitation notifications, team switching, and team-scoped routes such as /{current_team}/dashboard guarded by an EnsureTeamMembership middleware; URL defaults fill in the current team, and reserved names (settings, login, …​) cannot be used as team slugs.

  • Tooling: Pint, PHPStan with Larastan, Vite+ (vp build, vp check for linting and formatting) and a composer dev script that runs php artisan dev.

  • The --workos variant replaces Fortify with WorkOS AuthKit (social login, passkeys, magic links, SSO) and needs WORKOS_CLIENT_ID, WORKOS_API_KEY and WORKOS_REDIRECT_URL; it was not run here (no WorkOS account).

Two practical findings from creating the application in a restricted build environment: laravel new --teams installs the dev-teams branch of laravel/react-starter-kit, which Composer downloads as a GitHub archive and which failed without GitHub API access (cloning the branch and running composer install worked); and the generated vite.config.ts loads Instrument Sans from fonts.bunny.net during npm run build, which failed without that host until the font was switched to the fontsource() provider (Vite and Frontend Assets).

Where older books and tutorials are outdated

  • Laravel Breeze and Jetstream (and Stauffer-era make:auth and laravel/ui before them) are no longer the starter kits: since Laravel 12 the React, Vue, Svelte and Livewire kits replace them, and Breeze and Jetstream are no longer developed.

  • Inertia 1 and 2 code with Axios interceptors changes in Inertia 3, which uses its own XHR client; Axios-based interceptors move to Inertia’s built-in ones.

  • A separate Node SSR server during development is not needed in Inertia 3, which renders on the server in Vite’s dev mode.

  • Hand-written URL strings in front-end code ('/books/' + id) are replaced in the kits by Wayfinder’s typed route functions.

  • Jetstream Teams map to the starter kits' --teams variant, with a different data model and no Jetstream dependency.