Week 29: Laravel + SPA Integration with Inertia.js

Week 15 built a full REST API for a separate frontend to consume. Inertia.js offers a genuinely different middle ground — a modern JavaScript frontend (Vue or React) wired directly into Laravel's routing and controllers, with no separate API layer to build or maintain at all. Both patterns from this course are now on the table, deliberately.

Module 26 of 28 Week 29 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what problem Inertia.js solves & how it differs from a REST API
  • Build Laravel routes that render Vue/React pages via Inertia
  • Share data, handle forms & surface validation errors across the Inertia bridge

1. The Problem Inertia Solves

Week 15's REST API approach means building and maintaining two separate concerns: a versioned API surface (routes, Resources, auth tokens) and a frontend that consumes it, each with its own routing, its own auth handling, its own request/response contracts to keep in sync.

Inertia.js takes a different position: keep Laravel's routing, controllers, session-based auth and validation exactly as built in Weeks 9-14 — but instead of returning a Blade view, a controller returns the name of a Vue or React component plus the data it needs. Inertia handles turning that into a real client-side page render, with no API layer in between at all.

the three approaches from this course, compared
Blade (Week 9):        Controller → Blade view → full page reload on every navigation
REST + separate app:   Controller → JSON → a fully separate frontend app, own routing
Inertia (this week):   Controller → Vue/React component + data → SPA-like nav, one Laravel app

Inertia is the right fit specifically when a team wants a modern, SPA-feeling frontend without paying the cost of a second, fully independent application to build, deploy and keep in sync — a real trade-off against Week 15's approach, not a strict upgrade.

2. Setup

terminal
composer require inertiajs/inertia-laravel
php artisan inertia:middleware

npm install @inertiajs/vue3 vue
bootstrap/app.php (Laravel 11)
<?php

->withMiddleware(function ($middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\HandleInertiaRequests::class,
    ]);
})

This registers Inertia's middleware alongside every other web middleware from Week 14 — auth, CSRF protection, and session handling all continue to work exactly as before. Inertia adds a rendering layer on top; it doesn't replace the request pipeline underneath.

3. Rendering Pages

A controller returns Inertia::render() instead of view() — same routing and controller structure from Week 10, different return value:

app/Http/Controllers/TaskController.php
<?php

use Inertia\Inertia;

class TaskController extends Controller
{
    public function index()
    {
        return Inertia::render('Tasks/Index', [
            'tasks' => auth()->user()->tasks()->with('tags')->get(), // eager-loaded, per Week 12
        ]);
    }
}
resources/js/Pages/Tasks/Index.vue
<script setup>
defineProps({ tasks: Array });
</script>

<template>
  <div>
    <h1>Tasks</h1>
    <ul>
      <li v-for="task in tasks" :key="task.id">
        {{ task.title }}
        <span v-if="task.is_done">(done)</span>
      </li>
    </ul>
  </div>
</template>

'Tasks/Index' resolves to resources/js/Pages/Tasks/Index.vue by convention. Navigation between Inertia pages happens client-side, without a full page reload — but every navigation still goes through a real Laravel route and controller, unlike a traditional SPA's client-only routing.

4. Forms & Validation

This is where Inertia's approach diverges most visibly from Week 15's API pattern — validation errors from a Form Request flow straight through to the frontend, with no manual JSON error-handling required:

resources/js/Pages/Tasks/Create.vue
<script setup>
import { useForm } from '@inertiajs/vue3';

const form = useForm({ title: '', priority: 'medium' });

function submit() {
  form.post('/tasks'); // hits the exact same store() route from Week 10
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="form.title">
    <div v-if="form.errors.title">{{ form.errors.title }}</div>
    <button :disabled="form.processing">Create</button>
  </form>
</template>
the controller -- completely unchanged from Week 10's Form Request pattern
<?php

public function store(StoreTaskRequest $request)
{
    auth()->user()->tasks()->create($request->validated());

    return redirect()->route('tasks.index');
}

When StoreTaskRequest's validation fails, Inertia automatically catches the redirect-with-errors response (Week 10's exact mechanism) and populates form.errors on the client — the same server-side validation rules from Week 10, now surfacing directly in the Vue component with zero extra client-side validation logic duplicated.

5. Shared Data

Data every page needs — the logged-in user, flash messages — is declared once in middleware rather than passed manually from every controller:

app/Http/Middleware/HandleInertiaRequests.php
<?php

public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'auth' => [
            'user' => $request->user(),
        ],
        'flash' => [
            'success' => fn () => $request->session()->get('success'), // Week 10's flash data
        ],
    ];
}

Every Inertia page component now has $page.props.auth.user and $page.props.flash.success available automatically — the flash-message pattern from Week 5 and Week 10, now flowing through to a JavaScript frontend without any per-page plumbing.

6. Hands-on Exercise

Hands-on

Convert the task tracker's UI to Inertia + Vue

Replace Blade views with Inertia pages, keeping every controller, Policy and validation rule unchanged.

Requirements:

  1. Install Inertia + Vue and confirm the middleware is registered.
  2. Convert tasks.index, tasks.create and tasks.show from Blade views to Inertia-rendered Vue pages, without changing any controller logic beyond swapping view() for Inertia::render().
  3. Build the create-task form with useForm(), confirming server-side validation errors from StoreTaskRequest (Week 10) surface correctly in the Vue component.
  4. Share the authenticated user and flash success messages globally via HandleInertiaRequests, and display the flash message in a shared layout component.
  5. Confirm TaskPolicy's ownership checks (Week 13) still 403 correctly — authorization logic shouldn't need any changes at all for this migration to work.
Hint

If this migration goes well, your controllers, Form Requests, and Policies should need almost no changes — that's the actual point being demonstrated: Inertia is a rendering-layer swap, not a rewrite of the application's underlying logic.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What's the fundamental difference between Week 15's REST API approach and this week's Inertia.js approach?

A REST API creates a genuine boundary between two independent applications — a Laravel backend and a fully separate frontend, each with its own routing, auth handling and deploy lifecycle, communicating only through versioned JSON endpoints. Inertia keeps everything as one Laravel application: the same routes, controllers, session auth and validation, just rendering a JavaScript component instead of a Blade view — there's no separate API surface to design, version or maintain at all.

Q2

Why does TaskController@store need zero changes to work correctly with Inertia's useForm()?

Because Inertia is built specifically to understand Laravel's existing validation-failure response (the redirect-back-with-errors pattern from Week 10) and translate it automatically into form.errors on the client. The controller's StoreTaskRequest validation logic never had to be redesigned around Inertia — Inertia was designed to work with the pattern Laravel already uses.

Q3

Why should TaskPolicy's ownership checks from Week 13 keep working correctly after migrating to Inertia, with no changes to the Policy itself?

Authorization runs at the controller level ($this->authorize('delete', $task)), entirely independent of what the controller eventually returns. Inertia only changes the response format at the very end of the request — the entire middleware stack, routing, and authorization logic before that point is unaffected, which is exactly the point of Inertia being a rendering-layer change rather than an architectural rewrite.

Q4

When would Week 15's separate REST API still be the better choice over Inertia?

When more than one genuinely separate client needs the same backend — a mobile app and a web frontend both consuming the same data, for instance, or a third party integrating with your API directly. Inertia's tight coupling between the Laravel app and its one specific frontend is a strength for a single-application team, but it doesn't fit a situation where multiple independent clients need the same underlying data through a stable, versioned contract.