Stu Mason
Stu Mason
Guide

React 19 + Laravel 12: The Full Stack Setup I Use on Every Project

Stuart Mason6 min read

I've iterated on my stack for years. Tried Vue, tried Livewire, tried separate SPAs. What I've landed on is the most productive setup I've ever used. It's not bleeding edge — it's the boring-in-a-good

React 19 + Laravel 12: The Full Stack Setup I Use on Every Project

I've iterated on my stack for years. Tried Vue, tried Livewire, tried separate SPAs. What I've landed on is the most productive setup I've ever used. It's not bleeding edge — it's the boring-in-a-good-way sweet spot where everything just works.

Here's what's running on this very site.

The Stack

LayerTechnologyVersion
BackendLaravel12
Frontend BridgeInertia.jsv2
Frontend FrameworkReact19
Type SafetyTypeScript5.x
Route TypingWayfinderv0
CSSTailwind CSS4
Build ToolVite6
AuthFortifyv1
Queue ManagementHorizonv5
Real-timeReverbv1
TestingPestv4

Why These Specific Choices

Laravel 12 because it's the current version and the streamlined structure (see Article 01) reduces boilerplate significantly. The slimmed-down directory structure means less shit to navigate.

Inertia v2 because it eliminates the API layer entirely. Your controllers return Inertia responses. Your React components receive props. No REST endpoints to maintain, no token authentication to manage, no serialisation layer to build. It's the biggest productivity win in the stack.

React 19 because the component model is solid, the ecosystem is enormous, and hiring React developers is easy. Server components aren't relevant here (Inertia handles the server-client boundary), but hooks, suspense, and the improved rendering in 19 are all useful.

TypeScript because I don't want to ship runtime type errors. On a side note, if you're still writing plain JavaScript in 2026, I'm not angry, just disappointed.

Wayfinder because it generates TypeScript functions from your Laravel routes. Change a route parameter, and TypeScript catches it at compile time. This is the piece that makes the whole stack feel seamless.

Tailwind v4 because utility-first CSS is faster to write, easier to maintain, and the v4 CSS-first configuration is cleaner than the old JavaScript config.

Setting It All Up

Laravel's starter kits get you most of the way. But let me walk through the key configuration files so you understand what's actually happening.

vite.config.ts

import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import wayfinder from '@laravel/vite-plugin-wayfinder'

export default defineConfig({
    plugins: [
        laravel({
            input: 'resources/js/app.tsx',
            ssr: 'resources/js/ssr.tsx',
            refresh: true,
        }),
        react(),
        tailwindcss(),
        wayfinder(),
    ],
})

Four plugins, each doing one job:

  • laravel-vite-plugin handles hot module replacement and asset building
  • @vitejs/plugin-react enables JSX/TSX compilation
  • @tailwindcss/vite processes Tailwind classes
  • @laravel/vite-plugin-wayfinder auto-generates typed route functions when your routes change

The Inertia Entry Point

// resources/js/app.tsx
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'

createInertiaApp({
    resolve: (name) =>
        resolvePageComponent(
            `./Pages/${name}.tsx`,
            import.meta.glob('./Pages/**/*.tsx'),
        ),
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />)
    },
})

This is the bit that connects Inertia to React. When your Laravel controller returns Inertia::render('Dashboard/Index', [...]), Inertia resolves it to resources/js/Pages/Dashboard/Index.tsx and renders it with the provided props.

Tailwind v4 CSS-First Config

/* resources/css/app.css */
@import "tailwindcss";

@theme {
    --font-sans: "Inter", sans-serif;
    --color-primary: oklch(0.55 0.15 250);
    --color-danger: oklch(0.55 0.2 25);
}

No tailwind.config.js. In v4, everything is CSS-based using the @theme directive. Custom colours, fonts, spacing — all defined in CSS. It's cleaner and means your IDE can autocomplete custom values.

The Development Workflow

Here's what building a feature actually looks like, end to end.

Step 1: Define the route

// routes/web.php
Route::get('/projects/{project}', [ProjectController::class, 'show'])
    ->name('project.show');

Step 2: Create the controller

// app/Http/Controllers/ProjectController.php
class ProjectController extends Controller
{
    public function show(Project $project): Response
    {
        return Inertia::render('Projects/Show', [
            'project' => ProjectData::fromModel($project),
        ]);
    }
}

Step 3: Create the DTO

// app/DataTransferObjects/ProjectData.php
readonly class ProjectData
{
    public function __construct(
        public int $id,
        public string $name,
        public string $slug,
        public string $description,
        public string $status,
    ) {}

    public static function fromModel(Project $project): self
    {
        return new self(
            id: $project->id,
            name: $project->name,
            slug: $project->slug,
            description: $project->description,
            status: $project->status->value,
        );
    }
}

Step 4: Generate TypeScript types

php artisan types:generate

This creates:

// resources/js/types/generated.d.ts
export interface ProjectData {
    id: number
    name: string
    slug: string
    description: string
    status: string
}

Step 5: Build the page component

// resources/js/Pages/Projects/Show.tsx
import type { ProjectData } from '@/types/generated'

interface Props {
    project: ProjectData
}

export default function Show({ project }: Props) {
    return (
        <div className="max-w-4xl mx-auto py-12">
            <h1 className="text-3xl font-bold">{project.name}</h1>
            <p className="mt-4 text-gray-600">{project.description}</p>
            <span className="mt-2 inline-block rounded-full bg-primary/10 px-3 py-1 text-sm">
                {project.status}
            </span>
        </div>
    )
}

Step 6: Use Wayfinder for navigation

import { show } from '@/actions/App/Http/Controllers/ProjectController'
import { Link } from '@inertiajs/react'

// Type-safe URL generation
<Link href={show.url(project.id)}>
    View Project
</Link>

// Or with the Inertia router
router.visit(show.url(project.id))

If you rename the route parameter from {project} to {project:slug}, Wayfinder regenerates the types, and TypeScript tells you everywhere that needs updating. No grep. No runtime errors. Just compile-time safety.

Inertia v2 Features I Use Constantly

Deferred Props — Load expensive data after the page renders:

return Inertia::render('Dashboard', [
    'stats' => $basicStats,
    'activityLog' => Inertia::defer(fn () => $this->getExpensiveActivityLog()),
]);
<Deferred fallback={<ActivitySkeleton />}>
    <ActivityLog entries={activityLog} />
</Deferred>

The Form Component — Handles form state, submission, errors, and progress:

import { Form } from '@inertiajs/react'
import { store } from '@/actions/App/Http/Controllers/ProjectController'

<Form {...store.form()}>
    {({ errors, processing }) => (
        <>
            <input type="text" name="name" />
            {errors.name && <p className="text-red-500">{errors.name}</p>}
            <button type="submit" disabled={processing}>
                {processing ? 'Creating...' : 'Create'}
            </button>
        </>
    )}
</Form>

Polling — Refresh data on an interval without writing custom code:

<WhenVisible poll={5000}>
    <NotificationsList notifications={notifications} />
</WhenVisible>

The Production Build

npm run build    # Compiles TypeScript, bundles React, processes Tailwind

Vite produces hashed, minified assets in public/build/. Laravel's @vite directive in your root Blade template handles cache busting automatically.

In Docker (see Article 06), the Node build happens in a multi-stage build step, and only the compiled assets end up in the production image. No Node runtime needed in production.

Why This Stack Works

It's not about individual tools being the best in their category. It's about how they integrate:

  • Laravel handles all backend concerns (auth, database, queues, mail, etc.)
  • Inertia eliminates the API layer between backend and frontend
  • Wayfinder eliminates the type gap between PHP routes and TypeScript
  • DTOs with types:generate eliminate the type gap between PHP data and TypeScript
  • Tailwind eliminates the CSS-architecture decision entirely

Each piece removes a category of problems. What's left is just writing features.

I've been building web applications for 16 years, and this is the first time my stack hasn't felt like it's fighting me somewhere. It's not perfect — nothing is — but it's the closest I've found to "just build the thing."


I write about Laravel, AI tooling, and building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.