Why did I decide to build my own Laravel + React + Inertia template from scratch? You know how it usually goes? You need to start a new project. You open the Laravel docs, see the ready-made starter packages. You think: “Oh, cool, I’ll install this real quick and we’re off”. And sure enough, within an hour you’ve got a working prototype with authentication, user profile, password reset.
But then the fun begins. You try to change something to fit your needs. And that’s when you discover: the pre-made package is a black box. It pulls in controllers, routes, middleware on its own — things you didn’t even know existed. You change one line — and it breaks in three places. You think you’re in control of the project, but really you’re just using someone else’s magic.
That’s when I made a decision: enough. I’ll build my own template. From scratch. For Laravel 13, React 19, Inertia, and Tailwind 4. No Fortify, no pre-made solutions, no hidden dependencies. Only what I configure myself, with my own hands.
What did I get in the end?
I finally understood how Inertia connects frontend and backend, how routing works, why middleware is needed, how to pass data from a controller to a React component. Yes, it took a couple of evenings. But you know what’s cool? Now every new project I start in five minutes. I just copy my template — and I’m already writing business logic. No guessing “why does it work like this”, no fear of breaking something.
The main lesson I learned
When you build the foundation yourself, you stop being afraid. Because you wrote every single line with your own hands. You know where everything lives, why each file is needed, how it all connects. And that, bro, gives you a confidence that no pre-made package can ever provide.
That’s why in this article I’ll show you how to build such a template from scratch. Not because “it’s the right way” or “that’s how FAANG companies do it”. But because it’s genuinely convenient when you understand every layer of the system. Clean Laravel → you connect Inertia yourself → you configure Vite + React. No magic, no hidden controllers. Just you and the code.
In this article: • Steps 1-2: Installing Laravel and Inertia • Steps 3-7: Frontend setup (React, Vite, Blade) • Step 8: Installing shadcn/ui • Steps 9-10: Layout, pages, routes • Steps 11-12: Dev mode and launching
We’ll go strictly step by step. Only Laravel + Inertia + React. No Breeze, no Fortify, nothing else.
Just a starting point for development where you have full control, without “black boxes”.
First, make sure you have the latest versions of Node.js, npm, Composer, and PHP 8.4+ installed and available globally.
node -v # should be 20+
npm -v # 10+
php -v # 8.4+
composer -V
Step 1. Creating a Clean Laravel 13
Before we start adding anything, we need a fresh, completely clean Laravel. Without it, well, you know — nowhere to go.
I usually create the project folder right on my desktop — easier to find later, and it’s always in sight.
Let’s call it laravel-inertia-start. But if you prefer, name it my-app or whatever you like.
Then I open this folder in VS Code. The fastest way is to drag the folder directly into the editor window,
but you can also use “Open with Code”.
Now the most important part — the terminal. In VS Code it’s convenient: press Ctrl+` (that’s the same key as the tilde `~`, just with Ctrl),
and the terminal slides up from the bottom. Beautiful.
And here’s the important part, bro. We’re currently inside an empty folder. To create a fresh Laravel, right in the terminal we enter the command:
composer create-project laravel/laravel . --prefer-dist
The dot at the end isn’t accidental — it tells Composer: “install Laravel right here, in the current folder,
don’t create a new nested one”. If you forget the dot, it’ll create a laravel folder inside your
laravel-inertia-start folder, and then you’ll have to move everything around —
don’t repeat my mistakes.
After this command, Composer will start its magic. It’ll pull in Laravel 13 itself (the latest stable version, assuming you’re reading this around the article date), all dependencies, and create the folder structure. This takes about twenty to thirty seconds, depending on your internet.
When it’s done, you’ll see a bunch of files in the folder: artisan, composer.json,
folders like app, config, routes, and so on. This is our future masterpiece.
You can immediately check that everything works by launching the server:
php artisan serve
Step 2. Installing Inertia (Backend)
Alright, let’s get down to business. First, we need to install Inertia on the backend. But first, let me say a few words about why we’re doing all this.
You know, I used to struggle with the classic API + React setup. Meaning: you build endpoints in Laravel, a separate SPA on the frontend, they communicate via JSON, you’re constantly dealing with loaders, loading states, handling errors on the frontend, duplicating logic… In short, a real hassle. And if you need SSR — forget about it.
Inertia solves this problem in a way that still makes me smile when I think about it. It lets you write a full React frontend,
but you work with Laravel as if it were a classic monolith. Meaning: instead of returning JSON from your controller,
you simply call inertia()->render('Users/Index', $users) — and that’s it. The data automatically goes to the frontend,
Inertia renders the needed React component, updates the browser URL, changes the page title. And all this without a single
fetch or axios. Like magic, but no — just a very smart package.
Essentially, Inertia is a thin layer that steals the best parts of SPAs (fast page transitions without reloads) and the best parts of classic monoliths (single source of truth, no API hassle). For me, this has been a real Jedi trick for the past couple of years.
Installing Inertia on Laravel
So let’s install it. Go to the Laravel root and type:
composer require inertiajs/inertia-laravel
Quick note about PowerShell. If you’re on Windows, the default terminal might be PowerShell — it works, no problems. But sometimes you might get errors about script execution during installation. If that happens, just run the terminal as “Command Prompt” (cmd) or PowerShell as administrator — usually fixes it.
The package installs quickly. Composer will pull in inertia-laravel, and possibly a couple more dependencies with it.
All legit, no panic. Now we have the foundation for our Laravel Inertia React template.
Inertia is now installed on the Laravel side.
Publishing the Middleware
After installation, I usually publish the special middleware that will pass shared data to the frontend (like the user or flash messages):
php artisan inertia:middleware
This command creates the file App\Http\Middleware\HandleInertiaRequests.php.
If your IDE suddenly complains that it can’t find the Inertia class after installation —
just restart the terminal or run composer dump-autoload. Old issue, but easy to fix.
That’s it, the backend is ready. Now in any controller, instead of view('welcome') you can write
inertia('Dashboard', ['user' => $user]) — and Laravel will understand it needs to render
the React component Dashboard, passing the data along. Of course, it can’t do that yet — because we haven’t set up
the frontend part. But we’ll get there.
STEP 3. Editing bootstrap/app.php. Adding Our Middleware.
Laravel 13 changed the structure a bit — previously everything was configured in app/Http/Kernel.php,
but now the middleware is handled by the file bootstrap/app.php. And we need to tell Laravel:
“Hey, when you handle web requests, don’t forget to pass them through Inertia”.
So I open bootstrap/app.php and completely replace its contents with this code:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
// Default exception handler
})
->create();
Critical: The method ->withExceptions() must be present, otherwise you’ll get a BindingResolutionException error.
STEP 4. Configuring Frontend for Laravel Inertia React (package.json)
Alright, we’ve prepared the backend, Inertia is installed on Laravel, and we’ve hooked up the middleware. Now for the real meat — the frontend. We need to install React, Inertia for React, and a proper bundler to make it all fly.
I open the terminal (still in the project root) and start working my npm magic.
First, I install the core packages — the ones without which nothing will work at all:
npm i @inertiajs/react react react-dom
What’s happening here? @inertiajs/react is the adapter that connects Inertia with React.
It provides hooks, components like Head, Link, and most importantly — that very <Inertia />
that will render our pages. Without it, Inertia simply won’t understand how to talk to React.
react and react-dom — well, you get the idea, can’t live without them.
Next, we need a Vite plugin that will teach it to understand JSX and quickly rebuild React components.
This is for development, so with the -D flag (or --save-dev):
npm i @vitejs/plugin-react -D
@vitejs/plugin-react enables support for React components, Fast Refresh, and other goodies.
Without it, Vite will look at your JSX like a deer in headlights.
After all installations, your package.json will update — new dependencies will appear there.
Here’s roughly what the working configuration looks like at this stage (May 2026):
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^6.0.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
"dependencies": {
"@inertiajs/react": "^3.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
}
}
React 19 brought many improvements. Read about the new features in React 19 in the official blog.
STEP 5. Vite for Laravel Inertia React: Complete Setup
Vite is our bundler that will compile React, Tailwind, and everything else we write. Laravel 13 already works with Vite “out of the box”, but we need to tweak it a bit for our stack.
I open the root file vite.config.js and make some adjustments:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { bunny } from 'laravel-vite-plugin/fonts';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.jsx'],
refresh: true,
fonts: [
bunny('Instrument Sans', {
weights: [400, 500, 600],
}),
],
}),
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
},
},
server: {
watch: {
ignored: ['**/storage/framework/views/**'],
},
},
});
This vite.config.js configuration for Laravel Inertia React includes everything needed for fast development. Now let’s go through the settings so you understand what’s what when you come back to this file in a month.
The laravel() plugin — the foundation, without it Vite won’t understand it’s running inside Laravel.
In input I specify two entry points: app.css (where Tailwind is imported) and app.jsx (the main React file).
refresh: true — a nice feature: when I change Blade templates, the page reloads itself.
And fonts with bunny pulls the Instrument Sans font from Bunny CDN — fast and without extra hassle.
react() — enables React support and Fast Refresh. Without it, your components won’t even render.
tailwindcss() — plugin for Tailwind CSS 4. Note that I import it from @tailwindcss/vite,
not from the old package. In version 4, everything’s new and works faster.
resolve.alias — here I configure the @ alias, which will point to the resources/js folder.
Now in components I can write import Button from '@/Components/Button' instead of that endless ../../../Components/Button.
Trust me, a lifesaver for your eyes.
server.watch.ignored — I tell Vite: “don’t watch the storage/framework/views folder”.
Laravel caches Blade templates there, and if Vite watches it, strange bugs can happen. We just ignore it — and live peacefully.
Create jsconfig.json in the root — important for alias to work correctly.
{
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["./resources/js/*"]
}
},
"include": ["resources/js/**/*.js", "resources/js/**/*.jsx"]
}
Vite 8 runs on the new Rolldown engine. Vite documentation explains all settings in detail.
STEP 6. Creating the Blade Wrapper
So, we have React, we have Inertia, Vite is breathing down our neck. But one question remains: how will Laravel understand that it needs to serve not just an HTML page, but specifically the one that loads all the React stuff?
Simple answer: we need a single Blade template. It will be like the front door — greeting all requests and launching Inertia.
I create the file resources/views/app.blade.php and write this minimal code:
<!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>My Inertia App</title>
@viteReactRefresh
@vite(['resources/js/app.jsx', 'resources/css/app.css'])
</head>
<body>
@inertia
</body>
</html>
Look, it’s brilliantly simple here. The inertia attribute on the <title> tag — not a typo.
It’s needed so Inertia can dynamically change the page title when you navigate between sections. Convenient, right?
@viteReactRefresh — this directive is needed for hot reloading of React components.
Without it, Fast Refresh won’t work, and the page will reload entirely on every change. Not our style.
@vite(['resources/js/app.jsx', 'resources/css/app.css']) — here Vite injects links to the built files.
In development mode, these will be links to the dev server; in production — to compiled assets.
The main thing is we write it once, and it handles itself.
And the most important line — @inertia. This directive outputs the root container for the React application,
where Inertia will render the current component. You could say it’s the magical portal through which React enters our Laravel world.
And notice, bro, there’s nothing about React directly in this template. No div id="app", no manual scripts.
Inertia and Vite will do everything for us. Beautiful.
After this step, all requests that Inertia redirects for component rendering will land in this template,
load our future app.jsx, and show the right page.
Now we just need to write the app.jsx itself — the React application entry point.
STEP 7. React + Inertia Entry Point in Laravel
Alright, we’ve reached the heart of our application. We have the Blade wrapper, configured Vite, Inertia on the backend is waiting eagerly. One last thing — tell React: “wake up, bro, time to work”.
I create the file resources/js/app.jsx and write this code:
import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true });
return pages[`./Pages/${name}.jsx`];
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
},
});
The React entry point for Laravel Inertia is ready. Now let’s break down what’s happening here, because this isn’t magic — just smart code.
createInertiaApp — the main function that ties everything together.
It accepts a config with two key things: how to find components and how to render them.
resolve — here I tell Inertia: “Hey, when Laravel asks to render the Users/Index component,
you need to find the file ./Pages/Users/Index.jsx and return it”. I use import.meta.glob with eager: true
so all components load immediately. For small to medium projects, this is fine. If the project is huge,
you can switch to lazy loading later, but for a starter template — perfect.
Note the structure: all my pages live in the resources/js/Pages/ folder. Each file is a React component
that corresponds to a specific route. If you call inertia('Dashboard') in Laravel, Inertia looks for ./Pages/Dashboard.jsx.
Logical and simple.
setup — this is where the actual rendering happens. I receive the DOM element
(the one created by @inertia in the Blade template), the React component App (Inertia’s internal wrapper),
and all props that came from the backend. Then createRoot(el).render(...) — the standard way to launch React 19,
without the outdated ReactDOM.render.
And you know what? After all this, our frontend is ready. Inertia now knows where to find components, React knows where to render, Vite watches for changes. But that’s not all. To make everything work, we still need to install shadcn/ui, create a layout, a couple of pages, and define routes in routes/web.php.
STEP 8. Adding shadcn/ui to Laravel Inertia React Template — Working Method Without Headaches
You might ask: what is shadcn/ui? And why? Answer — it’s a modern tool for rapid UI component prototyping. It’s a great thing, and you’ll understand why in a moment! shadcn/ui components perfectly complement the Laravel React Inertia template. You get ready-made UI elements with full control.
You know, I had one issue with shadcn. I try to run the standard npx shadcn@latest init,
and it either downloads the CLI to a temporary folder, complains about missing dependencies, or pulls the wrong version.
This issue often comes up with zod — the schema validation package that shadcn uses internally.
I tried several methods and found one that works reliably. Sharing it with you.
Install shadcn and zod directly into the project
Instead of downloading the CLI via npx every time, I simply install shadcn as a regular development dependency.
Right into my project’s node_modules. Simple command:
npm install -D shadcn zod
What’s happening here? shadcn is the CLI itself for adding components. zod is the validation library
that shadcn uses under the hood. I install both with the -D flag (devDependencies) because they’re not needed in production.
Now shadcn lives locally, sees all your project’s dependencies, doesn’t get confused about versions, and doesn’t try to download something every time.
Initialization via npm exec
Now for the most important part. I run the initialization:
npm exec shadcn init
Press Enter twice (Radix->Nova)
Note: it’s specifically npm exec, not npx. Why? npm exec (or shortened npm x)
forces npm to use the locally installed version from the node_modules/.bin/ folder.
Whereas npx by default first checks the cache, might download a fresh version, ignore the local one,
and end up not seeing your zod.
I stepped on this rake once — sat for two hours wondering why the command failed with “Cannot find module ‘zod'”. Turns out npx was using its temporary CLI, not the one sitting next to zod in the project.
Adding Components
Once initialization succeeds, adding components is a breeze:
npm exec shadcn add button card input
After this, shadcn will create the folder resources/js/components/ui and place files like button.jsx,
card.jsx, input.jsx there. These aren’t pre-built binary dependencies — just source code for React components built on Tailwind.
You can edit them, customize them, change styles — full control.
And the best part: now in your pages you can write:
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
We’ll use them in the next step.
STEP 9. Creating Global Layout + Welcome and About Pages
Now we’ve reached the best part — actual pages. Our entry point app.jsx exists, Inertia is configured,
but inside — emptiness. Let’s fix that.
I want all pages to share a common header with menu and footer. No copying the same thing into every page. So first, I create a global layout — a wrapper that all other pages will be wrapped in.
I organize the structure so everything sits in its proper place:
resources/js/
├── app.jsx # Entry point (already exists)
├── Layouts/
│ └── AppLayout.jsx # ← New: global layout with menu
├── Pages/
│ ├── Home.jsx # ← New: homepage
│ ├── About.jsx # ← New: "About" page
Let’s first create the layout itself.
9.1 Creating the Layout for Our Pages
The global layout is the foundation of any Laravel Inertia React project. It ensures a consistent structure across all pages.
Create the file resources/js/Layouts/AppLayout.jsx
import { Link, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
export default function AppLayout({ children, title = 'My Site' }) {
const { url } = usePage();
// Helper for active link
const isActive = (path) => url === path ? 'text-primary font-medium' : 'text-muted-foreground hover:text-foreground';
return (
<div className="min-h-screen flex flex-col bg-background">
{/* Header with navigation */}
<header className="border-b bg-card">
<div className="container mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-foreground">
MyApp
</Link>
<nav className="flex items-center gap-6">
<Link href="/" className={isActive('/')}>
Home
</Link>
<Link href="/about" className={isActive('/about')}>
About
</Link>
<nav>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/login">Log In</Link>
</Button>
<Button size="sm" asChild>
<Link href="/register">Sign Up</Link>
</Button>
</div>
</div>
</header>
{/* Main content */}
<main className="flex-1 container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">{title}</h1>
{children}
</main>
{/* Footer */}
<footer className="border-t py-6 text-center text-sm text-muted-foreground">
<p>© {new Date().getFullYear()} MyApp. All rights reserved.</p>
</footer>
</div>
);
}
What’s important here. usePage() gives me access to the current URL.
With a simple isActive function, I make the active link highlighted — convenient and clear for users.
Link from Inertia instead of a regular a — because it enables page transitions without reloads, like in an SPA.
Everything else is just Tailwind markup: header, container, footer. And children — that’s where the specific page content will be inserted.
9.2 Creating the Homepage
Now for the homepage itself. I create resources/js/Pages/Home.jsx
import AppLayout from '@/Layouts/AppLayout';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function Home() {
return (
<AppLayout title="Welcome">
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Clean stack without extra magic</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p>• Laravel 13 + Inertia 3 + React 19</p>
<p>• Vite 8 for fast builds</p>
<p>• Full code control</p>
<p>• Ready for shared hosting deployment</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>🛠 What's Next?</CardTitle>
<CardDescription>Ideas for development</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p>• Add manual authentication</p>
<p>• Connect Tailwind CSS</p>
<p>• Create admin panel</p>
<p>• Set up deployment script</p>
</CardContent>
</Card>
</div>
<div className="mt-8 flex gap-4">
<Button asChild>
<a href="/about">Learn More</a>
</Button>
<Button variant="outline" asChild>
<a href="https://laravel.com" target="_blank" rel="noopener noreferrer">
Laravel Documentation
</a>
</Button>
</div>
</AppLayout>
);
}
See how clean it is? I just take the layout, wrap my content in it, and pass the title via the title prop.
The entire header, menu, footer — already there. The page contains only what’s unique to it. Clean and orderly.
9.3 Creating the “About” Page
Following the same principle in resources/js/Pages/About.jsx
import AppLayout from '@/Layouts/AppLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function About() {
return (
<AppLayout title="About the Project">
<div className="prose prose-neutral max-w-none">
<Card>
<CardHeader>
<CardTitle>Hey there!</CardTitle>
<CardDescription>A bit about how this site was built</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p>
This project was built from scratch using the <strong>Laravel 13 + Inertia + React</strong> stack.
No pre-made starter kits — just clean code and full understanding of every layer.
</p>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">Backend</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-1">
<li>• Laravel 13</li>
<li>• Inertia.js</li>
<li>• PHP 8.2+</li>
<li>• SQLite/MySQL</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">Frontend</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-1">
<li>• React 19</li>
<li>• Vite 8</li>
<li>• shadcn/ui</li>
<li>• Tailwind CSS</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">Deployment</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm space-y-1">
<li>• Shared hosting</li>
<li>• cPanel/Plesk</li>
<li>• FTP/SFTP</li>
<li>• No Node.js on server</li>
</ul>
</CardContent>
<Card>
</div>
<p className="text-sm text-muted-foreground">
Tip: this template can be used as a foundation for any project.
Just copy the structure and add your business logic.
</p>
</CardContent>
</Card>
</div>
<AppLayout>
);
}
Now we just need to add routes to routes/web.php
STEP 10. Configuring Routes for Laravel Inertia React
Alright, we’ve created the pages, written the layout, but Laravel still doesn’t know that it needs to show something
at / or /about. Let’s fix that.
I open the file routes/web.php. In Laravel, this is the main file for all web routes.
By default, there’s already some code there, but I completely replace it with my own:
<?php
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
// Homepage
Route::get('/', function () {
return Inertia::render('Home');
})->name('home');
// About page
Route::get('/about', function () {
return Inertia::render('About');
})->name('about');
See how transparent this is? I use the Inertia facade and call the render() method.
As the first parameter, I pass the component name — 'Home'. Inertia will look for the file
resources/js/Pages/Home.jsx, load it, and render it inside our Blade wrapper.
As a second parameter, I could pass data (like a list of users), but we don’t need that yet.
The ->name('home') method — I’m just giving the route a name. Why? So later in code I can write
route('home') instead of hardcoding '/'. If I suddenly decide the homepage should be at /main,
I only need to change it in one place — in web.php, and all links in the project will update automatically.
Small thing, but nice.
What About the Old Homepage?
In standard Laravel after installation, there’s already a route for '/' that shows either
the Blade template welcome.blade.php or something else. I just delete it — we don’t need it anymore.
How Navigation Works (Without Reloads)
You know, one of the coolest feelings when you first work with Inertia is the page transitions. You click a link, and the page doesn’t blink, doesn’t flash white, doesn’t reload. The content just smoothly changes, but the URL in the address bar updates, the back button works, and everything looks like a real multi-page site.
Question is: how does this work? Let me break it down step by step, because when you understand the mechanics, magic stops being magic and becomes a tool.
Step One. Inertia Intercepts the Click
When I use the <Link> component from Inertia instead of a regular <a>,
a trick happens. Inertia attaches a click handler and prevents the browser from following the link as usual.
Instead, it says: “Chill, dude, I’ll handle this”. Regular links without Link will work as usual — with full page reloads.
Step Two. Smart Request to Server
Inertia sends a request to the server at the target address, like /about. But not a simple one —
with special headers. The most important ones are X-Inertia: true and X-Requested-With: XMLHttpRequest.
By these headers, Laravel understands: “Ah, this isn’t a regular user visiting — it’s Inertia on the frontend.
So I don’t need to return a full HTML page”.
Step Three. Laravel Returns JSON
And here’s where the real magic begins. Laravel, seeing these headers, doesn’t return full HTML with header, footer, and scripts. Instead, it returns a compact JSON response. It looks something like this:
{
"component": "About",
"props": {
"user": null,
"title": "About the Project"
},
"url": "/about",
"version": null
}
This is perfect. No extra HTML, just the name of the needed component and the data it requires. The server sent kilobytes instead of megabytes — fast and efficient.
Step Four. Inertia Updates Only What’s Needed
On the client side, Inertia receives this JSON and does several things almost instantly.
It finds the React component About in the folder resources/js/Pages/About.jsx,
renders it with the passed props, and replaces only the content inside the layout.
Header, footer, sidebar — everything that didn’t change stays in place.
The browser doesn’t redraw the entire page, doesn’t restart scripts, doesn’t lose state.
At the same time, Inertia updates the URL in the address bar via history.pushState —
the same mechanism that real SPAs use. And the back and forward buttons continue to work because Inertia listens for the popstate event.
The result: users get instant transitions without flickering, while developers write code almost like in classic Laravel.
No separate API, no fetch, no manual handling of loading states. Inertia did all the dirty work for me.
For me, this is the main reason to use Inertia instead of classic API + React. Development speed like a monolith, runtime performance like an SPA.
STEP 12 Writing a Script for Synchronized Browser Launch and Adjusting composer.json
You know what my pain point was during early attempts to auto-launch the app in a browser tab?
When I ran composer run dev, the browser would open at the wrong time, and I’d end up seeing an error,
having to manually refresh the page after a couple seconds. Small thing, but annoying.
I found a solution: the browser should open only after the server has actually started and begun responding to requests.
For this, I wrote a small script that checks availability of http://localhost:8000 and only then opens the browser.
I create a folder .npm in the project root (if it doesn’t exist) and inside it a file open-browser.js:
import { exec } from 'child_process';
import http from 'http';
import { promisify } from 'util';
import { setTimeout as sleep } from 'timers/promises';
const execAsync = promisify(exec);
const URL = 'http://localhost:8000';
const MAX_ATTEMPTS = 30;
const DELAY_MS = 500;
const openBrowser = async (url) => {
const commands = {
win32: `start ${url}`,
darwin: `open ${url}`,
linux: `xdg-open ${url}`
};
const command = commands[process.platform] || commands.linux;
await execAsync(command);
};
const checkServer = () => {
return new Promise((resolve) => {
const request = http.get(URL, () => resolve(true));
request.on('error', () => resolve(false));
request.setTimeout(1000, () => {
request.destroy();
resolve(false);
});
});
};
const waitForServer = async () => {
console.log('Waiting for server...');
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (await checkServer()) {
console.log('Server ready! Opening browser...');
await openBrowser(URL);
return;
}
console.log(`Attempt ${attempt}/${MAX_ATTEMPTS} - Server not ready yet...`);
await sleep(DELAY_MS);
}
console.error('Timeout waiting for server');
process.exit(1);
};
waitForServer();
Now let me break down what’s happening here, because the script is simple but clever.
openBrowser(url) — determines the operating system (Windows, macOS, Linux)
and runs the appropriate command to open the browser. On Windows it’s start, on Mac — open,
on Linux — xdg-open. Cross-platform, clean.
checkServer() — sends an HTTP request to http://localhost:8000
and checks if the server responds. If it responds — returns true, if not or timeout — false.
No magic, just an honest check.
waitForServer() — the main function. It repeatedly (up to 30 attempts) checks the server every half second.
As soon as the server responds — it immediately opens the browser. If after 30 attempts the server still hasn’t started —
it outputs an error and exits.
MAX_ATTEMPTS = 30 and DELAY_MS = 500 — maximum 30 attempts with half-second intervals.
So the script waits for the server at most 15 seconds. That’s more than enough even on slow machines.
Adjusting composer.json — Smart Browser Launch Using open-browser.js Script
Now I update the dev script in composer.json:
"scripts": {
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74,#fdbbb4\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"npm run dev\" \"node .npm/open-browser.js\" --names=server,queue,vite,open --kill-others-on-fail"
]
},
What changed? Now it calls node .npm/open-browser.js. Meaning: our smart script runs,
which waits for the server itself and opens the browser at the right moment.
Breakdown of Other Settings
Composer\Config::disableProcessTimeout — disables Composer’s timeout.
By default, Composer waits for a command to finish, but our processes (server, queue, Vite) run continuously.
Without this line, Composer would kill everything after 300 seconds. With it — we live peacefully.
Removed php artisan pail — in the previous version I had php artisan pail
for pretty colored logs. But it requires additional installation and not everyone needs it.
Logs can be viewed via the standard storage/logs/laravel.log.
--tries=1 without --timeout=0 — previously it was --tries=1 --timeout=0.
I removed --timeout=0 because infinite waiting for task completion is rarely needed at project start.
Left just one attempt per execution — enough for development.
A Bit About Colors
The line -c "#93c5fd,#c4b5fd,#fdba74,#fdbbb4" — these are colors for each process in concurrently:
#93c5fd(light blue) — Laravel server#c4b5fd(purple) — queue#fdba74(orange) — Vite#fdbbb4(soft pink) — browser launch script
Visually pleasant and clear — you can immediately see which process is writing what to the terminal.
Why is this better than before?
Before: ran composer run dev → browser opened immediately → server not ready yet → error → manual refresh after 5 seconds.
Now: ran composer run dev → browser patiently waits → server started → script opened browser →
you immediately see the working site. No extra steps.
Small thing, but nice, bro. Especially when you restart the project 20 times a day.
STEP 12. Launching the Laravel Inertia React from Scratch Project
Now everything’s ready and all we need to do is open the terminal in the project root and type one single command:
composer run dev
In a couple seconds, the browser will automatically open at http://localhost:8000,
and you’ll see your homepage. Header, menu, footer, cards — everything in place. And most importantly,
when you click the “About” link — the page changes without reloading. Fast, smooth, beautiful.
Bro, we’ve done huge work together. From scratch, without pre-made starter kits or generators, we built our own template on a modern stack:
- Laravel 13 — powerful backend that we control entirely
- React 19 — fast frontend with component-based approach
- Inertia — the glue that gives us SPA without the hassle of a separate API
- Tailwind 4 — utility-first CSS with new Rust-based engine
- shadcn/ui — ready components that we keep under our control
- Vite — lightning-fast builds and hot reload
But the main thing isn’t even that. The main thing is — now you have your own Laravel 13 React 19 Inertia starter template. You know every line of code, understand how everything connects, and aren’t afraid of breaking anything. Because you built it yourself.
And now, when you need to start a new project, you just copy this template and in 5 minutes you’re already writing business logic. No “why did Breeze/Fortify/Jetstream do it this way?”, no hidden dependencies, no magic.
P.S. About the “Log In” and “Sign Up” Buttons
You’ve probably noticed that the “Log In” and “Sign Up” buttons in the header don’t work yet. When you click them, a modal window with an error appears. This isn’t a bug — it’s a feature of Inertia’s behavior, and it’s useful to know about.
Here’s how it works. When you click <Link href="/login">, Inertia sends a request to the server at that address.
But in our routes/web.php there’s no route for /login. Laravel doesn’t know what to do with this request
and returns a 404 error. Inertia on the client receives this error and shows it in a modal window —
this is done so the page doesn’t crash entirely, and the user sees a clear message.
Why doesn’t it open a separate error page? Because Inertia by default intercepts errors and shows them as a modal window right over the current page. This is intentional — so you don’t lose context and can continue working.
What to do about it?
Two paths. First — just leave it as is if you’re not planning to add authentication yet. The buttons don’t work, but they don’t get in the way either.
Second — temporarily remove these buttons from the layout until you implement authentication.
To do this, comment out or delete the block with them in AppLayout.jsx.
But if you want to make everything look polished, the authentication topic is the next logical step after our template. There you’ll need to create controllers, routes for login/registration, login and registration pages, configure sessions and protected routes. That’s a topic for a separate article, and it’s already in the plans.
For now, just remember: the buttons don’t work because there are no routes. Add the routes — they’ll work.
Everything’s honest, no hidden magic.