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(andssr.tsif you render on the server), two layouts, the flash listener, direction sync, the shared prop types andcn()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::renderstring. Components arePascalCase.vue; every other file iskebab-case, except composables, which areuseX.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 tocomponents/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 inlib/.- 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
nulland renderAuthLayoutthemselves, 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 nohrefand names the page. #page-actionsis wherePageHeaderteleports 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 addspace-y-*or a sectionmb-*. AppSidebarrenders<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".