Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Inertia & Starter Kits

Building a Vue front end without an API layer, and what the starter kits give you.

There are two common ways to build a Vue front end on Laravel. The first is the one this site's tutorials use: Laravel serves a JSON API (/api/demo/...) and Vue fetches from it. The second is Inertia: you keep Laravel's routes and controllers exactly as for a Blade app, but each controller returns a Vue page component with props instead of a Blade view. There's no API to design, and no client-side router to maintain. Laravel's official Vue starter kit is built on Inertia, so it's how most new Laravel + Vue apps start.

This app doesn't use Inertia: its pages are Blade and its demos call a JSON API. The Inertia code in this lesson is illustrative, and the live demo simulates the Inertia protocol in your browser using this site's real project data.
Browser (Vue) <Link href="/projects"> swaps the page component, no reload Laravel routes + controllers Inertia::render( 'Projects/Index', [...]) 1. first visit: normal GET full HTML with data-page="{component, props}" 2. link click: GET + X-Inertia: true JSON page object only
Inertia: the server picks the page and its data; the client only renders it. After the first load, navigation exchanges small JSON page objects.

Try it: the Inertia protocol, step by step

Click the links inside the simulated app. Each visit shows the request your browser would send and the page object Laravel would return. The project data is loaded from this site's real /api/demo/projects endpoint.

The first visit returns HTML because the browser needs a page to boot Vue. After that, every <Link> click sends X-Inertia: true, and Laravel answers with just { component, props, url, version }. The client looks up the component by name, passes it the props, and swaps it in without a full reload.

The controller: same as Blade, different return

Here is this app's real API controller for the projects list, which returns JSON for Vue to fetch:

app/Http/Controllers/Api/ProjectController.php

public function index(): AnonymousResourceCollection
{
    $projects = Project::query()
        ->with('owner')
        ->withCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()])
        ->orderBy('name')
        ->get();

    return ProjectResource::collection($projects);
}

The Inertia version of the same page keeps the query and returns a page component instead:

// Illustrative — app/Http/Controllers/ProjectController.php in an Inertia app
use Inertia\Inertia;
use Inertia\Response;

public function index(): Response
{
    $projects = Project::query()
        ->with('owner')
        ->withCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()])
        ->orderBy('name')
        ->get();

    return Inertia::render('Projects/Index', [
        'projects' => ProjectResource::collection($projects),
        'canCreate' => request()->user()->can('create', Project::class),
    ]);
}

Authentication, middleware, policies, validation, sessions and redirects all work exactly as in the Blade lessons. That's the main appeal: you write a classic server-side app and get a single-page-app experience.

The page component

'Projects/Index' maps to resources/js/pages/Projects/Index.vue. Props arrive through defineProps, and <Link> replaces <a> for in-app navigation:

<!-- Illustrative — resources/js/pages/Projects/Index.vue -->
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'
import AppLayout from '@/layouts/AppLayout.vue'

defineProps<{
  projects: { data: Array<{ id: number; name: string; slug: string; open_tasks_count: number }> }
  canCreate: boolean
}>()
</script>

<template>
  <AppLayout>
    <Head title="Projects" />
    <h1>Projects</h1>
    <Link v-if="canCreate" href="/projects/create">New project</Link>
    <ul>
      <li v-for="project in projects.data" :key="project.id">
        <Link :href="`/projects/${project.slug}`">{{ project.name }}</Link>
        ({{ project.open_tasks_count }} open)
      </li>
    </ul>
  </AppLayout>
</template>

Forms and validation errors

Inertia's useForm posts to a normal Laravel route. When validation fails, Laravel redirects back as usual, and Inertia puts the errors on form.errors, field by field. There's no 422 handling to write:

<!-- Illustrative — resources/js/pages/Tasks/Create.vue -->
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'

const props = defineProps<{ projectId: number }>()

const form = useForm({ project_id: props.projectId, title: '', priority: 'medium' })

function submit() {
  form.post('/tasks', { onSuccess: () => form.reset('title') })
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="form.title" />
    <p v-if="form.errors.title" class="error">{{ form.errors.title }}</p>
    <button :disabled="form.processing">Add task</button>
  </form>
</template>

On the server, the controller is the same one you'd write for Blade, using this app's real StoreTaskRequest:

// Illustrative
public function store(StoreTaskRequest $request): RedirectResponse
{
    $task = Task::create($request->validated());

    return to_route('projects.show', $task->project)->with('success', 'Task added.');
}

Compare that with the Form Validation lesson, where the Vue side maps a JSON 422 response onto fields by hand, because this site uses a separate API.

Shared data, partial reloads and Inertia 2 features

Shared data is available to every page: the logged-in user, flash messages, app name. It's defined once in the HandleInertiaRequests middleware:

// Illustrative — app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'auth' => ['user' => $request->user()?->only('id', 'name', 'email')],
        'flash' => ['success' => fn () => $request->session()->get('success')],
    ];
}

Pages read it with usePage().props.auth.user.

Inertia 2 adds tools for large pages:

  • Partial reloads: router.reload({ only: ['tasks'] }) re-runs the controller but sends back only the named props. Wrap expensive props in closures so they're only computed when requested.
  • Deferred props: Inertia::defer(fn () => $stats->get()) renders the page first and loads slow data in a follow-up request.
  • Prefetching: <Link prefetch> loads the next page on hover.
  • Polling (usePoll(5000)) and infinite scrolling with merged props.

The Vue starter kit

laravel new my-app, then choose Vue when asked for a starter kit, and you get a working Inertia app:

  • Inertia 2, Vue 3 with <script setup> and TypeScript, Vite and Tailwind CSS
  • UI components from shadcn-vue that you own and can edit
  • Login, registration, password reset, email verification and profile/settings pages, all wired up
  • Pest or PHPUnit tests for the authentication flows
  • Optional WorkOS AuthKit for social login and SSO
laravel new my-app          # choose "Vue" as the starter kit
cd my-app && composer run dev

The parts you'll edit first:

resources/js/pages/            # one .vue file per page, e.g. Dashboard.vue, auth/Login.vue
resources/js/layouts/          # AppLayout.vue, AuthLayout.vue
resources/js/components/ui/    # the shadcn-vue components
routes/web.php, routes/auth.php, routes/settings.php
app/Http/Controllers/Auth/     # the controllers behind the auth pages

React and Livewire starter kits work the same way. Breeze and Jetstream, the older kits, no longer receive new features.

A starter kit is a starting point, not a dependency: all its code is copied into your app. Read through the generated controllers and pages once. They're a good reference for how auth is meant to fit together; see the Login, Registration & Logout lesson.

Inertia or a separate API?

Choose Inertia when… Choose a JSON API when…
One Laravel app serves its own Vue front end Several clients use the data (mobile apps, partners, other services)
You want server-side routing, auth and validation The front end is deployed separately or built by another team
SEO matters and you can use Inertia's SSR You need a public, versioned, documented API
You'd rather not design and version endpoints Pages are embedded widgets inside a server-rendered site (like this one)

This site is the second case: Blade pages with independent Vue widgets that each call /api/demo. For the front-end side of both approaches, see Vue + Laravel in Production.

Key takeaways

  • Inertia lets controllers return Inertia::render('Page/Name', $props). Laravel keeps routing, auth and validation; Vue renders the page.
  • The first request returns HTML with the page object embedded; later navigations send X-Inertia: true and receive JSON.
  • useForm posts to normal routes and receives validation errors automatically. Shared props come from HandleInertiaRequests::share().
  • Inertia 2 adds partial reloads, deferred props, prefetching and polling for heavier pages.
  • The official Vue starter kit (laravel new, choose Vue) gives you Inertia, TypeScript, Tailwind and complete auth pages to build on.
  • Use Inertia for a single app's own front end; build a JSON API when other clients need the data.