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:
Source: Inertia — How It Works and The Protocol
The same interaction, adding a book, in the three server-driven front ends Laravel supports:
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');
}
}
Source: Inertia — Responses and Deferred Props
| Prop | First visit | Partial reload | Evaluated |
|---|---|---|---|
|
Included |
If requested |
Always |
|
Included |
If requested |
Only when sent |
|
Never |
If requested |
Only when sent |
|
Included |
Always |
Always |
|
Listed, not sent |
Fetched right after the first render |
Only when sent |
Source: Inertia — Partial Reloads
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…
Source: Inertia — The Protocol and Asset Versioning
-
The first response lists
statsunderdeferredPropswithout 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 withInertia::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
409withX-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
$hiddenfor, 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) ?? [],
];
}
Source: Inertia — Shared Data
'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>
</>
);
}
Source: Inertia — Forms and Validation
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>…
Source: Inertia — Server-Side Rendering
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
Source: Inertia — Testing
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 |
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); |
Source: Laravel — Available Starter Kits
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, …), andapp/Actions/Fortify/CreateNewUser.phpandResetUserPassword.phphold 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/wayfinderand its Vite plugin) generating typed route and controller functions intoresources/js/routesandresources/js/actionsat build time. Disabling a Fortify feature means removing its imports from the front end too, or the build fails. -
Teams (the
--teamsvariant):Team,MembershipandTeamInvitationmodels, a personal team created at registration, invitation notifications, team switching, and team-scoped routes such as/{current_team}/dashboardguarded by anEnsureTeamMembershipmiddleware; 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 checkfor linting and formatting) and acomposer devscript that runsphp artisan dev. -
The
--workosvariant replaces Fortify with WorkOS AuthKit (social login, passkeys, magic links, SSO) and needsWORKOS_CLIENT_ID,WORKOS_API_KEYandWORKOS_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:authandlaravel/uibefore 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'
--teamsvariant, with a different data model and no Jetstream dependency.