Laravel + Vue 3 Composition API: Build Modern Full-Stack SPAs
Learn how to build seamless full-stack SPAs with Laravel, Vue 3 Composition API, and Inertia.js for optimal performance and developer experience.

Here's the thing, I've built enough traditional REST APIs with Laravel backends and separate Vue frontends to know the pain. You're juggling CORS configurations, managing authentication tokens across domains, and writing the same CRUD logic twice. It gets old fast.
When I discovered Laravel Vue3 with Inertia.js, everything changed. Last year, while building StudyLab (my AI-powered quiz generator), I needed to ship features quickly without the overhead of maintaining separate API documentation and frontend state management for server data. Inertia.js became my secret weapon.
In this guide, I'll show you how to build modern full-stack SPAs using Laravel's backend power with Vue 3's Composition API reactivity, without writing a single REST endpoint. You'll learn the setup, architecture patterns I use in production, and why this stack has become my go-to for SaaS products.
What Makes Laravel + Vue 3 + Inertia.js Different?
Traditional SPA development splits your app into two separate applications. You've got a Laravel API serving JSON and a Vue frontend consuming it. This means:
- Maintaining two authentication systems
- Writing API resources and transformers
- Managing CORS and preflight requests
- Duplicating validation logic
- Building separate deployment pipelines
Inertia.js flips this approach. It's not a framework, think of it as a routing library that connects your Laravel backend directly to Vue components. You write standard Laravel controllers that return Vue pages instead of JSON. No API layer needed.
I'll be honest, this confused me at first. "How can it be an SPA without API calls?" But after building my first Inertia project, I got it. Inertia uses XHR requests behind the scenes, but you never write endpoints. Your controllers return props directly to Vue components.
Why Vue 3 Composition API?
If you're still using Vue 2's Options API, you're missing out. The Composition API changed how I structure components in every project since 2023.
Here's what sold me:
Better TypeScript support, Type inference actually works now. In my ReplyGenius Chrome extension, this caught dozens of bugs before production.
Logic reusability I can extract reactive logic into composables. Need authentication state? I have a useAuth()
composable that works across 5+ projects.
Clearer component organization No more scrolling through massive components looking for where data, methods, or computed properties live. Everything related stays together.
Plus, if you're using Laravel 12 (released early 2024), you're already on PHP 8.2+. Pairing modern PHP with modern Vue just feels right.
Setting Up Laravel + Vue 3 + Inertia.js
Let me walk you through the exact setup I use. This takes about 15 minutes if you've done it before, maybe 30 if it's your first time.
Initial Laravel Installation
# Create fresh Laravel 12 project
composer create-project laravel/laravel my-spa-app
cd my-spa-app
# Install Inertia server-side adapter
composer require inertiajs/inertia-laravel
# Install Vue 3 and Inertia client-side
npm install @inertiajs/vue3 vue@next
npm install --save-dev @vitejs/plugin-vue
Watch out for version conflicts here. If you're copying from an older tutorial, make sure you're using vue@next
(which is Vue 3) not just vue
. I spent 3 hours debugging this on my first project because the error messages weren't clear.
Configure Vite for Vue 3
Laravel 12 uses Vite out of the box. Update your vite.config.js
:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
resolve: {
alias: {
'@': '/resources/js',
},
},
});
That alias configuration is crucial. It lets you import components like import Button from '@/Components/Button.vue'
instead of relative paths. Trust me, you want this.
Setup Inertia Root Component
Create resources/js/app.js
:
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
createInertiaApp({
title: (title) => `${title} - My App`,
resolve: (name) => resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue')
),
setup({ el, App, props, plugin }) {
return createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
progress: {
color: '#4F46E5',
},
});
The resolvePageComponent
helper uses Vite's glob imports for code splitting. Each page component becomes a separate chunk, so your initial bundle stays lean.
Create Root Blade Template
Inertia needs one Blade file as the entry point. Create resources/views/app.blade.php
:
<!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>{{ config('app.name', 'Laravel') }}</title>
@routes
@vite(['resources/css/app.css', 'resources/js/app.js'])
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
The @routes
directive is optional but incredibly useful, it generates JavaScript route helpers so you can use route('posts.show', post.id)
in your Vue components.
Configure Inertia Middleware
Run this to publish Inertia's middleware:
php artisan inertia:middleware
Then add it to your bootstrap/app.php
:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
})
Building Your First Inertia Page with Composition API
Now the fun part. Let's build a dashboard that fetches and displays user stats.
Create the Laravel Controller
// app/Http/Controllers/DashboardController.php
namespace App\Http\Controllers;
use Inertia\Inertia;
use App\Models\Post;
class DashboardController extends Controller
{
public function index()
{
return Inertia::render('Dashboard', [
'stats' => [
'posts' => Post::count(),
'published' => Post::published()->count(),
'drafts' => Post::draft()->count(),
],
'recentPosts' => Post::latest()
->take(5)
->get()
->map(fn($post) => [
'id' => $post->id,
'title' => $post->title,
'published_at' => $post->published_at?->diffForHumans(),
]),
]);
}
}
See how clean this is? No API resources, no transformers. Just return the data you need. The map()
call formats dates and selects only necessary fields.
Create the Vue Component
<!-- resources/js/Pages/Dashboard.vue -->
<script setup>
import { computed } from 'vue';
import { Head } from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
const props = defineProps({
stats: Object,
recentPosts: Array,
});
// Computed property using Composition API
const publishRate = computed(() => {
if (props.stats.posts === 0) return 0;
return Math.round((props.stats.published / props.stats.posts) * 100);
});
</script>
<template>
<Head title="Dashboard" />
<AuthenticatedLayout>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-gray-500 text-sm font-medium">Total Posts</h3>
<p class="text-3xl font-bold text-gray-900">{{ stats.posts }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-gray-500 text-sm font-medium">Published</h3>
<p class="text-3xl font-bold text-green-600">{{ stats.published }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-gray-500 text-sm font-medium">Drafts</h3>
<p class="text-3xl font-bold text-yellow-600">{{ stats.drafts }}</p>
</div>
</div>
<!-- Publish Rate Indicator -->
<div class="bg-blue-50 rounded-lg p-4 mb-8">
<p class="text-sm text-blue-800">
You've published {{ publishRate }}% of your posts
</p>
</div>
<!-- Recent Posts -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Recent Posts</h2>
</div>
<ul class="divide-y divide-gray-200">
<li v-for="post in recentPosts"
:key="post.id"
class="px-6 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900">
{{ post.title }}
</h3>
<span class="text-sm text-gray-500">
{{ post.published_at }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
This is pure Composition API. Notice how publishRate
uses computed()
, it automatically recalculates when props change. In the Options API, you'd have this separated in a computed:
section somewhere else in the file.
Form Handling with Inertia and Composition API
Forms are where Inertia really shines. You get automatic loading states, error handling, and validation, all without writing fetch requests.
Create a Post Form
<!-- resources/js/Pages/Posts/Create.vue -->
<script setup>
import { useForm, Head } from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import InputError from '@/Components/InputError.vue';
const form = useForm({
title: '',
content: '',
status: 'draft',
});
const submit = () => {
form.post(route('posts.store'), {
onSuccess: () => form.reset(),
onError: (errors) => {
console.log('Validation failed:', errors);
},
});
};
</script>
<template>
<Head title="Create Post" />
<AuthenticatedLayout>
<div class="max-w-2xl mx-auto py-12">
<form @submit.prevent="submit" class="space-y-6">
<div>
<label for="title" class="block text-sm font-medium text-gray-700">
Title
</label>
<input
id="title"
v-model="form.title"
type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
:class="{ 'border-red-500': form.errors.title }"
/>
<InputError :message="form.errors.title" />
</div>
<div>
<label for="content" class="block text-sm font-medium text-gray-700">
Content
</label>
<textarea
id="content"
v-model="form.content"
rows="10"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
:class="{ 'border-red-500': form.errors.content }"
></textarea>
<InputError :message="form.errors.content" />
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700">
Status
</label>
<select
id="status"
v-model="form.status"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div class="flex items-center justify-end space-x-4">
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ form.processing ? 'Creating...' : 'Create Post' }}
</button>
</div>
</form>
</div>
</AuthenticatedLayout>
</template>
The useForm()
composable is brilliant. It handles:
- Loading states (
form.processing
) - Validation errors (
form.errors
) - Success callbacks
- Automatic CSRF tokens
Backend Controller for Form Submission
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PostController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'status' => 'required|in:draft,published',
]);
$post = $request->user()->posts()->create($validated);
return redirect()->route('posts.show', $post)
->with('success', 'Post created successfully!');
}
}
When validation fails, Inertia automatically sends errors back to your Vue component. No manual error handling needed.
Advanced Patterns: Composables and Shared State
Here's where Composition API gets powerful. Let me show you a reusable composable I use across projects.
Create a useFlash Composable
// resources/js/Composables/useFlash.js
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
export function useFlash() {
const page = usePage();
const flash = computed(() => page.props.flash || {});
const hasSuccess = computed(() => !!flash.value.success);
const hasError = computed(() => !!flash.value.error);
const hasWarning = computed(() => !!flash.value.warning);
return {
flash,
hasSuccess,
hasError,
hasWarning,
};
}
Using the Composable
<script setup>
import { useFlash } from '@/Composables/useFlash';
const { flash, hasSuccess, hasError } = useFlash();
</script>
<template>
<div v-if="hasSuccess" class="bg-green-50 p-4 rounded-md">
<p class="text-green-800">{{ flash.success }}</p>
</div>
<div v-if="hasError" class="bg-red-50 p-4 rounded-md">
<p class="text-red-800">{{ flash.error }}</p>
</div>
</template>
I use this pattern for authentication state, permission checks, and API configurations. Write once, use everywhere.
Handling Authentication with Inertia
Authentication is surprisingly straightforward. Laravel's session-based auth works perfectly with Inertia.
Share Auth Data Globally
In your HandleInertiaRequests
middleware:
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'avatar' => $request->user()->avatar_url,
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
];
}
Now auth.user
is available in every component via usePage().props.auth.user
.
Create useAuth Composable
// resources/js/Composables/useAuth.js
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
export function useAuth() {
const page = usePage();
const user = computed(() => page.props.auth.user);
const isAuthenticated = computed(() => !!user.value);
const can = (permission) => {
// Add permission logic here
return user.value?.permissions?.includes(permission) ?? false;
};
return {
user,
isAuthenticated,
can,
};
}
Usage in any component:
<script setup>
import { useAuth } from '@/Composables/useAuth';
const { user, isAuthenticated, can } = useAuth();
</script>
<template>
<div v-if="isAuthenticated">
<p>Welcome, {{ user.name }}!</p>
<button v-if="can('edit-posts')">Edit</button>
</div>
</template>
Performance Optimization Techniques
After building StudyLab and ReplyGenius, I learned some hard lessons about SPA performance. Here's what actually moves the needle.
Lazy Loading Pages
Inertia automatically code-splits pages, but you should lazy load heavy components too:
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent(() =>
import('@/Components/HeavyChart.vue')
);
This reduced my initial bundle size from 280KB to 120KB on ReplyGenius.
Partial Reloads
Only fetch what changed. If you're updating a single post, don't reload the entire page:
import { router } from '@inertiajs/vue3';
const updatePost = (postId) => {
router.patch(route('posts.update', postId), form.data(), {
only: ['post', 'flash'], // Only reload these props
preserveScroll: true,
});
};
Debounced Search
For search inputs, debounce API calls:
<script setup>
import { ref, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import { debounce } from 'lodash';
const search = ref('');
const performSearch = debounce((query) => {
router.get(route('posts.index'),
{ search: query },
{ preserveState: true, replace: true }
);
}, 300);
watch(search, (newValue) => {
performSearch(newValue);
});
</script>
This prevents hammering your server with requests on every keystroke.
Common Mistakes and How to Avoid Them
I've made all these mistakes. Learn from my pain.
Mistake 1: Not Using Shared Props
Don't fetch the same data in every controller. Use the middleware's share()
method for global data like auth user, notifications, or settings. I used to query the user in every controller action until I realized Laravel was executing 5+ identical queries per request.
Mistake 2: Forgetting preserveScroll
When updating data without changing pages, add preserveScroll: true
or your users will jump to the top. This drove me crazy in StudyLab's quiz interface before I discovered this option.
Mistake 3: Over-fetching Data
Don't send entire models to the frontend. Use map()
or Laravel Resources to send only what you need:
// Bad
return Inertia::render('Posts/Index', [
'posts' => Post::with('user', 'category', 'tags')->get(),
]);
// Good
return Inertia::render('Posts/Index', [
'posts' => Post::query()
->select('id', 'title', 'excerpt', 'created_at')
->get()
->map(fn($post) => [
'id' => $post->id,
'title' => $post->title,
'excerpt' => $post->excerpt,
'date' => $post->created_at->format('M d, Y'),
]),
]);
This reduced my API response sizes by 60% on average.
Mistake 4: Not Handling File Uploads Properly
File uploads need special handling with Inertia:
form.post(route('posts.store'), {
forceFormData: true, // Required for file uploads
onSuccess: () => form.reset(),
});
Without forceFormData: true
, your files won't upload. I discovered this after 2 hours of debugging.
Migration Strategy: Moving from REST API to Inertia
If you're converting an existing project, here's my phased approach:
Phase 1: Setup Inertia alongside existing API. Install Inertia but keep your API routes. Start with one simple page.
Phase 2: Migrate read-only pages. Move dashboard, profile views, and list pages. These are safe since they don't modify data.
Phase 3: Convert forms one at a time. Start with simple forms. Test thoroughly before moving to complex ones with file uploads or multi-step processes.
Phase 4: Update navigation. Switch <router-link>
to Inertia's <Link>
component:
<!-- Before -->
<router-link :to="{ name: 'posts.show', params: { id: post.id }}">
View Post
</router-link>
<!-- After -->
<Link :href="route('posts.show', post.id)">
View Post
</Link>
Phase 5: Remove API routes. Once fully migrated, clean up your routes file and delete unused API controllers.
I migrated a 15-page admin panel in about a week using this approach. The key is incremental changes, don't try to convert everything at once.
Trade-offs: When NOT to Use This Stack
Let me be honest about limitations.
Don't use Inertia if:
You need a true mobile app. Inertia is for web SPAs. If you're building iOS/Android apps, stick with a REST or GraphQL API.
Your frontend and backend teams are completely separate. Inertia requires backend developers to understand component props and frontend structure. If your teams never communicate, traditional API boundaries might work better.
You have heavy real-time requirements. While you can use WebSockets with Inertia, it's not the primary use case. Something like Livewire or a dedicated real-time framework might be better.
You need to serve multiple frontend applications. If you're building a public API that feeds a web app, mobile app, and third-party integrations, a proper REST API makes more sense.
The sweet spot:
- Internal admin panels
- SaaS dashboards
- E-commerce backends
- Any CRUD-heavy application where you control both frontend and backend
Conclusion: Why This Stack Works for Me
After 7+ years building web applications, Laravel + Vue 3 + Inertia.js has become my default choice for new projects. It eliminated the complexity that made me hesitant to start new SaaS ideas.
No more debating REST vs GraphQL. No more maintaining API documentation. No more CORS headaches. Just controllers returning props to Vue components.
The Composition API's reusability means I've built a library of composables that speed up every new project. My useAuth
, useFlash
, and useModal
composables save me hours per project.
If you're building a full-stack application where you control both ends, this stack is worth trying. Start small, convert one page of an existing project or build a simple CRUD app. You'll know within a day if it fits your workflow.
Next step? Pick a small feature and implement it with this stack. Maybe a user profile page or a simple dashboard. Once you get the flow, you'll wonder why you ever dealt with traditional API development.
Need help implementing this in your project? Let's work together: Contact me
Need Help With Your Laravel Project?
I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.
⚡ Currently available for 2-3 new projects

About Hafiz Riaz
Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.
View Portfolio →Related Articles

Setting Up Laravel 10 with Vue3 and Vuetify3
A complete guide to seamlessly integrating Vue3 and Vuetify3 into Laravel 10 usi...

Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Unleash the Full Potential of Laravel 10 with Vue 3 and Vuetify 3 in a Dockerize...

Mastering Design Patterns in Laravel
Unraveling the Secrets Behind Laravel’s Architectural Mastery