13 min read

Laravel + Vue 3 Composition API: Build Modern Full-Stack SPAs

Build full-stack SPAs with Laravel, Vue 3 Composition API, and Inertia v2 with deferred props, prefetching, and type-safe Wayfinder routing.

Laravel + Vue 3 Composition API: Build Modern Full-Stack SPAs

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 first tried Laravel with Inertia.js, everything clicked. I could ship features without the overhead of maintaining separate API documentation and frontend state management for server data. And now with Inertia v2's async capabilities and Laravel 12's Wayfinder integration, this stack has gone from "pretty good" to genuinely hard to beat.

In this guide, I'll walk you through building modern full-stack SPAs using Laravel's backend power with Vue 3's Composition API. We'll cover the updated setup with Inertia v2, Wayfinder's type-safe routing, deferred props, prefetching, and the patterns I actually use in production. No fluff. Just the stuff that works.

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. That means maintaining two authentication systems, writing API resources and transformers, managing CORS and preflight requests, duplicating validation logic, and 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. If you've been building REST APIs just to power your own frontend, this approach eliminates an entire layer of complexity.

And with Inertia v2 (released alongside the core library rewrite), you get async requests, deferred props, prefetching, polling, infinite scroll, and history encryption out of the box. It's a different beast from v1.

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, and there's no going back.

Here's what sold me:

Better TypeScript support. Type inference actually works now. This catches bugs before they hit production, and with Wayfinder generating TypeScript types from your Laravel backend, the entire stack becomes type-safe.

Logic reusability through composables. You can extract reactive logic into composables and share them across projects. Need authentication state? Write a useAuth() composable once, use it everywhere.

Clearer component organization. No more scrolling through massive components looking for where data, methods, or computed properties live. Related logic stays together, not scattered across data(), methods, and computed sections.

Laravel 12's official Vue starter kit uses the Composition API, TypeScript, Tailwind, and shadcn-vue. That's the direction the ecosystem is moving. Pairing modern PHP with modern Vue just feels right.

Setting Up Laravel + Vue 3 + Inertia.js v2

Let me walk you through the setup I use. If you want the fastest path, the Laravel installer handles most of this automatically. But understanding the pieces matters.

Option 1: Laravel Starter Kit (Recommended)

The fastest way to get started with Laravel 12:

laravel new my-spa-app

The installer will ask which starter kit you want. Pick Vue with Inertia. This gives you Vue 3, Composition API, TypeScript, Tailwind, shadcn-vue, and Wayfinder all preconfigured. You're coding in under two minutes.

Option 2: Manual Setup

If you're adding Inertia to an existing project or want to understand the pieces:

# Create fresh Laravel 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
npm install --save-dev @vitejs/plugin-vue

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 lets you import components like import Button from '@/Components/Button.vue' instead of relative path chains. 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>

    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @inertiaHead
</head>
<body>
    @inertia
</body>
</html>

Configure Inertia Middleware

Publish and register 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 stats.

The Laravel Controller

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.

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,
});

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">
                <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>

                <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>
            </div>
        </div>
    </AuthenticatedLayout>
</template>

This is pure Composition API. Notice how publishRate uses computed() and 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 without writing fetch requests.

<!-- 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(),
    });
};
</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 class="flex items-center justify-end">
                    <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 handles loading states via form.processing, validation errors via form.errors, success callbacks, and automatic CSRF tokens. Your backend controller stays standard Laravel:

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 sends errors back to your Vue component automatically. No manual error handling needed.

Advanced Patterns: Composables and Shared State

Here's where Composition API gets powerful. Composables let you extract and reuse reactive logic across your entire app.

Sharing Auth Data Globally

In your HandleInertiaRequests middleware, share data that every page needs:

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,
            ] : null,
        ],
        'flash' => [
            'success' => $request->session()->get('success'),
            'error' => $request->session()->get('error'),
        ],
    ];
}

Create Reusable Composables

Now auth.user is available in every component via usePage().props.auth.user. But accessing it that way everywhere is messy. Wrap it in a 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) => {
        return user.value?.permissions?.includes(permission) ?? false;
    };

    return { user, isAuthenticated, can };
}

Same pattern works for flash messages:

// 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);

    return { flash, hasSuccess, hasError };
}

Usage is clean and consistent:

<script setup>
import { useAuth } from '@/Composables/useAuth';
import { useFlash } from '@/Composables/useFlash';

const { user, isAuthenticated, can } = useAuth();
const { flash, hasSuccess } = 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="isAuthenticated">
        <p>Welcome, {{ user.name }}!</p>
        <button v-if="can('edit-posts')">Edit</button>
    </div>
</template>

Write once, use everywhere. I carry these composables across projects.

Inertia v2: The Features That Change Everything

Inertia v2 rewrote the core to support async requests. This unlocked a set of features that make the stack feel like a completely different tool.

Deferred Props

This is the biggest one. Instead of loading everything upfront, you can defer expensive data until after the initial page render:

return Inertia::render('Dashboard', [
    'stats' => $this->getQuickStats(),
    'activityLog' => Inertia::defer(fn () => ActivityLog::latest()->take(50)->get()),
    'analytics' => Inertia::defer(fn () => $this->generateAnalytics())->group('charts'),
    'trends' => Inertia::defer(fn () => $this->calculateTrends())->group('charts'),
]);

The stats prop loads immediately. The activityLog loads in a separate request after the page renders. And analytics and trends are grouped together, so they load in one request instead of two.

On the frontend, use the <Deferred> component:

<script setup>
import { Deferred } from '@inertiajs/vue3';
</script>

<template>
    <Deferred data="activityLog">
        <template #fallback>
            <div class="animate-pulse">Loading activity...</div>
        </template>

        <div v-for="entry in activityLog" :key="entry.id">
            {{ entry.description }}
        </div>
    </Deferred>
</template>

No manual onMounted fetching. No isLoading refs. The component handles the loading state and renders content when data arrives.

Prefetching

Make your app feel instant by prefetching data when users hover over links:

<Link href="/dashboard" prefetch>Dashboard</Link>
<Link href="/users" prefetch :cache-for="60000">Users</Link>

By default, Inertia fetches data after 75ms of hovering. You can also prefetch on mount or on mousedown. Cached data is served immediately on click, then refreshed in the background if stale. This alone makes your SPA feel noticeably faster.

Polling

Need live data without WebSockets? Polling refreshes your page data on an interval:

<script setup>
import { usePoll } from '@inertiajs/vue3';

usePoll(5000); // Refresh every 5 seconds
</script>

That's it. Your page props automatically refresh. For a SaaS dashboard that shows real-time order counts or active users, this is the simplest approach. It's not a replacement for WebSockets when you need true real-time, but for most "live-ish" data it's perfect.

WhenVisible and Infinite Scroll

Load data only when the user scrolls to it:

<script setup>
import { WhenVisible } from '@inertiajs/vue3';
</script>

<template>
    <WhenVisible data="comments" buffer="200">
        <template #fallback>
            <div>Loading comments...</div>
        </template>

        <div v-for="comment in comments" :key="comment.id">
            {{ comment.body }}
        </div>
    </WhenVisible>
</template>

And for infinite scroll, Inertia v2 provides built-in primitives that work with merging props. You define a merge strategy on the backend and the frontend handles appending data as the user scrolls. No third-party library needed.

Type-Safe Routing with Wayfinder

Laravel 12 replaced Ziggy with Wayfinder in its official starter kits. And for good reason.

Ziggy gave you a route() helper that generated URLs from route names. Wayfinder generates fully typed TypeScript functions for your controllers and routes. You import your controller actions directly:

import { store } from '@/actions/App/Http/Controllers/PostController';
import { index } from '@/routes/posts';

// Type-safe URL generation
const url = index(); // '/posts'
const createUrl = store(); // knows the HTTP method too

The Vite plugin watches your PHP files and regenerates TypeScript automatically:

// vite.config.js
import { wayfinder } from '@laravel/vite-plugin-wayfinder';

export default defineConfig({
    plugins: [
        laravel({ /* ... */ }),
        vue({ /* ... */ }),
        wayfinder(),
    ],
});

If you rename a route in Laravel, your TypeScript breaks at build time instead of failing silently in production. That's a huge win. The new Wayfinder beta goes even further, generating TypeScript types from form requests, Eloquent models, and PHP enums. For any new Inertia project, I'd use Wayfinder over Ziggy.

If you're on an existing project that still uses Ziggy, it still works fine. But for new builds, Wayfinder is the clear choice.

Performance Optimization Techniques

After shipping several Inertia apps, here are the optimizations that actually move the needle.

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'],
        preserveScroll: true,
    });
};

The only option tells Inertia to reload just those props. Combined with deferred props, you can build pages that load fast initially and update surgically.

Lazy Loading Heavy Components

Inertia code-splits pages automatically, but lazy load heavy components too:

import { defineAsyncComponent } from 'vue';

const HeavyChart = defineAsyncComponent(() =>
    import('@/Components/HeavyChart.vue')
);

Debounced Search

For search inputs, debounce to avoid hammering your server:

<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 pattern prevents requests on every keystroke. I usually validate the search query with a quick regex check before firing the request too, to avoid sending obviously empty or invalid queries.

Common Mistakes and How to Avoid Them

I've made all these mistakes. Save yourself the debugging time.

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. Without this, you'll end up with 5+ identical queries per request.

Forgetting preserveScroll. When updating data without changing pages, add preserveScroll: true or your users jump to the top of the page. This is especially painful on long list pages.

Over-fetching data. Don't send entire Eloquent models to the frontend. Use map() or API Resources to send only what you need:

// Don't do this
return Inertia::render('Posts/Index', [
    'posts' => Post::with('user', 'category', 'tags')->get(),
]);

// Do this
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'),
        ]),
]);

Not handling file uploads properly. File uploads need forceFormData:

form.post(route('posts.store'), {
    forceFormData: true,
    onSuccess: () => form.reset(),
});

Without it, your files won't upload. You can check your request payload with your browser's HTTP status codes reference if you're getting unexpected 422 or 413 responses.

Ignoring Inertia v2 features. If you're manually writing onMounted fetches for secondary data, you should be using deferred props. If you're building custom loading states, you should be using the <Deferred> component. The v2 features exist to eliminate boilerplate.

Trade-offs: When NOT to Use This Stack

I'm a fan, but let me be honest about limitations.

Don't use Inertia if you need a mobile app. Inertia is for web SPAs. If you're building iOS or Android apps, stick with a REST or GraphQL API.

Don't use it if 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.

Don't use it if you need to serve multiple frontends. 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.

Don't use it for heavy real-time requirements. Polling works great for dashboards, but if you need instant messaging or collaborative editing, you'll want WebSockets with Laravel Reverb alongside Inertia, not instead of it.

The sweet spot is internal admin panels, SaaS dashboards, e-commerce backends, and any CRUD-heavy application where you control both frontend and backend. That's where this stack absolutely dominates.

Frequently Asked Questions

Should I use Ziggy or Wayfinder for routing?

For new projects, use Wayfinder. It's the official choice in Laravel 12 starter kits and generates fully typed TypeScript functions, not just URL strings. Ziggy still works fine for existing projects, but Wayfinder provides type safety that catches broken routes at build time instead of runtime.

Can I add Inertia to an existing Laravel project?

Yes. Install Inertia alongside your existing routes and migrate one page at a time. Start with read-only pages (dashboards, profile views), then convert forms gradually. You don't need to rewrite everything at once. Keep your API routes running until you've fully migrated.

What's the difference between deferred props and partial reloads?

Deferred props load automatically after the initial page render. You define them on the server and Inertia handles the fetch. Partial reloads are triggered manually (like when a user submits a form) and let you specify which props to refresh. Use deferred props for non-critical initial data, partial reloads for user-initiated updates.

Is Inertia v2 backwards compatible with v1?

Mostly, yes. The breaking changes are minimal: Vue 2 adapter removed, remember renamed to useRemember, and partial reloads are now async by default. The upgrade guide covers everything, and most v1 code works without changes. The new features (deferred props, polling, prefetching) are purely additive.

Do I still need Vuex or Pinia with Inertia?

Usually not. Inertia's shared props and composables handle most state management needs. Your server is the source of truth, and Inertia syncs it to the frontend automatically. I only reach for Pinia when I have complex client-side state that doesn't map to server data, like a multi-step wizard with draft state or a complex drag-and-drop interface.

Wrapping Up

After 9+ years building web applications, Laravel + Vue 3 + Inertia v2 has become my default choice for new full-stack projects. It eliminated the complexity that used to slow me down when starting new ideas.

No more debating REST vs GraphQL for your own frontend. No more maintaining API documentation. No more CORS headaches. Just controllers returning props to Vue components, with deferred props and prefetching making everything feel fast.

The Composition API's reusability means I've built a library of composables that speed up every new project. Wayfinder's type safety means broken routes surface at build time. And Inertia v2's async features mean I'm writing less boilerplate than ever.

If you're building a full-stack application where you control both ends, give this stack a try. Start small: convert one page of an existing project or scaffold a fresh one with laravel new. You'll know within a day if it fits your workflow.

Need help building your next full-stack project with this stack? Let's talk.

Share: X/Twitter | LinkedIn |
Hafiz Riaz

About Hafiz

Senior Full-Stack Developer with 9+ years building web apps and SaaS platforms. I specialize in Laravel and Vue.js, and I write about the real decisions behind shipping production software.

View My Work →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime