When I Reject v0 Code: Pattern-Matching Rules for Safer UI Generation
Generated UI is a speed boost until it quietly ships broken states, fake accessibility, and client-only assumptions into production.

I use v0 for speed, not for trust. That distinction matters. A generated UI can give you a gorgeous first draft in minutes, but the first draft is not a production interface. It is a pile of guesses about your state model, your accessibility contract, your routing rules, your data shape, and the weird little product decisions that make your side project different from a demo.
The trap is reviewing generated UI like a screenshot. You scan the card radius, the spacing, the gradient, the copy, and the button labels. It feels done because it looks done. Then you connect real data and discover the form submits twice, the empty state hides the only call to action, the destructive action has no disabled state, and the component imported a browser-only API into a server-rendered route.
My rule is simple: reject v0 code when it matches unsafe patterns. Do not argue with the vibe. Do not rewrite the whole thing manually. Build a small review gate that finds the patterns you already know are dangerous, then keep the generated code that survives. That is the indie-hacker version of Don't outsource the thinking™: let the model draft, but make your system decide what is allowed to ship.
Two useful reality checks: Stack Overflow's 2024 Developer Survey reported that 76% of respondents were using or planning to use AI tools in development, and WebAIM's 2025 Million report found detectable WCAG failures on 94.8% of tested home pages. AI-generated UI is entering a web that already struggles with quality, so the review gate has to be mechanical, not vibes-only.
Why does generated UI fail after it looks polished?
Generated UI fails where screenshots stop: runtime state, accessibility semantics, hydration boundaries, real API errors, and product-specific edge cases.
v0 is strongest when the problem is visual composition: arrange this dashboard, make this onboarding flow feel modern, give this pricing page better hierarchy. It is weaker when the problem is contract preservation. The model does not know which fields your API treats as nullable, which button must be idempotent, which route runs as a Server Component, or which empty state appears only for a trial user with no workspace and an expired invite.
That is why I reject by pattern. A rejection pattern is not "this code feels messy." It is a specific signal that has burned real apps: placeholder handlers, fake loading states, array indexes as keys, forms without error rendering, clickable divs, hard-coded sample data in production paths, duplicate IDs, and client-only globals crossing a server boundary. If you want a broader AI QA setup, pair this with our agentic QA framework so code review and browser behavior checks reinforce each other.
The rejection table I use before touching the design
| Generated pattern | Why I reject it | Acceptable replacement |
|---|---|---|
| onClick={() => {}} | Looks interactive but silently drops user intent. | Real handler, disabled state, or explicit TODO blocked from production. |
| div role="button" without keyboard handling | Mouse-only UI breaks keyboard and assistive tech access. | Native button or full keyboard semantics with tests. |
| Hard-coded demo arrays | Masks loading, empty, long, and error states. | Props from real data plus empty and error branches. |
| window or localStorage in Server Components | Hydration and runtime failures appear only after integration. | Client component boundary or effect guarded by environment checks. |
Code example 1: AST gate for rejectable v0 patterns
Start with static rejection. The goal is not to prove the component is correct. The goal is to stop obviously unsafe generated code before you waste attention on pixels. This script parses TSX, reports line numbers, and exits non-zero in CI. It handles edge cases like parse errors, missing files, empty handlers, duplicate IDs, array-index keys, clickable divs, and browser globals in files that are not explicitly client components.
// scripts/reject-v0-patterns.ts
// Run: npx tsx scripts/reject-v0-patterns.ts app components
import fs from 'node:fs/promises'
import path from 'node:path'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
type Finding = { file: string; line: number; rule: string; message: string }
const roots = process.argv.slice(2)
if (roots.length === 0) {
console.error('Usage: npx tsx scripts/reject-v0-patterns.ts <dir...>')
process.exit(2)
}
async function walk(dir: string): Promise<string[]> {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch (error) {
throw new Error(`Cannot read ${dir}: ${(error as Error).message}`)
}
const files = await Promise.all(entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(fullPath)
return /\.(tsx|jsx)$/.test(entry.name) ? [fullPath] : []
}))
return files.flat()
}
function isEmptyFunction(node: any) {
return node?.type === 'ArrowFunctionExpression' &&
node.body?.type === 'BlockStatement' &&
node.body.body.length === 0
}
function attrName(attr: any) {
return attr?.name?.type === 'JSXIdentifier' ? attr.name.name : undefined
}
function getStringAttr(attr: any) {
if (!attr) return undefined
if (attr.value?.type === 'StringLiteral') return attr.value.value
return undefined
}
async function inspect(file: string): Promise<Finding[]> {
const source = await fs.readFile(file, 'utf8')
const findings: Finding[] = []
const ids = new Map<string, number>()
const isClient = source.trimStart().startsWith('"use client"') || source.trimStart().startsWith("'use client'")
let ast
try {
ast = parse(source, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
errorRecovery: false,
})
} catch (error) {
return [{ file, line: 1, rule: 'parse-error', message: (error as Error).message }]
}
traverse(ast, {
JSXOpeningElement(path) {
const name = path.node.name.type === 'JSXIdentifier' ? path.node.name.name : ''
const attrs = path.node.attributes.filter((attr) => attr.type === 'JSXAttribute')
const byName = new Map(attrs.map((attr) => [attrName(attr), attr]))
const id = getStringAttr(byName.get('id'))
if (id) {
if (ids.has(id)) findings.push({ file, line: path.node.loc?.start.line ?? 1, rule: 'duplicate-id', message: `Duplicate id "${id}" also appears on line ${ids.get(id)}` })
ids.set(id, path.node.loc?.start.line ?? 1)
}
const onClick = byName.get('onClick') as any
if (onClick?.value?.expression && isEmptyFunction(onClick.value.expression)) {
findings.push({ file, line: path.node.loc?.start.line ?? 1, rule: 'empty-handler', message: 'Interactive element has an empty onClick handler.' })
}
if (name === 'div' && byName.has('onClick') && !byName.has('role')) {
findings.push({ file, line: path.node.loc?.start.line ?? 1, rule: 'clickable-div', message: 'Use a native button or add role, tabIndex, and keyboard handling.' })
}
const key = byName.get('key') as any
if (key?.value?.expression?.type === 'Identifier' && ['index', 'i'].includes(key.value.expression.name)) {
findings.push({ file, line: path.node.loc?.start.line ?? 1, rule: 'index-key', message: 'Array index keys corrupt state when generated lists reorder or filter.' })
}
},
Identifier(path) {
if (!isClient && ['window', 'document', 'localStorage', 'sessionStorage'].includes(path.node.name)) {
findings.push({ file, line: path.node.loc?.start.line ?? 1, rule: 'server-browser-global', message: `${path.node.name} requires a client boundary or guarded effect.` })
}
},
})
return findings
}
const files = (await Promise.all(roots.map(walk))).flat()
const findings = (await Promise.all(files.map(inspect))).flat()
for (const finding of findings) {
console.error(`${finding.file}:${finding.line} [${finding.rule}] ${finding.message}`)
}
process.exit(findings.length > 0 ? 1 : 0)The important design choice is that every rule is explainable. If the script rejects a component, the developer sees a file, line, rule, and fix direction. That keeps the review from turning into "AI bad" theater. You are rejecting known unsafe shapes.
Code example 2: Require generated components to model real states
Static checks catch suspicious syntax. They do not verify state behavior. For that, force generated UI through a typed state machine. This example is a production-friendly React pattern for a generated project list. It handles loading, empty, API error, malformed data, long names, and destructive action failures without letting the model hide behind perfect mock data.
// components/project-list.tsx
'use client'
import { useEffect, useState } from 'react'
type Project = { id: string; name: string; updatedAt: string }
type LoadState =
| { status: 'loading' }
| { status: 'empty' }
| { status: 'ready'; projects: Project[] }
| { status: 'error'; message: string }
function isProject(value: unknown): value is Project {
const item = value as Project
return Boolean(item && typeof item.id === 'string' && typeof item.name === 'string' && typeof item.updatedAt === 'string')
}
export function ProjectList({ endpoint }: { endpoint: string }) {
const [state, setState] = useState<LoadState>({ status: 'loading' })
const [deletingId, setDeletingId] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
async function load() {
try {
const response = await fetch(endpoint, { signal: controller.signal })
if (!response.ok) throw new Error(`Projects request failed with ${response.status}`)
const json: unknown = await response.json()
if (!Array.isArray(json)) throw new Error('Projects response was not an array')
if (!json.every(isProject)) throw new Error('Projects response contained malformed rows')
setState(json.length === 0 ? { status: 'empty' } : { status: 'ready', projects: json })
} catch (error) {
if ((error as Error).name === 'AbortError') return
setState({ status: 'error', message: (error as Error).message || 'Could not load projects' })
}
}
load()
return () => controller.abort()
}, [endpoint])
async function deleteProject(project: Project) {
if (deletingId) return
setDeletingId(project.id)
try {
const response = await fetch(`${endpoint}/${encodeURIComponent(project.id)}`, { method: 'DELETE' })
if (!response.ok) throw new Error(`Delete failed with ${response.status}`)
setState((current) => current.status !== 'ready'
? current
: current.projects.length === 1
? { status: 'empty' }
: { status: 'ready', projects: current.projects.filter((item) => item.id !== project.id) })
} catch (error) {
alert((error as Error).message || 'Delete failed. Please try again.')
} finally {
setDeletingId(null)
}
}
if (state.status === 'loading') return <p aria-live="polite">Loading projects...</p>
if (state.status === 'error') return <p role="alert">{state.message}</p>
if (state.status === 'empty') return <a href="/#contact">Create your first project</a>
return (
<ul aria-label="Projects">
{state.projects.map((project) => (
<li key={project.id}>
<span title={project.name}>{project.name.length > 60 ? `${project.name.slice(0, 57)}...` : project.name}</span>
<time dateTime={project.updatedAt}>{new Date(project.updatedAt).toLocaleDateString()}</time>
<button disabled={deletingId === project.id} onClick={() => deleteProject(project)}>
{deletingId === project.id ? 'Deleting...' : 'Delete'}
</button>
</li>
))}
</ul>
)
}This is the fastest way to turn a pretty generated component into a reliable one: make impossible states impossible, then render every possible state. The model can still help with layout, but it cannot skip the product contract. For more browser-flow checks, see how to test AI-generated UI.
Can I automate v0 review without killing momentum?
Yes. Use cheap AST gates first, focused browser smoke tests second, and human review last for product intent, copy, layout, and tradeoffs.
Code example 3: Playwright smoke test for generated UI safety
Browser tests catch what static checks cannot: hydration errors, invisible interactive elements, broken focus order, empty accessible names, and layouts that only fail at mobile widths. This Playwright test fails on console errors, tests desktop and mobile, validates the empty and error states, and runs axe accessibility checks. It is intentionally small because side projects need gates that actually run.
// tests/generated-ui.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
const routes = [
{ path: '/projects', name: 'projects index' },
{ path: '/settings', name: 'settings' },
]
for (const route of routes) {
test.describe(route.name, () => {
test.use({ viewport: { width: 390, height: 844 } })
test('has no generated-ui runtime or accessibility failures', async ({ page }) => {
const consoleErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => consoleErrors.push(error.message))
await page.goto(route.path, { waitUntil: 'networkidle' })
const buttons = page.getByRole('button')
const count = await buttons.count()
for (let index = 0; index < count; index += 1) {
const button = buttons.nth(index)
await expect(button, `button ${index} is visible`).toBeVisible()
const name = (await button.textContent())?.trim() || await button.getAttribute('aria-label')
expect(name, `button ${index} needs an accessible name`).toBeTruthy()
}
await page.keyboard.press('Tab')
await expect(page.locator(':focus')).toBeVisible()
const accessibility = await new AxeBuilder({ page })
.disableRules(['color-contrast'])
.analyze()
expect(accessibility.violations).toEqual([])
expect(consoleErrors.filter((error) => !/favicon|ResizeObserver/.test(error))).toEqual([])
})
test('renders empty and server-error states honestly', async ({ page }) => {
await page.route('**/api/projects', async (route) => route.fulfill({ status: 200, json: [] }))
await page.goto('/projects')
await expect(page.getByText(/create your first project/i)).toBeVisible()
await page.route('**/api/projects', async (route) => route.fulfill({ status: 500, body: 'boom' }))
await page.reload()
await expect(page.getByRole('alert')).toContainText(/failed|could not|error/i)
})
})
}Notice the edge cases: it ignores known noisy browser messages, checks accessible names without assuming text-only buttons, and mocks both empty and server-error responses. That is the difference between a demo test and a rejection gate.
Troubleshooting: what to debug when the gate rejects good-looking code
A rejection is not a moral verdict on the generator. It is a debugging prompt. Treat every failure as one of five buckets.
- Parse failures: the generated file may contain markdown fences, duplicate default exports, or syntax from a different framework. Remove wrapper text and rerun the AST gate.
- Hydration failures: search for dates, random IDs, browser globals, and conditionals that render differently on server and client.
- Fake interactivity: reject empty handlers, href="#", disabled buttons with no explanation, and visual buttons that are not native buttons.
- State lies: force loading, empty, error, long-content, and permission-denied paths. Generated UI often optimizes for the single happy screenshot.
- Accessibility drift: inspect roles and names, not just colors. Icon-only controls, custom selects, modals, and tab panels are common failure zones.
The sneakiest gotcha is overfitting the gate to yesterday's bad output. Keep rules small and reviewable. If a rule catches a real production risk twice, automate it. If it only encodes your personal taste, leave it for human review. A good gate protects momentum by removing repeat mistakes, not by turning every generated component into a compliance seminar.
The review loop that keeps side projects moving
My preferred loop is: generate, normalize, gate, integrate, smoke test, then polish. Ask v0 for the shape. Run the AST gate before you read the whole file. Convert mock data into typed props or real API calls. Add the tiny Playwright test for the critical flow. Only then spend taste-budget on spacing, copy, and animation.
This sounds strict until you compare it with the alternative: discovering broken UI after launch, when every fix is mixed with panic, analytics noise, and user screenshots. For solo builders, reliability is leverage. You do not have a QA department waiting behind your launch button. Your rejection rules are the team.
The point is not to become anti-AI. The point is to become AI-Native instead of AI-First. AI-First says "the model made it, ship it." AI-Native says "the model made it, now the system checks it." That is the mature pattern: short semantic distance between idea, generated code, automated review, and browser proof.
Ready to ship your next project faster?
Desplega.ai helps indie hackers and solopreneurs build and ship faster with AI-assisted QA that catches broken flows before users do.
Get StartedFrequently Asked Questions
Should I reject all v0 code by default?
No. Reject patterns, not tools. Keep generated UI when it passes type checks, state tests, accessibility checks, and your product-specific review rules for real flows.
What is the fastest first gate for generated React UI?
Start with AST checks for forbidden imports, missing form handling, unsafe browser globals, duplicate IDs, and placeholder handlers before visual review begins.
Do indie projects need Playwright for v0 output?
Yes for critical flows. A tiny smoke suite catches broken navigation, invisible buttons, hydration issues, and empty states before a launch tweet sends traffic.
How strict should AI UI rejection rules be?
Strict where mistakes are expensive: auth, checkout, settings, deletion, onboarding, and data export. Looser rules are fine for throwaway prototypes and mockups.
Related Posts
Cody's Repository Indexing: Does Cognitive Offloading Create Knowledge Gaps in Large Codebases? | Desplega AI
A practical deep dive into Cody repository indexing, context retrieval, and how indie hackers avoid AI-created knowledge gaps.
Hot Module Replacement: Why Your Dev Server Restarts Are Killing Your Flow State | desplega.ai
Stop losing 2-3 hours daily to dev server restarts. Master HMR configuration in Vite and Next.js to maintain flow state, preserve component state, and boost coding velocity by 80%.
The Flaky Test Tax: Why Your Engineering Team is Secretly Burning Cash | desplega.ai
Discover how flaky tests create a hidden operational tax that costs CTOs millions in wasted compute, developer time, and delayed releases. Calculate your flakiness cost today.