Senior 3 min · April 12, 2026

v0 + shadcn/ui — Empty DataTable on 3G (No Loading States)

Intermittent empty tables on 3G? v0's DataTable has no loading states.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • v0 generates shadcn/ui components from natural language prompts — the output uses Tailwind utilities and CSS variables
  • Always review v0 output: it scaffolds structure but misses accessibility, error states, and edge cases
  • 5 components built: data table, multi-step form wizard, dashboard cards, command palette, notification system
  • Each component follows the same workflow: prompt v0 → review output → add production concerns → integrate with your theme
  • v0 output is starting code, not shipping code — treat it like a senior engineer's first draft
Plain-English First

v0 is Vercel's AI tool that generates React components from text descriptions. It outputs code using shadcn/ui primitives and Tailwind CSS. Think of it as a fast first draft — it gets you 70% of the way there. The remaining 30% is production work: accessibility, error handling, state management, and integration with your design system. This article shows you how to close that gap on 5 real components.

v0 by Vercel generates React components from natural language prompts. The output uses shadcn/ui primitives and Tailwind utilities — which means every component it generates is compatible with your existing design tokens and CSS variables.

The problem: v0 output looks polished but ships broken. Missing aria attributes, no loading states, hardcoded values, no error boundaries. Engineers copy-paste v0 output and deploy it. Three weeks later, they are debugging layout shifts, accessibility failures, and state management bugs that v0 never addressed.

This article builds 5 components end-to-end. Each one starts with a real v0 prompt, reviews the generated output, then adds the production layer: accessibility, error handling, keyboard navigation, and theme integration. The pattern is the same every time — v0 scaffolds, you engineer.

Comparison: v0 Output vs Production-Ready Component (see full table below)

Component 1: Sortable, Filterable Data Table

"Build a sortable, filterable data table using shadcn/ui Table and TanStack Table. Columns: customer (string, sortable), amount (currency), status (badge: paid/pending/overdue), date. Add search filter on customer. Use TypeScript. Include loading, empty, and error states."

Data tables are the most requested v0 component and the most commonly shipped broken. v0 generates a table with sample data, column headers, and basic styling. It does not add loading states, empty states, error handling, or server-side pagination.

After v0 generates the table, you add the production layer: TanStack Table for client-side sorting and filtering, Skeleton components for loading states, an empty state card with a CTA, and row selection with bulk actions.

components/data-table.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
// ============================================
// Production Data Table — v0 scaffold + engineering layer
// ============================================

'use client'

import { useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  SortingState,
  ColumnFiltersState,
  useReactTable,
} from '@tanstack/react-table'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent } from '@/components/ui/card'
import { ArrowUpDown, Search, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'

interface Invoice {
  id: string
  customer: string
  amount: number
  status: 'paid' | 'pending' | 'overdue'
  date: string
}

const columns: ColumnDef<Invoice>[] = [
  {
    accessorKey: 'customer',
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
      >
        Customer
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
    cell: ({ row }) => row.getValue('customer'),
  },
  {
    accessorKey: 'amount',
    header: 'Amount',
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue('amount'))
      return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
    },
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => {
      const status = row.getValue('status') as string
      return (
        <span className={cn('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
          status === 'paid' && 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
          status === 'pending' && 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
          status === 'overdue' && 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
        )}>
          {status}
        </span>
      )
    },
  },
  { accessorKey: 'date', header: 'Date' }
]

function TableSkeleton() {
  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Customer</TableHead>
            <TableHead>Amount</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Date</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {Array.from({ length: 5 }).map((_, i) => (
            <TableRow key={i}>
              <TableCell><Skeleton className="h-4 w-[180px]" /></TableCell>
              <TableCell><Skeleton className="h-4 w-20" /></TableCell>
              <TableCell><Skeleton className="h-4 w-16" /></TableCell>
              <TableCell><Skeleton className="h-4 w-24" /></TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

function EmptyState({ onReset }: { onReset: () => void }) {
  return (
    <Card className="flex flex-col items-center justify-center py-12">
      <CardContent className="text-center">
        <p className="text-muted-foreground">No invoices found</p>
        <Button variant="outline" className="mt-4" onClick={onReset}>Clear filters</Button>
      </CardContent>
    </Card>
  )
}

function ErrorState({ onRetry }: { onRetry: () => void }) {
  return (
    <Card className="flex flex-col items-center justify-center py-12">
      <CardContent className="text-center">
        <p className="text-destructive">Failed to load invoices</p>
        <Button variant="outline" className="mt-4" onClick={onRetry}>
          <RefreshCw className="mr-2 h-4 w-4" />Retry
        </Button>
      </CardContent>
    </Card>
  )
}

interface DataTableProps {
  data: Invoice[]
  isLoading: boolean
  isError: boolean
  onRetry: () => void
}

export function DataTable({ data, isLoading, isError, onRetry }: DataTableProps) {
  const [sorting, setSorting] = useState<SortingState>([])
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])

  const debouncedFilter = useDebouncedCallback((value: string) => {
    table.getColumn('customer')?.setFilterValue(value)
  }, 300)

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: { sorting, columnFilters },
  })

  if (isLoading) return <TableSkeleton />
  if (isError) return <ErrorState onRetry={onRetry} />
  if (data.length === 0) return <EmptyState onReset={() => setColumnFilters([])} />

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2">
        <Search className="h-4 w-4 text-muted-foreground" />
        <Input
          placeholder="Filter by customer..."
          onChange={(e) => debouncedFilter(e.target.value)}
          className="max-w-sm"
        />
      </div>

      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead
                    key={header.id}
                    aria-sort={header.column.getIsSorted() === 'asc' ? 'ascending' : header.column.getIsSorted() === 'desc' ? 'descending' : 'none'}
                  >
                    {flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.map((row) => (
              <TableRow key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  )
}
v0 Data Table Workflow
  • Prompt v0 with your exact data shape, column names, and interaction model — vague prompts produce generic tables
  • After generation, add TanStack Table for sorting/filtering — v0's inline sorting logic does not scale
  • Add Skeleton, EmptyState, and ErrorState components — v0 assumes data is always available
  • Replace sample data with your API response type — verify column accessor keys match object keys
  • Test on throttled network (3G) before shipping — loading and empty states are invisible on fast connections
Production Insight
v0 data tables render empty during API calls. Always add loading/empty/error states, debounced filters, and aria-sort.
Key Takeaway
v0 data tables are 70% complete — add TanStack Table, three states, debounced filters, and aria-sort.

Component 2: Multi-Step Form Wizard

"Build a multi-step onboarding wizard using shadcn/ui. Steps: Personal Info (firstName, lastName, email), Organization (company, role, teamSize), Plan Selection (plan, billingCycle). Use react-hook-form + Zod. Show step indicators with progress. Include a review step before submit."

Form wizards are where v0 output diverges most from production requirements. v0 generates a multi-step form with step indicators, next/back buttons, and basic styling. It does not add per-step validation, step-level error handling, form state persistence across steps, or accessibility for screen readers navigating between steps.

After generation, add per-step zod schemas that validate before advancing, form state persistence, a summary/review step, and aria-live announcements.

components/onboarding-wizard.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
// ============================================
// Multi-Step Form WizardFIXED with useFormContext
// ============================================

'use client'

import { useState } from 'react'
import { useForm, FormProvider, useFormContext } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-react'

// Per-step schemas
const step1Schema = z.object({ firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'), email: z.string().email('Valid email is required') })
const step2Schema = z.object({ company: z.string().min(1, 'Company is required'), role: z.string().min(1, 'Role is required'), teamSize: z.string().min(1, 'Team size is required') })
const step3Schema = z.object({ plan: z.enum(['starter', 'pro', 'enterprise']), billingCycle: z.enum(['monthly', 'annual']) })

const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)

type FormData = z.infer<typeof fullSchema>

const steps = [
  { label: 'Personal', schema: step1Schema },
  { label: 'Organization', schema: step2Schema },
  { label: 'Plan', schema: step3Schema },
  { label: 'Review', schema: z.object({}) }
]

function StepPersonal() {
  const { register, formState: { errors } } = useFormContext<FormData>()
  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-4">
        <div className="space-y-2">
          <Label htmlFor="firstName">First name</Label>
          <Input id="firstName" {...register('firstName')} />
          {errors.firstName && <p className="text-sm text-destructive">{errors.firstName.message}</p>}
        </div>
        <div className="space-y-2">
          <Label htmlFor="lastName">Last name</Label>
          <Input id="lastName" {...register('lastName')} />
          {errors.lastName && <p className="text-sm text-destructive">{errors.lastName.message}</p>}
        </div>
      </div>
      <div className="space-y-2">
        <Label htmlFor="email">Email</Label>
        <Input id="email" type="email" {...register('email')} />
        {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
      </div>
    </div>
  )
}

// StepOrganization, StepPlan, and StepReview follow the same pattern with useFormContext + error display (omitted for brevity but identical structure)

function StepReview({ data }: { data: FormData }) {
  return (
    <div className="space-y-4">
      <h3 className="text-lg font-semibold">Review your information</h3>
      <dl className="grid grid-cols-2 gap-2 text-sm">
        <dt className="text-muted-foreground">Name</dt><dd>{data.firstName} {data.lastName}</dd>
        <dt className="text-muted-foreground">Email</dt><dd>{data.email}</dd>
        <dt className="text-muted-foreground">Company</dt><dd>{data.company}</dd>
        <dt className="text-muted-foreground">Plan</dt><dd>{data.plan} ({data.billingCycle})</dd>
      </dl>
    </div>
  )
}

export function OnboardingWizard() {
  const [step, setStep] = useState(0)
  const methods = useForm<FormData>({
    resolver: zodResolver(fullSchema),
    defaultValues: { firstName: '', lastName: '', email: '', company: '', role: '', teamSize: '', plan: 'starter', billingCycle: 'monthly' }
  })
  const { handleSubmit, trigger, getValues } = methods

  const stepFields = { 0: ['firstName','lastName','email'] as const, 1: ['company','role','teamSize'] as const, 2: ['plan','billingCycle'] as const } as const

  async function nextStep() {
    const isValid = await trigger(stepFields[step as keyof typeof stepFields])
    if (isValid) setStep((s) => Math.min(s + 1, steps.length - 1))
  }

  function prevStep() { setStep((s) => Math.max(s - 1, 0)) }

  const onSubmit = async (data: FormData) => { console.log('Submitting:', data) }

  return (
    <FormProvider {...methods}>
      <Card className="mx-auto max-w-lg">
        <CardHeader>
          <CardTitle>Get started</CardTitle>
          <StepIndicator current={step} total={steps.length} />
        </CardHeader>
        <CardContent>
          <div aria-live="polite" className="sr-only">Step {step + 1} of {steps.length}: {steps[step].label}</div>
          <form onSubmit={handleSubmit(onSubmit)}>
            {step === 0 && <StepPersonal />}
            {step === 1 && <StepOrganization />}
            {step === 2 && <StepPlan />}
            {step === 3 && <StepReview data={getValues()} />}
            <div className="mt-6 flex justify-between">
              <Button type="button" variant="outline" onClick={prevStep} disabled={step === 0}>Back</Button>
              {step < steps.length - 1 ? (
                <Button type="button" onClick={nextStep}>Continue</Button>
              ) : (
                <Button type="submit">Submit</Button>
              )}
            </div>
          </form>
        </CardContent>
      </Card>
    </FormProvider>
  )
}
v0 Form Wizards Skip Per-Step Validation
  • v0 generates a single zod schema for the entire form — it validates on submit, not per-step
  • Users can advance past invalid steps without seeing errors — add trigger() validation before each step transition
  • v0 never generates a review/summary step — add it manually so users can verify before submitting
  • Form state is lost on navigation — persist with react-hook-form's getValues() or a state store
  • Add aria-live announcements when the step changes — screen readers need to know the context shifted
Production Insight
v0 form wizards validate only on final submit. Add per-step validation with Zod + trigger().
Key Takeaway
v0 form wizards are missing per-step validation and review steps. Use useFormContext + trigger() before advancing.

Component 3: Analytics Dashboard Cards

"Create 4 analytics dashboard metric cards using shadcn/ui Card. Show revenue, subscriptions, active users, orders with trend indicators and icons. Make it responsive. Include loading skeleton for each card."

Dashboard cards are v0's strongest output. The generated cards look polished with gradients, icons, and trend indicators. But v0 hardcodes the data.

After generation, add a data-fetching hook, real-time updates, responsive grid, and independent skeletons to prevent CLS.

components/dashboard-cards.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// ============================================
// Analytics Dashboard Cards — v0 scaffold + data layer
// ============================================

'use client'

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { TrendingUp, TrendingDown, Users, DollarSign, ShoppingCart, Activity } from 'lucide-react'

interface MetricCard {
  title: string
  value: string | number
  change: number
  changeLabel: string
  icon: React.ComponentType<{ className?: string }>
}

interface DashboardCardsProps {
  metrics: MetricCard[]
  isLoading: boolean
}

function MetricCard({ metric }: { metric: MetricCard }) {
  const isPositive = metric.change >= 0
  const TrendIcon = isPositive ? TrendingUp : TrendingDown
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">{metric.title}</CardTitle>
        <metric.icon className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{metric.value}</div>
        <p className="mt-1 flex items-center gap-1 text-xs">
          <TrendIcon className={cn('h-3 w-3', isPositive ? 'text-emerald-600' : 'text-red-600')} />
          <span className={cn(isPositive ? 'text-emerald-600' : 'text-red-600')}>{isPositive ? '+' : ''}{metric.change}%</span>
          <span className="text-muted-foreground">{metric.changeLabel}</span>
        </p>
      </CardContent>
    </Card>
  )
}

function MetricCardSkeleton() {
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <Skeleton className="h-4 w-24" />
        <Skeleton className="h-4 w-4" />
      </CardHeader>
      <CardContent>
        <Skeleton className="h-8 w-32" />
        <Skeleton className="mt-2 h-3 w-20" />
      </CardContent>
    </Card>
  )
}

export function DashboardCards({ metrics, isLoading }: DashboardCardsProps) {
  if (isLoading) {
    return (
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
        {Array.from({ length: 4 }).map((_, i) => <MetricCardSkeleton key={i} />)}
      </div>
    )
  }
  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
      {metrics.map((metric) => <MetricCard key={metric.title} metric={metric} />)}
    </div>
  )
}
v0 Dashboard Cards Are Static — Add the Data Layer
  • v0 hardcodes metric values — replace with typed props that accept your API response
  • Add a data-fetching hook (SWR or React Query) with a loading state — each card skeleton loads independently
  • Real-time updates: poll every 30s or use WebSocket — v0 assumes static data
  • Responsive grid: sm:grid-cols-2 lg:grid-cols-4 — v0 often generates a fixed 4-column layout that breaks on mobile
Production Insight
v0 dashboard cards look complete but have no data layer — hardcoded values never update. Add SWR/React Query with independent loading states.
Key Takeaway
v0 dashboard cards are static mockups — add typed props, data fetching hooks, and responsive grid layout.

Component 4: Command Palette

"Build a Cmd+K command palette using shadcn/ui Command and cmdk. Include recent commands, grouped sections (Navigation, Actions), icons, and global keyboard shortcut."

Command palettes (Cmd+K) are a power-user feature that v0 can scaffold. The generated component includes a search input, a results list, and keyboard navigation basics. But v0 misses fuzzy search integration, recent commands persistence, grouped results with section headers, and proper focus management.

components/command-palette.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// ============================================
// Command Palette — v0 scaffold + cmdk integration
// ============================================

'use client'

import { useEffect, useState } from 'react'
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'
import { useRouter } from 'next/navigation'
import { File, Search, Settings, Users, LayoutDashboard, CreditCard } from 'lucide-react'

interface CommandAction {
  id: string
  label: string
  icon: React.ComponentType<{ className?: string }>
  action: () => void
  category: string
  keywords?: string[]
}

const RECENT_KEY = 'command-palette-recent'

function getRecentCommands(): string[] {
  if (typeof window === 'undefined') return []
  try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
}

function addRecentCommand(id: string) {
  const recent = getRecentCommands().filter((r) => r !== id)
  recent.unshift(id)
  localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5)))
}

export function CommandPalette() {
  const [open, setOpen] = useState(false)
  const router = useRouter()

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((prev) => !prev) }
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [])

  const commands: CommandAction[] = [ /* same as previous version */ ]

  function runCommand(command: CommandAction) {
    addRecentCommand(command.id)
    command.action()
    setOpen(false)
  }

  const recentIds = getRecentCommands()
  const recentCommands = commands.filter((c) => recentIds.includes(c.id))

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>
        {recentCommands.length > 0 && (
          <CommandGroup heading="Recent">
            {recentCommands.map((command) => (
              <CommandItem key={command.id} onSelect={() => runCommand(command)}>
                <command.icon className="mr-2 h-4 w-4" />{command.label}
              </CommandItem>
            ))}
          </CommandGroup>
        )}
        {['Navigation', 'Actions'].map((category) => {
          const groupCommands = commands.filter((c) => c.category === category)
          return (
            <CommandGroup key={category} heading={category}>
              {groupCommands.map((command) => (
                <CommandItem key={command.id} keywords={command.keywords} onSelect={() => runCommand(command)}>
                  <command.icon className="mr-2 h-4 w-4" />{command.label}
                </CommandItem>
              ))}
            </CommandGroup>
          )
        })}
      </CommandList>
    </CommandDialog>
  )
}
v0 Command Palettes Need cmdk and Persistence
  • v0 generates a basic search list — use cmdk library for proper keyboard navigation, grouping, and fuzzy matching
  • Recent commands: persist to localStorage, show as a separate group at the top — v0 never adds this
  • Global shortcut (Cmd+K): v0 generates the listener but forgets cleanup — add return statement in useEffect
  • Grouped results with section headers improve scanability — group by Navigation, Actions, Settings
Production Insight
v0 command palettes lack fuzzy search, recent commands, and proper focus management. Use cmdk for the underlying engine.
Key Takeaway
v0 command palettes are basic search lists — add cmdk, recent commands persistence, and grouped results.

Component 5: Notification Toast System

"Create a toast notification system using sonner and shadcn/ui styling. Support success, error, warning, info, action buttons, and promise toasts."

Toast notifications are deceptively complex. v0 generates a single toast component with success/error variants and a dismiss button. It does not generate a toast queue manager that handles stacking and deduplication, auto-dismiss with pause-on-hover, action buttons within toasts, or a toast provider that wraps the app.

components/toast-system.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ============================================
// Notification Toast System — v0 scaffold + sonner
// ============================================

// Provider in app/layout.tsx
import { Toaster } from '@/components/ui/sonner'
// <Toaster position="top-right" richColors closeButton />

// Toast utility in lib/toast.ts
import { toast } from 'sonner'

export const notify = {
  success({ title, description, action, duration = 4000 }: ToastOptions) {
    toast.success(title, { description, duration, action: action ? { label: action.label, onClick: action.onClick } : undefined })
  },
  error({ title, description, action, duration = 6000 }: ToastOptions) {
    toast.error(title, { description, duration, action: action ? { label: action.label, onClick: action.onClick } : undefined })
  },
  // warning, info, promise methods unchanged
}

// Usage example (in any component)
// notify.success({ title: 'Invoice sent', description: 'The invoice was emailed to the customer.', action: { label: 'Undo', onClick: () => undoSend() } })
// notify.promise(saveInvoice(data), { loading: 'Saving invoice...', success: 'Invoice saved successfully', error: (err) => `Failed to save: ${err.message}` })
v0 Toasts Are Single Instances — Use sonner for the Queue
  • v0 generates a single toast component — use sonner for stacking, deduplication, and auto-dismiss
  • Create a notify utility with typed methods (success, error, warning, info) — enforces consistent API
  • Auto-dismiss durations should vary by severity — errors stay longer (6s) than success (4s)
  • Promise-based toasts (notify.promise) show loading → success/error automatically — ideal for API calls
  • Always add a Toaster provider at the app root — v0 generates the toast but forgets the provider
Production Insight
v0 generates a single toast component with no queue management — multiple toasts stack without limits. Use sonner for the toast engine.
Key Takeaway
v0 toasts are single instances — use sonner for stacking, deduplication, and promise-based toasts. Create a notify utility with typed methods.
● Production incidentPOST-MORTEMseverity: high

v0-generated data table deployed without loading states

Symptom
Users reported intermittent empty tables. The issue was not reproducible on fast connections — only on 3G or throttled networks.
Assumption
The team assumed v0's output included loading states because the demo showed a filled table.
Root cause
v0 generates components that assume data is available at render time. The generated DataTable component had no loading skeleton, no empty state, and no error state. When the API call took 3+ seconds, the table rendered an empty tbody with no visual feedback.
Fix
Added three states to the DataTable component: loading (skeleton rows), empty (illustration + CTA), and error (retry button). Each state was a separate shadcn/ui Card wrapping conditional content. The loading skeleton used shadcn/ui's Skeleton component with 5 placeholder rows.
Key lesson
  • v0 generates for the happy path — you must add loading, empty, and error states manually
  • Never deploy v0 output without testing on throttled network conditions
  • Every data-fetching component needs three render states: loading, populated, empty/error
Production debug guideDiagnose issues with v0-generated components in production6 entries
Symptom · 01
Component renders but styles are missing or broken
Fix
Check that shadcn/ui is initialized in your project — run npx shadcn@latest init. Verify globals.css contains the required CSS variables.
Symptom · 02
v0 component uses a primitive not installed
Fix
Run npx shadcn@latest add [component-name] for each missing import. v0 assumes all shadcn/ui primitives are available.
Symptom · 03
Dark mode styles not applying to v0 output
Fix
Verify .dark class toggling on html element. v0 components use CSS variables — if --background, --foreground are not defined in .dark, colors will not switch.
Symptom · 04
Form validation from v0 does not work
Fix
v0 uses react-hook-form + zod for validation. Ensure both are installed and the form schema matches your API contract — v0 generates placeholder schemas.
Symptom · 05
Table sorting or filtering breaks with real data
Fix
v0 generates sample data inline. Replace with your API response shape. Verify column accessor keys match your data object keys — mismatches produce undefined cells.
Symptom · 06
Keyboard navigation missing on v0 dropdowns or modals
Fix
v0 uses Radix UI primitives which have built-in keyboard support. If keyboard nav is broken, check that you are using the correct Radix component — not a custom div with onClick.
★ v0 Component Quick Debug ReferenceFast checks for common v0 + shadcn/ui integration issues
Component import fails at build time
Immediate action
Check that the shadcn/ui component is installed
Commands
ls components/ui/ | grep [component-name]
npx shadcn@latest add [component-name] --overwrite
Fix now
Install the missing shadcn/ui primitive and re-import
Styles look different from v0 preview+
Immediate action
Compare CSS variable values between your globals.css and v0 defaults
Commands
grep -A 30 ':root' app/globals.css
grep -A 30 '.dark' app/globals.css
Fix now
Align your CSS variables with the values v0 used in its preview — or update the component to use your existing tokens
TypeScript errors on v0-generated props+
Immediate action
Check that your data types match the component's expected interface
Commands
npx tsc --noEmit 2>&1 | grep [filename]
cat components/[path] | grep 'interface\|type'
Fix now
Define a shared type that matches your API response and pass it as the component's generic or prop type
Accessibility audit fails on v0 components+
Immediate action
Run axe-core or Lighthouse accessibility audit
Commands
npx @axe-core/cli http://localhost:3000/[page]
npx lighthouse http://localhost:3000/[page] --only-categories=accessibility
Fix now
Add missing aria-labels, roles, and keyboard handlers — v0 output often omits these
v0 Output vs Production-Ready Component
Concernv0 GeneratesYou Must Add
Loading stateNo — assumes data is availableSkeleton components per element
Empty stateNo — renders empty containerIllustration + CTA when data is empty
Error stateNo — crashes silentlyError card with retry button
AccessibilityBasic — missing aria-live, focus managementaria-live for dynamic content, focus trapping
Keyboard navigationPartial — Radix handles basicsCustom shortcuts, focus return on close
ValidationOn submit onlyPer-step validation, real-time feedback
Data fetchingNone — hardcoded sample dataSWR/React Query with typed props
Responsive layoutSometimes — often fixed columnssm:/md:/lg: breakpoints, container queries
Theme integrationPartial — uses shadcn/ui tokensVerify CSS variables match your design system
State persistenceNonelocalStorage for preferences, recent items

Key takeaways

1
v0 generates shadcn/ui components from prompts
the output is 70% complete, not production-ready
2
Every data-fetching component needs three states
loading (skeleton), empty (CTA), error (retry) — v0 generates none
3
Use typed props instead of v0's hardcoded data
define interfaces matching your API contract
4
Add per-step validation to v0 form wizards
v0 validates on submit only
5
Use sonner for toast systems and cmdk for command palettes
v0 generates basic versions without queue management or fuzzy search
6
Run accessibility audits on every v0 component
missing aria-live, focus management, and keyboard navigation are common gaps
7
Test v0 components on throttled network (3G) before shipping

Common mistakes to avoid

5 patterns
×

Deploying v0 output without adding loading/empty/error states

Symptom
Users see blank screens during API calls or when data is empty — no visual feedback.
Fix
Add three render states to every data-fetching component: loading (Skeleton), empty (Card with CTA), error (retry button). Test on throttled network.
×

Using v0's inline data instead of typed props

Symptom
Component works in isolation but breaks when connected to real API — column keys do not match data shape.
Fix
Define a TypeScript interface matching your API response. Pass data as typed props. Replace v0's hardcoded sample data with your interface.
×

Not installing shadcn/ui primitives that v0 imports

Symptom
Build fails with module not found errors for components like Table, Command, Dialog.
Fix
Read all imports in v0 output. Run npx shadcn@latest add [component] for each missing primitive before running the app.
×

Trusting v0's form validation schema as-is

Symptom
Form accepts invalid data — v0 generates placeholder zod schemas that are too permissive.
Fix
Rewrite zod schemas to match your API contract. Add per-step validation for multi-step forms. Test with invalid inputs.
×

Not adding keyboard navigation to v0 dropdowns and modals

Symptom
Power users cannot navigate with keyboard — accessibility audit fails.
Fix
Use cmdk for command palettes, Radix primitives for dropdowns/modals. Both provide keyboard navigation out of the box. Test with Tab, Enter, Escape.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the recommended workflow when using v0 to generate a production ...
Q02SENIOR
Why does v0-generated code fail accessibility audits, and how do you fix...
Q03SENIOR
How do you handle the gap between v0's generated form validation and pro...
Q04SENIOR
What is the difference between v0's output and a production-ready compon...
Q05SENIOR
When should you use v0 versus writing the component from scratch?
Q01 of 05JUNIOR

What is the recommended workflow when using v0 to generate a production component?

ANSWER
Four steps: 1) Write a detailed prompt specifying the data shape, interaction model, and required primitives. 2) Review the generated code for missing states (loading, empty, error), accessibility gaps, and hardcoded values. 3) Add the production layer: typed props, data fetching, validation, keyboard navigation, and theme integration. 4) Test on throttled network and run an accessibility audit before shipping.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use v0 for free?
02
Does v0 work with frameworks other than Next.js?
03
How do I customize the shadcn/ui theme in v0 output?
04
Can v0 generate server components?
05
How do I handle v0 output that uses a shadcn/ui component I have not installed?
🔥

That's React.js. Mark it forged?

3 min read · try the examples if you haven't

Previous
How to Build a Design System with shadcn/ui, Tailwind & Radix
31 / 47 · React.js
Next
Prisma ORM Best Practices with Next.js 16 in 2026