shadcn-vue-laravel
shadcn-vue for Laravel Inertia
Use this skill as the translation layer from generic shadcn-vue examples to a Laravel + Inertia + Vue 3 codebase. Optimize for Laravel starter-kit conventions: Blade root view, @inertiajs/vue3, TypeScript, Tailwind, and frontend code under resources/js.
For broader Inertia application behavior such as useHttp, layout props, optimistic updates, deferred props, prefetch policy, router events, and Laravel adapter testing, use the companion skill $laravel-inertia.
Quick Translation Rules
- Nuxt data APIs such as
useFetchanduseAsyncData: replace with controller props, deferred props, partial reloads, or explicit Inertia visits. useRouter()from Nuxt or Vue Router: replace withrouterfrom@inertiajs/vue3.<NuxtLink>or<RouterLink>: replace with<Link>from@inertiajs/vue3.useHead(): replace with<Head>from@inertiajs/vue3.src/...,app/frontend/..., or Rails frontend paths: translate to the existing Laravel structure, usuallyresources/js/....- shadcn-vue form examples built around
vee-validate: do not copy them blindly into Inertia<Form>flows.
Setup
- Start from a working Laravel + Inertia + Vue application.
- Run
npx shadcn-vue@latest init. - Keep generated components under
resources/js/components/uiunless the project already uses a different component root. - Ensure
@/*resolves to./resources/js/*intsconfig.json. - If the project uses
@imports andvite.config.tsdoes not already define the alias, add one that points toresources/js. - Preserve the page and layout casing already used by the project. Do not rename
Pagestopages, or the reverse, just to match an example. - Keep Inertia boot files ESM-only. Do not introduce
require()intoapp.ts, SSR entrypoints, or Vite config.
If you need the Vite alias, use:
import path from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
},
},
})
If the app adopts @inertiajs/vite in v3, let that plugin own page resolution and dev SSR plumbing instead of copying older manual bootstrap patterns back into the project.
Choose <Form> vs useForm
- Use Inertia
<Form>for straightforward server-driven forms where inputs can submit throughnameattributes. - Use
useFormwhen the component owns reactive state, conditional fields, client-side transforms, file uploads, or progressive validation. v-modelis correct withuseForm.v-modelalone is not the source of truth for an Inertia<Form>; the submitted payload comes from inputnameattributes.- Do not use shadcn-vue
FormField,FormItem,FormLabel, orFormMessageinside an Inertia<Form>unless you intentionally wirevee-validatefor that page. They are not part of the default Laravel + Inertia path. - In v3,
useForm().processingremainstrueuntilonFinish, so disable buttons and pending UI fromprocessinginstead of resetting eagerly.
Default <Form> Pattern
Use plain shadcn-vue inputs plus manual error rendering:
<script setup lang="ts">
import { Form } from '@inertiajs/vue3'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
</script>
<template>
<Form method="post" action="/users">
<template #default="{ errors, processing }">
<div class="space-y-4">
<div>
<Label for="name">Name</Label>
<Input id="name" name="name" />
<p v-if="errors.name" class="text-sm text-destructive">{{ errors.name }}</p>
</div>
<div>
<Label for="email">Email</Label>
<Input id="email" name="email" type="email" />
<p v-if="errors.email" class="text-sm text-destructive">{{ errors.email }}</p>
</div>
<Button type="submit" :disabled="processing">
{{ processing ? 'Creating...' : 'Create User' }}
</Button>
</div>
</template>
</Form>
</template>
Inside <Form>, Select must carry a name prop:
<template>
<Select name="role" default-value="member">
<SelectTrigger><SelectValue placeholder="Select role" /></SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
</template>
Default useForm Pattern
Switch to useForm when local reactive state is the point:
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
const form = useForm({
name: '',
email: '',
role: 'member',
})
const submit = () => form.post('/users')
</script>
<template>
<form class="space-y-4" @submit.prevent="submit">
<div>
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" />
<p v-if="form.errors.name" class="text-sm text-destructive">{{ form.errors.name }}</p>
</div>
<div>
<Label for="email">Email</Label>
<Input id="email" v-model="form.email" type="email" />
<p v-if="form.errors.email" class="text-sm text-destructive">{{ form.errors.email }}</p>
</div>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Creating...' : 'Create User' }}
</Button>
</form>
</template>
Shadcn-Relevant v3 Helper
useFormContext() lets deep child components read the parent <Form> state. Use it for reusable shadcn wrappers, sticky action bars, field summaries, or nested submit controls without prop drilling.
For broader Inertia primitives such as Precognition, cache invalidation, router error events, deferred props, and prefetch strategy, load $laravel-inertia instead of expanding this skill.
Example child component using useFormContext():
<script setup lang="ts">
import { useFormContext } from '@inertiajs/vue3'
import { Button } from '@/components/ui/button'
const form = useFormContext()
</script>
<template>
<div v-if="form" class="flex items-center gap-2">
<span v-if="form.isDirty" class="text-sm text-muted-foreground">Unsaved changes</span>
<Button type="button" :disabled="form.processing" @click="form.submit()">
Save
</Button>
</div>
</template>
Component Gotchas
- Use
<Link>instead of raw<a>for internal navigation that should preserve Inertia behavior. - For table sorting, filtering, tabs, and dialogs driven by URL state, use
router.get()orrouter.reload()withpreserveStateandpreserveScrollwhen appropriate. usePage()is reactive; wrap derived values incomputed(() => page.props...)instead of destructuring once.- shadcn-vue
Dialogemitsupdate:open, notclose. - Translate Rails layout snippets to
resources/views/app.blade.php. - If a component pattern depends on global router listeners or visit callbacks, verify the exact event names in
$laravel-inertiabefore copying older snippets.
Flash Toasts
Prefer the Inertia v3 flash-data flow: server-side Inertia::flash(...), then page.flash, per-visit onFlash, or router.on('flash', ...) on the client. Use router.flash(...) for client-only ephemeral UI state when no server round trip is needed. Treat props.flash through HandleInertiaRequests as a compatibility path for older code, not the default for new work.
Read references/flash-toast.md when implementing centralized Sonner toasts or a reusable flash composable. Do not load it for pages that only render one flash value inline.
Dark Mode
npx shadcn-vue@latest init gives you the CSS variables. The Laravel-specific part is preventing FOUC in resources/views/app.blade.php before @vite(...) runs:
<script>
document.documentElement.classList.toggle(
'dark',
localStorage.appearance === 'dark' ||
(!('appearance' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches),
);
</script>
Use a small useAppearance composable and toggle the .dark class on <html>. Do not pull in Nuxt color-mode or Rails-specific theme helpers.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
FormField or FormMessage crash |
shadcn-vue form components were copied without vee-validate context |
Use plain Input / Label plus Inertia error rendering, or intentionally wire vee-validate |
Select value is missing on submit |
<Select> inside <Form> has no name prop |
Add name="field" |
v-model value never reaches the server |
<Form> is reading DOM field names, not your local refs |
Use name attributes or switch to useForm |
| Shared props look stale after navigation | A usePage() value was destructured once |
Wrap derived values in computed() |
| Dialog close handler never fires | @close was used instead of @update:open |
Handle @update:open="(open) => { if (!open) close() }" |
| Global error listener never runs | v2 event names were copied into a v3 app | Replace invalid with httpException and exception with networkError |
| Dark mode flashes on first paint | Theme script runs after Vite assets | Put the script in resources/views/app.blade.php before @vite(...) |
References
- Load
references/components.mdwhen you need extended patterns such as AlertDialog, Sheet, Tabs, DropdownMenu, Pagination, Search, Date Picker, or Breadcrumbs. - Load
references/flash-toast.mdwhen you need centralized flash-toast wiring with Sonner.