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:
vue<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:
vue<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
vue<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
vue<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):
erb<%# 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:
vue<!-- 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:
vue<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:
vue<!-- 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.

