laravel-inertia-vue / references/blueprint.md

Project blueprint

Read when: starting a new Laravel + Inertia + Vue app, or adding a top-level folder, an entry file, a layout or a shared type to one. Owns: the resources/js folder map, the day-one files with their code, and the naming rules.

TL;DR

  • Keep the starter kit's layout under resources/js/; every folder below has one job.
  • Day one: app.ts, inertia-app.ts (and ssr.ts if you render on the server), two layouts, the flash listener, direction sync, the shared prop types and cn() are here; the translation helper and the appearance composable are in composables.md.
  • The house components are in house-components.md.
  • A page file path equals its Inertia::render string. Components are PascalCase.vue; every other file is kebab-case, except composables, which are useX.ts.

Folder map

resources/
├── css/app.css                  Tailwind entry, design tokens, fonts, the few unlayered overrides
├── views/app.blade.php          the Inertia root view: <html lang dir class>, @vite, @inertia
└── js/
    ├── app.ts                   browser entry: createInertiaApp + theme, direction sync, flash listener
    ├── ssr.ts                   server entry (only with SSR): createInertiaApp, nothing that needs window
    ├── inertia-app.ts           what both entries share: title(), layout resolver, URL defaults
    ├── pages/<Area>/<Name>.vue  one file per screen: Index, Show, Create, Edit
    ├── layouts/                 AppLayout (sidebar shell), AuthLayout (centred card); a nested one in a folder
    ├── components/
    │   ├── ui/                  shadcn-vue output, written by the CLI only
    │   ├── <Name>.vue           house components used across areas (PageHeader, DataTable, …)
    │   └── <area>/<Name>.vue    components one area uses (invoices/InvoiceTotals.vue)
    ├── composables/             useX.ts: functions that own reactive state or wrap Inertia
    ├── lib/                     utils (cn), flash, document, permissions, button, tone, direction, http
    ├── types/                   index.ts (shared props, Inertia augmentation), pagination.ts, <area>.ts
    ├── routes/ actions/ wayfinder/   generated by Wayfinder: gitignored, never edited
    └── __tests__/               Vitest files for lib/ and composables/ (if the project tests the front end)
  • components/ is flat for house components and one lowercase folder per area for the rest. A component moves up to components/ when a second area needs it, not before.
  • lib/ holds plain functions, constants and boot code. A function that owns reactive state is a composable; one that only reads the page (can(), the flash listener) may stay in lib/.
  • No store/ folder. State lives in props, the URL, or a composable (data-flow.md).

inertia-app.ts

Shared by both entries, so a layout added to one cannot be missing from the other.

import AppLayout from '@/layouts/AppLayout.vue';

export const appName = import.meta.env.VITE_APP_NAME || 'App';

export const title = (title: string): string => (title ? `${title} | ${appName}` : appName);

export const layout = (name: string) => {
    switch (true) {
        case name.startsWith('Auth/'):
            return null;
        default:
            return AppLayout;
    }
};
  • The resolver matches on the page-name prefix, and the match is case-sensitive. Rename an area and you update this switch in the same change, or the page falls back to the default shell.
  • Auth pages return null and render AuthLayout themselves, so they can fill its title slots.
  • A nested shell is an array: return [AppLayout, SettingsLayout].

app.ts

import { createInertiaApp } from '@inertiajs/vue3';
import { initializeTheme } from '@/composables/useAppearance';
import { layout, title } from '@/inertia-app';
import { syncDocumentLanguage } from '@/lib/document';
import { initializeFlashToast } from '@/lib/flash';

createInertiaApp({ title, layout, progress: { color: 'var(--primary)' } });

initializeTheme();
syncDocumentLanguage();
initializeFlashToast();

ssr.ts is the same createInertiaApp({ title, layout }) without the three calls: each one touches window or document, which crashes under Node. The theme survives because the root view writes the dark class from the appearance cookie before any script runs.

lib/utils.ts

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]): string {
    return twMerge(clsx(inputs));
}

The shadcn-vue CLI writes this file; keep it as it is.

types/index.ts

Augment Inertia once, so usePage().props is typed everywhere without a generic at each call.

import '@inertiajs/core';

export type Locale = 'ar' | 'en';
export type Direction = 'ltr' | 'rtl';
export type Crumb = { title: string; href?: string };
export type AuthUser = { id: number; name: string; email: string };

export type SharedProps = {
    name: string;
    auth: { user: AuthUser | null; permissions: string[] };
    locale: Locale;
    direction: Direction;
    translations: Record<string, unknown>;
    flash: { success: string | null; error: string | null };
};

declare module '@inertiajs/core' {
    export interface InertiaConfig {
        sharedPageProps: SharedProps;
        layoutProps: { breadcrumbs: Crumb[] };
    }
}

export type { Paginated } from './pagination';

types/pagination.ts mirrors what Laravel's paginate() serialises to, so a controller can send $query->paginate()->through(fn ($row) => [...]) with no wrapper:

export type Paginated<T> = {
    data: T[];
    current_page: number;
    last_page: number;
    per_page: number;
    total: number;
    from: number | null;
    to: number | null;
    links: { url: string | null; label: string; active: boolean }[];
};

A resource collection serialises to { data, meta, links } instead. Pick one shape per app and type it once.

lib/document.ts: direction sync

Inertia swaps pages under one <html>, so the lang and dir the server wrote on the first load outlive every visit, including a switch of language. Watch the page, not a router event: navigate does not fire on a replace visit.

import { usePage } from '@inertiajs/vue3';
import { watchEffect } from 'vue';

export function syncDocumentLanguage(): void {
    if (typeof document === 'undefined') return;

    const page = usePage();

    watchEffect(() => {
        if (page.props === undefined) return;

        const root = document.documentElement;
        const lang = page.props.locale === 'ar' ? 'ar' : 'en';
        const dir = page.props.direction === 'rtl' ? 'rtl' : 'ltr';

        if (root.lang !== lang) root.setAttribute('lang', lang);
        if (root.dir !== dir) root.setAttribute('dir', dir);
    });
}

Compare before writing: setting dir re-lays out the whole page. In a one-language app this file still pays for itself the day a second language arrives; skip it only if that day will never come.

lib/flash.ts: the toast listener

Registered once, at boot. A GET stays silent; every write reports.

import { router, usePage } from '@inertiajs/vue3';
import { toast } from 'vue-sonner';
import { useTranslations } from '@/composables/useTranslations';

export function initializeFlashToast(): void {
    let writing = false;
    let reported = false;
    const { t } = useTranslations();

    router.on('before', (event) => {
        writing = event.detail.visit.method !== 'get';
        reported = false;
    });

    router.on('error', () => {
        reported = true;
        toast.error(t('common.validation_failed'));
    });

    router.on('httpException', () => {
        reported = true;
        toast.error(t('common.operation_failed'));
    });

    router.on('networkError', () => {
        reported = true;
        toast.error(t('common.operation_failed'));
    });

    router.on('success', (event) => {
        if (reported) return;

        const flash = event.detail.page.props.flash;

        if (flash?.error) toast.error(flash.error);
        else if (flash?.success) toast.success(flash.success);
        else if (writing) toast.success(t('common.operation_done'));
    });
}

The event names are Inertia 3's; Inertia 2 called the last two invalid and exception (checked 2026-11-03). Inertia 3 can also flash one-off data without the session (Inertia::flash('toast', …) read by router.on('flash')); either works, as long as one listener owns every toast. The rules for what a toast says are in actions-and-feedback.md.

Layouts

AppLayout.vue, the sidebar shell. Every layout's root is ConfigProvider, because Reka reads direction from it, not from <html>.

<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { ConfigProvider } from 'reka-ui';
import AppSidebar from '@/components/AppSidebar.vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { Separator } from '@/components/ui/separator';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { Toaster } from '@/components/ui/sonner';
import { useAppearance } from '@/composables/useAppearance';
import type { Crumb } from '@/types';

withDefaults(defineProps<{ breadcrumbs?: Crumb[] }>(), { breadcrumbs: () => [] });

const page = usePage();
const { appearance } = useAppearance();
</script>

<template>
    <ConfigProvider :dir="page.props.direction" :locale="page.props.locale">
        <SidebarProvider>
            <AppSidebar />
            <SidebarInset>
                <header class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
                    <SidebarTrigger class="-ms-1" />
                    <Separator v-if="breadcrumbs.length" orientation="vertical" class="me-2 h-4 data-[orientation=vertical]:self-center" />
                    <Breadcrumbs v-if="breadcrumbs.length" :crumbs="breadcrumbs" />
                    <div id="page-actions" class="ms-auto flex items-center gap-2" />
                </header>
                <div class="flex flex-1 flex-col gap-4 p-4">
                    <slot />
                </div>
            </SidebarInset>
            <Toaster :dir="page.props.direction" :theme="appearance" position="top-center" close-button />
        </SidebarProvider>
    </ConfigProvider>
</template>
  • A page feeds the header with setLayoutProps({ breadcrumbs: [...] }) (Inertia 3 layout props, checked 2026-11-03); the last crumb has no href and names the page.
  • #page-actions is where PageHeader teleports the page's buttons, so the header band holds the trail and the actions and no page draws a second title row.
  • The content column is flex flex-1 flex-col gap-4 p-4: the layout owns the page padding and the gap between sections, so pages never add space-y-* or a section mb-*.
  • AppSidebar renders <Sidebar collapsible="icon" variant="inset">; the look this gives is in theme.md.

AuthLayout.vue keeps the same ConfigProvider and Toaster, around a centred card:

<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
    <div class="flex w-full max-w-sm flex-col gap-6">
        <div class="flex items-center justify-center gap-2 self-center font-medium">
            <AppLogo class="size-6" />
            {{ page.props.name }}
        </div>
        <Card>
            <CardHeader class="text-center">
                <CardTitle class="text-xl"><slot name="title" /></CardTitle>
                <CardDescription><slot name="description" /></CardDescription>
            </CardHeader>
            <CardContent><slot /></CardContent>
        </Card>
    </div>
</div>

Naming

Thing Rule Example
page pages/<Area>/<Name>.vue, PascalCase area and file, equal to the render string Inertia::render('Invoices/Show') → pages/Invoices/Show.vue
page names Index, Show, Create, Edit; a multi-step form Wizard
component PascalCase.vue, at least two words; an area component repeats its area components/invoices/InvoiceTotals.vue
component folder lowercase kebab-case, named after the area components/order-items/
composable useX.ts exporting useX(); use only when it owns state or wraps Inertia useListFilters.ts
other files kebab-case.ts exporting camelCase functions; predicates start with is / has lib/format-money.ts → formatMoney()
types type aliases, PascalCase, one area per file; a list page's slim shape ends in Row types/invoice.ts: Invoice, InvoiceRow
permissions <resource>.<action> invoices.update
  • No [id] folders: Inertia has no file-based routing; an id is a server route parameter.
  • A single-word component (Row.vue, Grid.vue) can clash with an HTML element; Vue's style guide asks for two words.
  • The render string and the file path differ only by pages/ and .vue. A mismatch is the most common Inertia "page not found".