v0 + shadcn/ui — Empty DataTable on 3G (No Loading States)
Intermittent empty tables on 3G? v0's DataTable has no loading states.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
This masterclass is a deep-dive into production-grade UI patterns built with v0 (Vercel's AI-powered component generator) and shadcn/ui, the de facto React component library for modern web apps. It exists because most tutorials show isolated, pretty demos that fall apart under real-world constraints—slow networks, missing loading states, empty data, and accessibility gaps.
Here, you'll learn to build five critical, reusable components—a sortable/filterable data table, a multi-step form wizard, analytics dashboard cards, a command palette, and a notification toast system—each hardened for 3G connections and edge cases like empty datasets. The article targets senior engineers who already know React and shadcn/ui basics but need battle-tested patterns for latency-sensitive, data-heavy interfaces.
It's not for beginners or projects where a simple <table> or a library like React Table suffices; it's for when you must own every millisecond of perceived performance and every pixel of empty-state UX.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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)
Why v0 + shadcn/ui Masterclass Exists
This masterclass dissects a specific failure mode: rendering an empty DataTable on a 3G connection without any loading state. It's not a generic UI tutorial — it's a deep dive into the gap between local dev (sub-10ms responses) and real-world latency (300ms+ round trips). The core mechanic is that shadcn/ui's DataTable, built on TanStack Table, renders synchronously. If your data fetch is async and you don't gate the render, the table appears empty for hundreds of milliseconds, then flashes full. Users perceive a broken page, not a loading one.
In practice, this means your table's columns and data props must be null/undefined until the fetch resolves. Without a loading skeleton or spinner, the empty state is indistinguishable from a zero-results query. The key property: shadcn/ui components are stateless presentational wrappers — they don't manage async lifecycle. You must implement the loading gate yourself, typically with a useQuery hook from TanStack Query or a simple isLoading boolean.
Use this pattern whenever your DataTable depends on a network call, especially on slow or unreliable connections. It matters because users on 3G or poor Wi-Fi will see a blank table for 1-3 seconds, then a sudden data dump — a jarring UX that erodes trust. The fix is trivial (a conditional skeleton), but omitting it is a top-3 complaint in production React dashboards.
isLoading is false — always show a skeleton or spinner first.Component 1: Sortable, Filterable Data Table
Real v0 prompt used:
"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.
// ============================================ // 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> ) }
- 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
Component 2: Multi-Step Form Wizard
Real v0 prompt used:
"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.
// ============================================ // Multi-Step Form Wizard — FIXED 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 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
trigger().trigger() before advancing.Component 3: Analytics Dashboard Cards
Real v0 prompt used:
"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.
// ============================================ // 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 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
Component 4: Command Palette
Real v0 prompt used:
"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.
// ============================================ // 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 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
Component 5: Notification Toast System
Real v0 prompt used:
"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.
// ============================================ // 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 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
Why Your Prototype Still Feels Like a Toy — Theme Orchestration
Three v0 prompts give you three different button colors. That's not a system, that's a mess. Production apps need a single source of truth for theme tokens, and shadcn/ui gives you CSS variables, not hardcoded Tailwind classes. The mistake most devs make: overriding className on every component instead of feeding tokens through the global provider.
You declare `--primary: 222.2 47.4% 11.2% in globals.css. Then every Button, Card, and Badge picks it up automatically. Want dark mode? Swap the :root and .dark` blocks. One file, zero per-component edits.
The real win? When the designer changes "that blue" after launch, you don't grep forty files. You change one number. Theme orchestration is the difference between a demo and a deployable product.
// io.thecodeforge — javascript tutorial // globals.css — your single source of truth :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --muted: 210 40% 96.1%; --border: 214.3 31.8% 91.4%; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; } // Tailwind config — map them to utilities module.exports = { theme: { extend: { colors: { primary: 'hsl(var(--primary))', muted: 'hsl(var(--muted))', border: 'hsl(var(--border))', } } } }
@apply for theme tokens inside component files. It breaks the cascade when you later try to theme switch. CSS variables are the contract. @apply is the implementation detail. Keep them separate.The Gap Between Prototype and Production: Error Boundaries
Your v0-generated dashboard crashes silently when the API returns null for a chart value. The user sees a blank screen. The PM asks if "it's done." You've already lost trust.
Shadcn/ui components are just React components. They don't wrap anything in error boundaries for you. That's your job. A single ErrorBoundary wrapper around your page layout catches render-time exceptions and displays a <Card> with a retry button instead of a white death screen.
Why this matters: shadcn/ui's DataTable uses @tanstack/react-table internally. If your data source shape changes — and it will, because backends lie — the table throws, not handles. Wrap your table in an error boundary, log the root cause to Sentry or your observability tool, and show a user-facing fallback. Don't let a null sink your whole viewport.
// io.thecodeforge — javascript tutorial import React from 'react'; import { Card, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, info) { console.error('Render crash:', error, info); // Push to your observability tool here — don't swallow it } render() { if (this.state.hasError) { return ( <Card className="mx-auto mt-8 max-w-md"> <CardHeader> <CardTitle>Something broke</CardTitle> <CardDescription> The component hit an unexpected state. </CardDescription> </CardHeader> <CardFooter> <Button onClick={() => this.setState({ hasError: false })}> Retry </Button> </CardFooter> </Card> ); } return this.props.children; } } export default ErrorBoundary;
Event Bus vs Prop Drilling: Cross-Component Communication
Your command palette needs to tell the notification toast component "User changed settings, show success." The prop drilling path: pass a callback from App -> Layout -> CommandPalette -> CommandItem. That's four levels of indirection for one string message. It's fragile, verbose, and the next dev will hate you.
Use an event bus. One singleton, zero prop drilling. Publish from the palette, subscribe in the toast system. v0 can't generate that for you — it doesn't understand your app's topology — but you can retrofit it in under twenty lines.
The alternative? Context API. But context re-renders every consumer on every change. An event bus only re-renders the components that actually care. When your app grows beyond three routes, prop drilling becomes technical debt. Event buses are the spackle that fills the gaps until you reach for Zustand or Jotai.
// io.thecodeforge — javascript tutorial // event-bus.js — one singleton to rule them all const listeners = {}; export const EventBus = { publish(event, data) { if (!listeners[event]) return; listeners[event].forEach(cb => cb(data)); }, subscribe(event, callback) { if (!listeners[event]) { listeners[event] = []; } listeners[event].push(callback); // return unsubscribe function return () => { listeners[event] = listeners[event].filter(cb => cb !== callback); }; } }; // Usage in CommandPalette import { EventBus } from './event-bus'; // ... when command executed EventBus.publish('settings:changed', { key: 'theme', value: 'dark' }); // Usage in ToastSystem import { EventBus } from './event-bus'; useEffect(() => { const unsub = EventBus.subscribe('settings:changed', (payload) => { toast({ title: `${payload.key} updated to ${payload.value}` }); }); return () => unsub(); }, []);
v0-generated data table deployed without loading states
- 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
ls components/ui/ | grep [component-name]npx shadcn@latest add [component-name] --overwritegrep -A 30 ':root' app/globals.cssgrep -A 30 '.dark' app/globals.cssnpx tsc --noEmit 2>&1 | grep [filename]cat components/[path] | grep 'interface\|type'npx @axe-core/cli http://localhost:3000/[page]npx lighthouse http://localhost:3000/[page] --only-categories=accessibility| Concern | v0 Generates | You Must Add |
|---|---|---|
| Loading state | No — assumes data is available | Skeleton components per element |
| Empty state | No — renders empty container | Illustration + CTA when data is empty |
| Error state | No — crashes silently | Error card with retry button |
| Accessibility | Basic — missing aria-live, focus management | aria-live for dynamic content, focus trapping |
| Keyboard navigation | Partial — Radix handles basics | Custom shortcuts, focus return on close |
| Validation | On submit only | Per-step validation, real-time feedback |
| Data fetching | None — hardcoded sample data | SWR/React Query with typed props |
| Responsive layout | Sometimes — often fixed columns | sm:/md:/lg: breakpoints, container queries |
| Theme integration | Partial — uses shadcn/ui tokens | Verify CSS variables match your design system |
| State persistence | None | localStorage for preferences, recent items |
| File | Command / Code | Purpose |
|---|---|---|
| components | 'use client' | Component 1 |
| components | 'use client' | Component 2 |
| components | 'use client' | Component 3 |
| components | 'use client' | Component 4 |
| components | export const notify = { | Component 5 |
| ThemeTokens.js | :root { | Why Your Prototype Still Feels Like a Toy |
| ErrorBoundaryFallback.js | class ErrorBoundary extends React.Component { | The Gap Between Prototype and Production |
| EventBus.js | const listeners = {}; | Event Bus vs Prop Drilling |
Key takeaways
Common mistakes to avoid
5 patternsDeploying v0 output without adding loading/empty/error states
Using v0's inline data instead of typed props
Not installing shadcn/ui primitives that v0 imports
Trusting v0's form validation schema as-is
Not adding keyboard navigation to v0 dropdowns and modals
Interview Questions on This Topic
What is the recommended workflow when using v0 to generate a production component?
Why does v0-generated code fail accessibility audits, and how do you fix it?
How do you handle the gap between v0's generated form validation and production requirements?
trigger() validation per step, and test with intentionally invalid inputs.What is the difference between v0's output and a production-ready component?
When should you use v0 versus writing the component from scratch?
Frequently Asked Questions
v0 has a free tier with ~200 credits per month (roughly 20-30 full component generations as of 2025). The paid plan increases the generation limit and adds project-level context. For production use, the free tier is sufficient for prototyping.
v0 generates React components using shadcn/ui and Tailwind CSS. The output works with any React framework (Next.js, Remix, Vite + React). However, v0 defaults to Next.js conventions (app router, server components). For other frameworks, you may need to adjust imports.
v0 uses your project's existing CSS variables. Update the CSS variables in globals.css (under :root and .dark), and v0-generated components will automatically use your theme. Do not override v0's Tailwind classes — update the CSS variables instead.
Yes. v0 can generate React Server Components when you specify in the prompt. However, most interactive components require 'use client'.
Read the import statements in v0's output. Run npx shadcn@latest add [component-name] for each missing primitive.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
4 min read · try the examples if you haven't