Building modern web applications often means choosing between server-side rendering and building a separate API—but what if you didn't have to pick? Inertia.js with Laravel: a practical PHP & Laravel guide shows us how to return Vue or React pages directly from Laravel controllers without constructing a traditional API layer. After 50 days of PHP and Laravel fundamentals, this is where things get interesting: we can leverage our Laravel backend expertise while writing frontend code that feels native to modern JavaScript frameworks.
What is Inertia.js and Why It Matters
Inertia.js is a glue layer between your Laravel backend and a frontend framework like Vue or React. It eliminates the need to build REST or GraphQL endpoints by allowing controllers to render pages directly with component-level data. Instead of returning JSON responses and managing API versions, we return an Inertia response from a controller—it automatically serializes data, handles routing on the client side, and keeps our architecture simpler.
We've built plenty of applications where the backend and frontend teams operate in silos, each maintaining their own contracts. Inertia.js removes that friction. Your Laravel routes map directly to Vue or React components, and data flows from controllers to components as props. For teams accustomed to server-side rendering but wanting modern interactivity, this is a game-changer.
Controllers Return Inertia Responses
The foundation of Inertia.js is straightforward: instead of returning a view or JSON response, controllers return an Inertia response. Let's look at a practical example from an e-commerce platform where we're displaying a product listing page.
namespace App\Http\Controllers;
use App\Models\Product;
use Inertia\Inertia;
class ProductController extends Controller
{
public function index()
{
$products = Product::where('active', true)
->paginate(20)
->through(fn ($product) => [
'id' => $product->id,
'name' => $product->name,
'price' => $product->price,
'image' => $product->image_url,
]);
return Inertia::render('Products/Index', [
'products' => $products,
'filters' => request()->only(['search', 'category']),
]);
}
public function show(Product $product)
{
return Inertia::render('Products/Show', [
'product' => [
'id' => $product->id,
'name' => $product->name,
'description' => $product->description,
'price' => $product->price,
'variants' => $product->variants,
'reviews' => $product->reviews()->latest()->limit(10)->get(),
],
]);
}
}The Inertia::render() method takes a component name (which maps to your Vue or React file) and an associative array of props. These props are automatically JSON-serialized and passed to the frontend component. Notice how we're transforming models into arrays—this keeps the serialization clean and ensures we only send necessary data.
When a user visits /products, the controller prepares data, and Inertia returns an HTML page with your Vue or React component already hydrated. The browser receives a fully-rendered page on the first load, then JavaScript takes over for client-side navigation. This gives us both SEO benefits and modern interactivity.
Props Pass Data to Frontend Pages
Once data reaches your frontend component via props, you work with it exactly as you would in a typical Vue or React application. Props are immutable from the component's perspective, but they trigger reactivity when they change between page visits.
Let's see how we'd consume those props in a Vue 3 component:
<template>
<div class="products-grid">
<div v-if="products.length === 0" class="empty-state">
No products found
</div>
<div v-for="product in products.data" :key="product.id" class="product-card">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="price">{{ formatPrice(product.price) }}</p>
<Link :href="`/products/${product.id}`" class="btn">
View Details
</Link>
</div>
<Pagination v-if="products.last_page > 1" :links="products.links" />
</div>
</template>
<script setup>
import { Link } from '@inertiajs/vue3';
import Pagination from '@/Components/Pagination.vue';
defineProps({
products: Object,
filters: Object,
});
const formatPrice = (price) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price / 100);
};
</script>Notice we're using Inertia's Link component instead of standard anchor tags. This enables client-side navigation without full page reloads—the router intercepts clicks, makes a request to the server, and updates only the component content. The browser URL and history still update normally.
When the user clicks to a different product, the controller processes the request and returns a new Inertia response. React and Vue both receive new props, triggering re-renders. This pattern eliminates boilerplate API integration code we'd normally write in a SPA.
Shared Data for Auth and Flash Messages
Most applications need global state: the authenticated user, flash messages, CSRF tokens, and permission flags. Rather than fetching these repeatedly, Inertia provides a "shared data" mechanism that automatically injects data into every response.
We typically configure this in a middleware or service provider:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function handle(Request $request, Closure $next)
{
Inertia::share([
'auth' => [
'user' => $request->user(),
],
'flash' => [
'success' => session('success'),
'error' => session('error'),
'message' => session('message'),
],
'csrf_token' => csrf_token(),
]);
return $next($request);
}
}This middleware runs on every request. Every component automatically receives auth.user, flash.success, and so on as props—we don't need to explicitly pass them from controllers. In a SaaS application, we'd extend this with team data, feature flags, or role-based permissions:
Inertia::share([
'auth' => [
'user' => $request->user(),
'team' => $request->user()?->currentTeam,
'can' => [
'create_project' => $request->user()?->can('create', Project::class),
'manage_billing' => $request->user()?->isAdmin(),
],
],
]);Now every component can reference $page.props.auth.can.create_project to conditionally render UI or check permissions before submitting forms. Flash messages become trivial:
<template>
<div v-if="$page.props.flash.success" class="alert alert-success">
{{ $page.props.flash.success }}
</div>
</template>After a form submission that returns a redirect in Laravel, the session flash data automatically appears on the next page render without explicit fetching.
SSR Support for SEO Pages
Server-side rendering with Inertia.js is optional but valuable for SEO-critical pages. Without SSR, the initial HTML from Inertia is a shell containing your JavaScript—search engines see little content. With SSR, we pre-render components on the server and send fully-formed HTML.
Setting up SSR requires Node.js running alongside Laravel. We configure a separate Node server that executes Vue or React components server-side:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import laravel from 'laravel-vite-plugin'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
laravel({
input: 'resources/js/app.js',
ssr: 'resources/js/ssr.js',
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
})For product pages or blog posts, SSR is worth the operational complexity. A CRM dashboard? Probably not. Evaluate based on your audience and SEO needs. When enabled, every initial page load renders the component tree on the server, waits for async data to resolve, and sends complete HTML to the browser. JavaScript still hydrates afterward for interactivity.
Common Mistakes
Sending too much data in props: It's tempting to pass entire Eloquent collections or relationships to components. This bloats the JSON payload and slows initial page loads. Transform models into lean arrays containing only the fields your component needs.
Forgetting to transform timestamps: Eloquent timestamps are Carbon instances. When serialized to JSON, they become ISO strings. Always explicitly format dates in your controller: 'created_at' => $product->created_at->format('Y-m-d').
Overusing shared data: While convenient, dumping everything into shared data makes prop flow unclear. Reserve it for truly global state (auth, CSRF, flash messages). Page-specific data belongs in controller props.
Ignoring pagination links: When paginating, include the links property in your response. Inertia's pagination component uses these to build navigation without additional API calls.
Not setting up LaravelInertia.js middleware: Inertia requires a Laravel middleware to handle asset versioning and validation responses. Skipping this step causes asset cache-busting and validation errors to behave unexpectedly.
Key Takeaways
Inertia::render()bridges Laravel controllers and frontend components—no API layer needed.- Props flow from controllers to Vue/React components; use
Linkcomponents for client-side navigation. - Shared data middleware injects auth, flash messages, and global state into every response automatically.
- Transform Eloquent models to lean arrays before passing as props to minimize JSON payload size.
- SSR is optional but valuable for SEO-critical pages; evaluate the operational overhead for your use case.
- Validation errors, redirects, and session handling work seamlessly because the architecture remains server-centric.






Comments
Loading comments…