v0 by Vercel security: 6 things to review before pasting the code.
'use client' components importing server-side helpers (which leaks them to the bundle), NEXT_PUBLIC_ on env vars that aren't supposed to be public, missing auth checks on Server Actions, hardcoded credentials in JSX defaults, exposed Server Component data over the wire, and the API route that v0 generated as a placeholder being left as-is in production.
v0's output is good. The components compile, the styles work, the data flows. The trap is that v0 generates code with the assumption you'll review it the way you'd review code from a junior on your team — line by line. Most users paste-and-ship.
Here are the six patterns to look for. Each one has caused a leak in real apps shipping in the last few months.
1. 'use client' components importing server helpers
Next.js boundaries are subtle. A component marked 'use client' ends up in the browser bundle. Anything it imports — directly or transitively — also ends up in the bundle. v0's generated code frequently puts a database client, an auth helper, or an env-var reader at the top of a client component without realising the implication.
Test:
# In your repo, find client components that import server-only code:
grep -rln "'use client'" app/ components/ src/ \
| xargs grep -l 'import .* from .*\(supabase-server\|db\|prisma\|server-only\|process\.env\.\(SUPABASE_SERVICE_\|DATABASE_URL\|API_SECRET\)\)'
Anything matching is a likely leak. The fix:
- Move the dangerous import out of the client component into a Server Component or Server Action.
- Add
import 'server-only'at the top of any module that must never reach the browser. The import will throw at build time if it ever does.
2. NEXT_PUBLIC_ on env vars that shouldn't be public
v0 doesn't know which of your env vars are secrets. If you tell it "wire up Supabase," it will frequently write:
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_KEY;
That single NEXT_PUBLIC_ prefix is the difference between "public anon key" and "your service_role key is now in every bundle." If NEXT_PUBLIC_SUPABASE_KEY contains a JWT with "role":"service_role" in its payload, you have a P0. Read the response guide.
Test:
# Find every NEXT_PUBLIC_ env var:
grep -rn 'NEXT_PUBLIC_' app/ components/ src/ .env*
# Then for each: ask whether it's safe in the browser.
3. Server Actions without auth checks
v0 generates Server Actions for anything you describe as "save", "submit", "delete." The default template often skips the auth check at the top:
'use server';
export async function deletePost(id: string) {
return await db.from('posts').delete().eq('id', id);
}
That action is callable from the browser by anyone — including someone who guessed the function name from the bundle's React fetch traffic. The Server Action enforces nothing.
Fix every Server Action:
'use server';
import { createClient } from '@/lib/supabase-server';
export async function deletePost(id: string) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// Either rely on RLS to scope the delete:
return await supabase.from('posts').delete().eq('id', id);
// or explicitly check ownership:
// .match({ id, user_id: user.id });
}
4. Hardcoded credentials in JSX defaults
v0 likes to give example data so the preview looks alive. The defaults sometimes contain real-looking strings that get copied as-is:
const apiKey = "sk-test-or-real-it-doesn't-matter-it's-now-in-your-repo";
Or shape-of-real-config in default props:
function Config({ apiKey = "AIzaSyD...realLookingFirebaseKey" }) { ... }
Test before merging any v0 paste:
git diff origin/main..HEAD | grep -E '(sk_live|sk-(proj-)?[A-Za-z0-9]{20}|AIza[A-Za-z0-9_-]{35}|ghp_[A-Za-z0-9]{36}|eyJ[A-Za-z0-9_-]+\.eyJ)' || echo 'clean'
5. RSC data leaks via JSON-in-flight
Next.js streams Server Component data to the browser as serialised JSON in the response body. If your Server Component fetches user PII and returns it in a prop — even if the rendered output omits the sensitive fields — the full object is in the wire format.
v0-generated dashboards often make this mistake: fetch user, pass it to a Profile component, render only user.name. But user.email, user.phone, user.role are all in the RSC payload that the browser receives.
Test: open DevTools → Network → filter for a route that renders user data. Look at the response body. Search for fields you didn't expect.
Fix: map down to a minimal DTO before passing to client components:
const userDTO = { id: user.id, name: user.name }; // exclude email, phone, etc.
return <Profile user={userDTO} />;
6. Placeholder API routes left in production
When v0 generates an API route as a stub, it often returns mock data or echoes the request body. The route works, your demo looks good, you ship — and now /api/users is returning [{ id: 1, name: 'Alice', email: '[email protected]' }] hardcoded, which doesn't sound dangerous until you realise /api/admin next to it is also still a stub that returns { ok: true } for any input.
Test: grep your app/api/ directory for routes that return hardcoded data or skip validation:
grep -rn 'return Response\.json\|res\.json' app/api/ \
| grep -v 'await\|body\|params\|req\.'
Anything matching is likely a placeholder. Either implement it properly or delete it.
The deployed-side check
Items 1–4 require looking at your source. Items 5 and 6 we can detect from outside — vibecheck probes for placeholder routes, exposed RSC payloads, and the standard secret patterns. Run it against every preview deploy before merging.
Inspect your v0 deploy