Laravel + Inertia + Vue
Read when: creating a Laravel + Inertia + Vue app, or writing or reviewing anything under
resources/js/ in one.
Owns: the project blueprint, the house components, and the rules for data flow, forms, lists,
records, actions, feedback, copy, theme, direction, icons and motion on this stack.
The server keeps routing, controllers and validation; each page is a Vue component that renders
typed props and submits forms. Versions this skill was checked against (2026-11-03): Inertia 3,
Vue 3.5, Tailwind 4, reka-ui 2, zod 4, vue-sonner 2, @lucide/vue 1. Re-check after a major upgrade.
TL;DR
- The server owns data, routing and validation. A page renders typed props and submits with
useForm. - Start a project from blueprint.md and
assets/app.css; build the house components once from house-components.md. Every screen uses them, which is what makes screens look the same. - Build from the registry, never from styled
divs. Never hand-editcomponents/ui/. - Semantic tokens only, logical properties only. Directional icons flip in RTL.
- Every page sets
<Head :title>, types its props, and takes its copy from the lang files (in a two-language app). - A row shows view · edit · delete, then
⋯. A header shows one action. A form has one submit and no Cancel. - Toasts come only from the flash listener. Deletes go through a confirm that names the record.
- Run
npm run types:checkand look at the change in a browser, in both themes and both languages.
New project
laravel new app # the Vue starter kit: Inertia, Vue, TypeScript, Tailwind, shadcn-vue
cd app
npm install
npm install zod vue-sonner @lucide/vue @vueuse/core # whichever the kit did not already add
composer run dev # server, queue, logs and Vite together
Then, before the first screen:
- Set
components.jsonas in theme.md (rtl: trueif the app has Arabic) before the firstnpx shadcn-vue@latest add, so the registry is written in logical properties from the start. - Copy
assets/app.csstoresources/css/app.css. - Create the day-one files:
inertia-app.ts,app.ts, the two layouts,lib/flash.ts,lib/document.tsandtypes/index.tsfrom blueprint.md, anduseTranslationsanduseAppearancefrom composables.md. - Add the registry parts the house components use:
button badge card table pagination select dropdown-menu alert-dialog dialog sheet popover command field input-group input separator skeleton spinner tabs tooltip sidebar sonner breadcrumb empty. - Build the house components from house-components.md.
The stack, and what it is not
| Concern | Use | Not |
|---|---|---|
| routing | Inertia <Link>, router.visit, Wayfinder helpers |
vue-router, hand-written paths |
| page data | props from the controller | client-side fetching for the page |
| writes | useForm: POST → redirect → GET plus a flash |
a REST client |
| validation | zod before the request, a Form Request after, one error slot | vee-validate |
| components | shadcn-vue (Reka UI) in components/ui/, house wrappers in components/ |
another kit, hand-rolled markup |
| tables | the registry's Table with v-for, filters in the URL |
a client-side row model for a paged list |
| client state | a composable with module-scoped state | Pinia or Vuex by default |
| styling | Tailwind 4 with CSS-variable tokens | literal colours |
| toasts | vue-sonner, fired by one router listener | ad-hoc banners |
| icons | @lucide/vue, named imports |
pasted SVG paths, a second set |
| direction | ConfigProvider :dir in each layout, logical properties |
dir patches per component |
Always-on rules
- Every page sets
<Head :title>(detail pages compute it from props), because the browser tab and history are how people find a page again. - Typed props:
defineProps<{ … }>(), noany. Untyped props hide a renamed key until runtime. - The render string equals the file:
Inertia::render('Customers/Index')→pages/Customers/Index.vue. A mismatch is the most common "page not found". - Never name a page prop after a shared prop. It silently replaces the shared one on that page.
- Never hand-edit
components/ui/. The CLI rewrites it. Wrap it, passclass, or change a token. - Build from the registry. A callout is
Alert, a chipBadge, a ruleSeparator, a loading placeholderSkeleton: a hand-built one misses focus, dark mode, RTL and keyboard handling. - No literal colours. Tokens are redefined under
.dark; a literal is not. - Logical properties, always (
ms-,pe-,start-,text-start), so one markup serves both directions. - Copy from the lang files through
t()in a two-language app, every key in each language, because a literal string never gets translated. - Every URL from a Wayfinder helper, so a renamed route fails the type check instead of 404ing.
- Hide what the viewer may not use with
v-if="can(...)"; the route enforces the same rule, because the template guard is a courtesy, not the boundary. - A status is a
StatusBadgewhose tone the server sends, so one enum decides what a status looks like everywhere. - Toasts come only from the flash listener, so a toast appears only when the server confirmed the outcome.
- Destructive actions go through
ConfirmAction, which names the record, because a bare button deletes on a stray click. - A record is never a
Table.Tableis for a list of records, where columns repeat.
Where each topic lives
| Working on… | Read |
|---|---|
| a new project, the folder map, entry files, layouts, naming | references/blueprint.md |
| a house component's props, slots and template | references/house-components.md |
a composable's signature and code (useListFilters, useClientValidation, …) |
references/composables.md |
| props, shared props, state, types, permissions, routes, navigation | references/data-flow.md |
| a form, validation, field errors, tabbed and per-language forms | references/forms.md |
| an index page, filters, pagination, table cells, empty states | references/lists.md |
| a show page: facts, sections, header actions, deciding verbs | references/records.md |
| a button, a row or header action, a delete, a dialog, a toast, a status badge | references/actions-and-feedback.md |
| a label, help text, an empty state, a confirm, Arabic wording, lang files | references/copy.md |
| what to build a piece of UI from, layout classes, the vendored registry | references/components.md |
a colour, radius, card, the shell, fonts, dark mode, shadcn-vue add |
references/theme.md |
| the stylesheet to copy | assets/app.css |
| something on the wrong side in Arabic, a reversed value, input direction | references/rtl-and-bidi.md |
| an icon, an animation, a new UI library | references/icons-and-motion.md |
Page shape at a glance
AppLayout sidebar (inset) │ header h-16: trigger · breadcrumbs ─────── #page-actions
content: flex flex-1 flex-col gap-4 p-4
Index PageHeader (create) · DataTable (toolbar · table · pagination)
Show PageHeader (one action) · Tabs of DetailCard grids · DangerZone
Create/Edit PageHeader · Card with a field grid · one submit
Auth no shell: AuthLayout's centred card
Before calling it done
npm run types:check # vue-tsc --noEmit
npm run build
npm run format
- Every new surface works in light and dark, at 390px wide, and in each language the app has. Check it in a browser, not by reading the diff.
- After any
shadcn-vue add,git diffthe stylesheet (theme.md). - Logic added to
lib/or a composable gets a Vitest test, if the project tests its front end.