shadcn-vue-inertia
shadcn-vue for Inertia Rails
shadcn-vue patterns adapted for Inertia.js + Rails + Vue 3. NOT Nuxt.
Before using a shadcn-vue example, ask:
- Does it use Nuxt-specific APIs? (
useRouter,useFetch,<NuxtLink>) → Replace with Inertiarouter, server props,<Link> - Does it use
vee-validate+zod? → Replace with Inertia<Form>+nameattributes. Inertia handles CSRF, errors, redirects, processing state.
Key Differences from Nuxt Defaults
| shadcn-vue default (Nuxt) | Inertia equivalent |
|---|---|
useFetch / useAsyncData |
Server-rendered props via controller |
useRouter() (Nuxt) |
router from @inertiajs/vue3 |
<NuxtLink> |
<Link> from @inertiajs/vue3 |
vee-validate + zod |
Inertia <Form> component |
FormField, FormItem, FormMessage |
Plain <Input name="..."> + errors.field |
useHead() (Nuxt) |
<Head> from @inertiajs/vue3 |
NEVER use shadcn-vue's FormField, FormItem, FormLabel, FormMessage components —
they depend on vee-validate's form context internally and will crash without it.
Use plain shadcn-vue Input/Label/Select with name attributes inside Inertia <Form>,
and render errors from the scoped slot's errors object.
Setup
npx shadcn-vue@latest init. Add @/ resolve aliases to tsconfig.json if not present.
Do NOT add @/ resolve aliases to vite.config.ts — vite-plugin-ruby already provides them.
shadcn-vue Inputs in Inertia <Form>
Use plain shadcn-vue Input/Label/Button with name attributes inside Inertia <Form>.
See inertia-rails-forms skill (+ references/vue.md) for full <Form> API.
The key pattern: Replace shadcn-vue's FormField/FormItem/FormMessage with plain
components + manual error display:
<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>
<Select> requires name prop for Inertia <Form> integration:
<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>
Dialog with Inertia Navigation
<script setup lang="ts">
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { router } from '@inertiajs/vue3'
defineProps<{ open: boolean; user: User }>()
</script>
<template>
<Dialog
:open="open"
@update:open="(isOpen) => { if (!isOpen) router.replaceProp('show_dialog', false) }"
>
<DialogContent>
<DialogHeader>
<DialogTitle>{{ user.name }}</DialogTitle>
</DialogHeader>
<!-- content -->
</DialogContent>
</Dialog>
</template>
Table with Server-Side Sorting
<script setup lang="ts">
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { router } from '@inertiajs/vue3'
defineProps<{ users: User[]; sort: string }>()
const handleSort = (column: string) => {
router.get('/users', { sort: column }, { preserveState: true })
}
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead class="cursor-pointer" @click="handleSort('name')">
Name {{ sort === 'name' ? '↑' : '' }}
</TableHead>
<TableHead>Email</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell>{{ user.name }}</TableCell>
<TableCell>{{ user.email }}</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
Use <Link> (not <a>) for row links to preserve SPA navigation.
Toast with Flash Messages
Flash config (flash_keys) is in inertia-rails-controllers. Flash access
(usePage().flash) is in inertia-rails-pages. This section covers toast UI wiring only.
MANDATORY — READ ENTIRE FILE when implementing flash-based toasts with Sonner:
references/flash-toast.md (~80 lines) — full useFlash
composable and Sonner toast provider. Do NOT load if only reading flash values without toast UI.
Dark Mode (No Nuxt color-mode)
npx shadcn-vue@latest init generates CSS variables for light/dark and
@custom-variant dark (&:is(.dark *)); in your CSS (Tailwind v4). No extra
setup needed for the variables themselves.
CRITICAL — prevent flash of wrong theme (FOUC): Add an inline script in
<head> (before Vue hydrates):
<%# app/views/layouts/application.html.erb — in <head>, before any stylesheets %>
<script>
document.documentElement.classList.toggle(
"dark",
localStorage.appearance === "dark" ||
(!("appearance" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
);
</script>
Use a useAppearance composable (light/dark/system modes, localStorage persistence,
matchMedia listener) instead of Nuxt color-mode. Toggle via .dark class on
<html> — no provider needed.
Vue-Specific Gotchas
v-model does NOT work with Inertia <Form> — <Form> reads values from
input name attributes on submit, not from Vue's reactivity system. Using
v-model creates a second source of truth that <Form> ignores:
<!-- BAD — v-model value is ignored by <Form> on submit -->
<Form method="post" action="/users">
<Input v-model="name" />
</Form>
<!-- GOOD — name attribute is what <Form> reads -->
<Form method="post" action="/users">
<Input name="name" />
</Form>
Use v-model only with useForm (where you explicitly manage form.name).
usePage() returns a reactive object — use computed() for derived values:
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const page = usePage()
// BAD — not reactive, won't update when page changes:
// const user = page.props.auth.user
// GOOD — reactive, updates on navigation:
const user = computed(() => page.props.auth.user)
</script>
Without computed(), destructured values freeze at their initial state and
won't update after Inertia navigation.
@update:open vs @close for Dialog — shadcn-vue Dialog emits
update:open, not close. Using @close silently does nothing:
<!-- BAD — @close is not emitted by shadcn-vue Dialog -->
<Dialog @close="handleClose">
<!-- GOOD — @update:open fires on open AND close -->
<Dialog :open="open" @update:open="(isOpen) => { if (!isOpen) handleClose() }">
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
FormField/FormMessage crash |
Using shadcn-vue form components that depend on vee-validate | Replace with plain Input/Label + errors.field display |
Select value not submitted |
Missing name prop |
Add name="field" to <Select> |
| Dialog closes unexpectedly | Missing or wrong @update:open handler |
Use @update:open="(open) => { if (!open) closeHandler() }" |
| Flash of wrong theme (FOUC) | Missing inline <script> in <head> |
Add dark mode script before stylesheets |
v-model value not submitted |
<Form> reads name attrs, not Vue reactive state |
Use name attribute; reserve v-model for useForm only |
| Shared props stale after navigation | Destructured usePage() without computed() |
Wrap derived values in computed(() => ...) |
Related Skills
- Form component →
inertia-rails-forms+references/vue.md(<Form>scoped slot, useForm) - Flash config →
inertia-rails-controllers(flash_keys initializer) - Flash access →
inertia-rails-pages+references/vue.md(usePage().flash) - URL-driven dialogs →
inertia-rails-pages+references/vue.md(router.get pattern)
References
Load references/components.md (~200 lines) when building
shadcn-vue components beyond those shown above (Accordion, Sheet, Tabs, DropdownMenu,
AlertDialog with Inertia patterns).
Do NOT load components.md for basic Form, Select, Dialog, or Table usage —
the examples above are sufficient.