How a complete architectural rewrite is bringing React developers into the Laravel ecosystem—and why it might be the full-stack solution you've been searching for.
If you're a React developer who's ever built a full-stack application, you know the pain. You've probably cobbled together Next.js with API routes, wrestled with server actions, debugged hydration mismatches, and wondered why building a simple CRUD app requires understanding the intricacies of Server Components, edge functions, and ten different rendering strategies.
What if I told you there's a better way? One that lets you keep writing React the framework you know and love while eliminating 90% of the complexity? Enter Inertia.js.
The Full-Stack JavaScript Illusion
Let me be direct: the modern JavaScript ecosystem has sold React developers a dream that's turned into a nightmare.
We were promised that JavaScript everywhere would simplify our lives. Instead, we got:
-
API Route Chaos: In Next.js, managing API routes at scale becomes unwieldy fast. As one developer put it, "improper structuring of API routes can lead to performance bottlenecks, code duplication, and security vulnerabilities." You end up with a
/pages/apifolder that looks like someone threw spaghetti at a wall. -
The Complexity Tax: The App Router in Next.js introduced powerful features—Server Components, Layouts, sophisticated caching—but at what cost? Developers are no longer asking "Why doesn't this work?" They're asking "Why does this work this way?" The cognitive overhead is real, especially for teams building B2B SaaS applications where shipping features fast matters more than bleeding-edge optimizations.
-
The Separation Problem: Even with API routes, you're still mentally context-switching between frontend and backend concerns. You write a
POSThandler in/api/users, then flip to your component to call it withfetchoraxios, manage loading states, handle errors, deal with CORS in development... it's exhausting.
Taylor Otwell, creator of Laravel, put it perfectly in a recent discussion:
"I don't see a full-stack story in JavaScript yet that would allow me to realistically sit down and build something like Forge or Vapor from start to finish. Maybe I'm missing it. The MVP start-ups I do see fully built on current JS meta frameworks are much thinner. The stereotypical API call to an AI service. Not much meat on the bones."
He's not wrong. Rails and Laravel were built with the express purpose of allowing a single developer to build the next GitHub, Airbnb, or Shopify prototyped from beginning to end. That's the promise React developers are still chasing.
Enter Inertia: The Modern Monolith
Here's the radical idea behind Inertia: What if you didn't need an API?
Inertia.js allows you to create fully client-side (or server-side) rendered, single-page React apps without the complexity that comes with modern SPAs. It does this by leveraging existing server-side patterns that work.
The concept is beautifully simple:
- Your Laravel routes return Inertia responses (not JSON)
- Inertia sends your React component name + props to the frontend
- Your React app renders using those props
- On subsequent navigation, Inertia intercepts requests and fetches only the JSON it needs
- No full page reloads, no API to build, no state management library required for server data
Think of Inertia as glue between your server-side framework and your client-side framework. It's not a replacement for either—it makes them work together seamlessly.
What's New in Inertia 2.0: A Complete Rewrite
In August 2024, Taylor Otwell announced Inertia 2.0 at Laracon US. The stable release followed in early 2025, and it's a game-changer. The core library has been completely rewritten to architecturally support asynchronous requests, unlocking an entirely new set of features that make building modern SPAs feel... easy.
1. Async Requests: Breaking Free from Sequential Limitations
Previously, all Inertia requests were synchronous. If you navigated to a new page or refreshed a prop, that request would block others. No more.
Inertia 2.0 introduces full asynchronous support. Multiple requests can happen simultaneously without canceling each other. This fundamental architectural change enables everything else in this release.
For example, the existing reload method is now async by default:
// Non-blocking, doesn't cancel other requests
// Disables progress indicator by default
router.reload({ only: ['users'] })
This might seem subtle, but it's transformative. You can now build interfaces that feel genuinely responsive loading different parts of the page independently, polling for updates, lazy loading data—all without the complexity of managing WebSocket connections or elaborate client-side state machines.
2. Prefetching: Make Navigation Instant
One of the most impressive features in 2.0 is prefetching. Inertia can now load pages in the background before users navigate to them, dramatically improving perceived performance.
By default, Inertia prefetches data when a user hovers over a link for more than 75ms. When they click, the page appears instantly because the data is already loaded and cached.
import { Link } from '@inertiajs/react'
// Automatic prefetching on hover
<Link href="/users/123">View Profile</Link>
// Or prefetch immediately on page load
<Link href="/dashboard" prefetch>Dashboard</Link>
The cache is smart too data is cached for 30 seconds by default, and you can manually control the TTL. Even if cache expires, the page loads instantly with stale data while Inertia fetches fresh data in the background.
This is the kind of optimization that would require significant engineering effort in a traditional React + API architecture. With Inertia 2.0, it's a single prop.
3. Deferred Props: Optimize Initial Load Times
Here's a common pattern: you have a dashboard page that shows user info (fast) and recent analytics (slow because it queries a data warehouse). In a traditional setup, the entire page waits for the slowest query.
Inertia 2.0 introduces deferred props—data that loads in the background after the initial page render:
Laravel Controller:
use Inertia\Inertia;
public function show()
{
return Inertia::render('Dashboard', [
'user' => Auth::user(), // Loaded immediately
'analytics' => Inertia::defer(fn () => $this->getAnalytics()), // Loaded async
]);
}
React Component:
export default function Dashboard({ user, analytics }) {
return (
<div>
<h1>Welcome back, {user.name}!</h1>
{analytics ? (
<AnalyticsChart data={analytics} />
) : (
<LoadingSpinner />
)}
</div>
)
}
The page renders immediately with the user data. A split second later, the analytics appear. The user sees a fast page load, and you don't need to orchestrate multiple API calls or manage complex loading states.
4. Lazy Loading with WhenVisible
Building on deferred props, Inertia 2.0 introduces the WhenVisible component. It loads data only when elements scroll into view perfect for optimizing pages with lots of content.
import { WhenVisible } from '@inertiajs/react'
<WhenVisible data="comments" fallback={<LoadingSkeleton />}>
{({ comments }) => (
<CommentSection comments={comments} />
)}
</WhenVisible>
On the backend, you defer the prop:
return Inertia::render('Post/Show', [
'post' => $post,
'comments' => Inertia::defer(fn () => $post->comments),
]);
The comments only load when the user scrolls down to that section. This drastically reduces initial page load time and database queries for content users might never see.
5. Infinite Scrolling: Built-in Primitives
Infinite scrolling used to require libraries, pagination logic, scroll event handlers, and careful state management. Not anymore.
import { InfiniteScroll } from '@inertiajs/react'
export default function Feed({ posts }) {
return (
<div>
{posts.data.map(post => <PostCard key={post.id} post={post} />)}
<InfiniteScroll
loadMore={() => router.reload({
only: ['posts'],
data: { page: posts.current_page + 1 }
})}
hasMore={posts.current_page < posts.last_page}
/>
</div>
)
}
On the backend, use Inertia::merge():
public function index(Request $request)
{
return Inertia::render('Feed', [
'posts' => Inertia::merge(
Post::latest()->paginate(10)
),
]);
}
Inertia handles merging new results with existing data automatically. You get smooth, infinite scrolling with just a few lines of code.
6. Polling: Real-time Without WebSockets
Need real-time updates without the complexity of WebSockets? Polling is now trivial:
import { useEffect } from 'react'
import { router } from '@inertiajs/react'
export default function Leaderboard({ scores }) {
useEffect(() => {
const interval = setInterval(() => {
router.reload({ only: ['scores'] })
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [])
return (
<div>
{scores.map(score => (
<div key={score.id}>{score.player}: {score.points}</div>
))}
</div>
)
}
For many use cases dashboards, leaderboards, status pages this is simpler and more reliable than maintaining WebSocket connections.
7. History Encryption: Security by Default
One often overlooked concern: when users log out, their browsing history still contains sensitive data in the browser's history state. Inertia 2.0 solves this with history encryption, enabled by default.
Session state is encrypted automatically. When a user logs out, privileged information is cleared from the browser history. This happens without any configuration—it just works.
Forms: Where Inertia Shines
If you've built forms in React, you know it's tedious. You manage state, handle submissions, deal with validation errors, show loading states, handle file uploads... it's a lot of boilerplate.
Inertia's useForm hook eliminates most of it:
import { useForm } from '@inertiajs/react'
export default function CreateUser() {
const { data, setData, post, processing, errors } = useForm({
name: '',
email: '',
password: '',
})
function submit(e) {
e.preventDefault()
post('/users')
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div className="error">{errors.name}</div>}
<input
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && <div className="error">{errors.email}</div>}
<input
type="password"
value={data.password}
onChange={e => setData('password', e.target.value)}
/>
{errors.password && <div className="error">{errors.password}</div>}
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create User'}
</button>
</form>
)
}
On the Laravel backend:
public function store(Request $request)
{
$request->validate([
'name' => ['required', 'max:50'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'min:8'],
]);
User::create($request->all());
return redirect()->route('users.index');
}
Notice what you don't have to do:
- No API endpoint to build
- No JSON responses to format
- No client-side validation duplication
- No manual error state management
- No loading state management beyond the provided
processingboolean
The validation errors from Laravel automatically flow back to the frontend. The form knows when it's processing. File uploads work automatically—Inertia converts them to FormData behind the scenes.
Advanced Form Features
The useForm hook provides several quality-of-life features:
Progress tracking for file uploads:
const { data, setData, post, progress } = useForm({ avatar: null })
<input type="file" onChange={e => setData('avatar', e.target.files[0])} />
{progress && <ProgressBar value={progress.percentage} />}
Dirty checking:
const { data, isDirty, reset } = useForm({ name: 'John' })
{isDirty && <button onClick={reset}>Reset Changes</button>}
Success states:
const { wasSuccessful, recentlySuccessful } = useForm({ ... })
{recentlySuccessful && <div className="success">Saved!</div>}
Transform data before submission:
const form = useForm({ name: '', email: '' })
form.transform((data) => ({
...data,
name: data.name.trim(),
}))
form.post('/users')
The Developer Experience Difference
Let me show you two approaches to building the same feature: a user profile page with the ability to update information.
The Traditional React + Next.js Approach
API Route (/app/api/users/[id]/route.ts):
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({
where: { id: params.id }
})
if (!user) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(user)
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json()
// Manual validation
if (!body.name || body.name.length > 50) {
return Response.json(
{ errors: { name: 'Name is required and must be less than 50 characters' } },
{ status: 422 }
)
}
const user = await db.user.update({
where: { id: params.id },
data: body
})
return Response.json(user)
}
React Component (/app/users/[id]/edit/page.tsx):
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
export default function EditUser() {
const router = useRouter()
const params = useParams()
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState({})
const [formData, setFormData] = useState({
name: '',
email: '',
})
useEffect(() => {
fetch(`/api/users/${params.id}`)
.then(res => res.json())
.then(data => {
setUser(data)
setFormData({ name: data.name, email: data.email })
setLoading(false)
})
}, [params.id])
async function handleSubmit(e) {
e.preventDefault()
setSubmitting(true)
setErrors({})
const res = await fetch(`/api/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
const data = await res.json()
if (res.ok) {
router.push('/users')
} else {
setErrors(data.errors || {})
setSubmitting(false)
}
}
if (loading) return <div>Loading...</div>
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
{errors.name && <div>{errors.name}</div>}
<button disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</form>
)
}
That's a lot of code. And we haven't even covered:
- Error boundaries
- Type safety
- CORS in development
- Optimistic updates
- Success notifications
The Laravel + Inertia + React Approach
Laravel Controller:
public function edit(User $user)
{
return Inertia::render('Users/Edit', [
'user' => $user,
]);
}
public function update(Request $request, User $user)
{
$user->update($request->validate([
'name' => ['required', 'max:50'],
'email' => ['required', 'email'],
]));
return redirect()->route('users.index');
}
React Component:
import { useForm } from '@inertiajs/react'
export default function Edit({ user }) {
const { data, setData, put, processing, errors } = useForm({
name: user.name,
email: user.email,
})
function submit(e) {
e.preventDefault()
put(`/users/${user.id}`)
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div>{errors.name}</div>}
<button disabled={processing}>Save</button>
</form>
)
}
That's it.
No API route. No manual fetch calls. No loading state management. No error state management. The validation happens on the server using Laravel's battle-tested validation, and errors automatically flow to the frontend.
The difference in lines of code is staggering. More importantly, the mental overhead is dramatically lower. You're not context-switching between API design, request handling, error formats, and frontend state management. You're just building your feature.
Type Safety: The TypeScript Story
React developers love TypeScript. Good news: Inertia works beautifully with it.
For shared types between Laravel and React, you can use tools like Laravel TypeScript or Ziggy for route helpers.
Example with type-safe forms:
import { useForm } from '@inertiajs/react'
interface UserFormData {
name: string
email: string
avatar: File | null
}
interface User {
id: number
name: string
email: string
avatar_url: string
}
interface Props {
user: User
}
export default function Edit({ user }: Props) {
const { data, setData, put, errors } = useForm<UserFormData>({
name: user.name,
email: user.email,
avatar: null,
})
// TypeScript knows all the types
function submit(e: React.FormEvent) {
e.preventDefault()
put(`/users/${user.id}`)
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
</form>
)
}
Laravel Data and other packages can automatically generate TypeScript definitions from your PHP classes, giving you end-to-end type safety from database to UI.
Migration Path: You Don't Have to Rewrite Everything
Here's the beauty of Inertia: you can adopt it incrementally.
Already have a Laravel app with Blade templates? Start converting one page at a time to Inertia + React. Your Blade pages and Inertia pages coexist perfectly.
Already using Vue in your Laravel app? Inertia supports Vue, React, and Svelte you can even mix them in the same project (though you probably shouldn't).
Coming from a pure React background? Laravel is surprisingly approachable. The documentation is excellent, the ecosystem is mature, and the patterns feel intuitive. If you can build a Next.js app, you can build a Laravel app.
The Ecosystem Advantage
When you choose Laravel + Inertia + React, you're not just getting a framework you're getting an ecosystem:
Authentication? Laravel's official React starter kit ships with complete authentication out of the box—login, registration, password reset, email verification, and even two-factor authentication powered by Laravel Fortify. It uses React 19, TypeScript, Tailwind 4, and shadcn/ui components.
Payments? Laravel Cashier handles Stripe subscriptions with a few lines of code.
Background Jobs? Laravel Queues with Horizon for monitoring.
Real-time? Laravel Reverb (WebSockets), Echo (client library), and it integrates seamlessly with Inertia.
Emails? Laravel's Mailable system with beautiful templates.
File Storage? Unified API for local, S3, or any other storage.
Testing? Pest for backend, React Testing Library for frontend, Dusk for E2E.
Deployment? Laravel Forge for one-click deployment to any cloud provider.
Compare this to the JavaScript ecosystem where you're constantly evaluating, integrating, and maintaining dozens of disparate packages. The Laravel ecosystem is cohesive, well-documented, and it just works.
The Bottom Line
React developers: Laravel wants you.
Taylor Otwell and the Laravel team have been systematically building a modern frontend story with Inertia at its center. This isn't a half-baked attempt to shoehorn React into a PHP framework—it's a thoughtfully designed approach that respects both ecosystems.
Inertia 2.0 represents a fundamental rethinking of how to build modern web applications. Instead of forcing you to become a full-stack JavaScript developer (with all the complexity that entails), it lets you leverage the best of both worlds:
- React for what it's great at: Building interactive, component-based UIs
- Laravel for what it's great at: Routing, validation, authentication, database queries, background jobs, email—all the backend stuff that JavaScript frameworks struggle with
The result? You ship features faster. Your code is simpler. Your application is more maintainable. And you can actually build substantial products as a solo developer or small team.
As Taylor said, Laravel and Rails were built to empower individual developers to build the next GitHub, Airbnb, or Shopify. With Inertia 2.0, React developers can now join that story.
The modern monolith is back. And it's powered by React.
Try It Today
- Official Inertia 2.0 Documentation: inertiajs.com
- Laravel Documentation: laravel.com/docs
- Laravel Starter Kits: laravel.com/starter-kits
The tools are here. The ecosystem is mature. The only question is: are you ready to simplify your stack?