Blog · ← All posts
v0.dev XSS Patterns: What AI-Generated Next.js Gets Wrong
Last updated: October 2026 · 7 min read
v0.dev is exceptional at generating Next.js UI, and remarkably consistent at generating the same XSS patterns. React's JSX escaping protects you by default, so XSS in v0 output almost always means the model deliberately reached past the guardrails. Here are the five patterns to hunt in every v0.dev paste, with fixes.
One thing to understand before the patterns: v0.dev optimizes for visual fidelity. When you ask for a rich-text preview, a Markdown renderer, or an embed widget, the fastest path to "looks right" is the escape hatch, and the model takes it without weighing the security cost. The prompt said what it should look like. Nothing in the prompt said where the content comes from or who controls it. That missing threat model is the root cause behind all five patterns below.
Pattern 1: dangerouslySetInnerHTML on dynamic content
The classic. "Render this rich text," "display the Markdown," "make the preview show HTML", the model answers with the one prop React named as a warning.
// VULNERABLE
<div dangerouslySetInnerHTML={{ __html: post.content }} />
// FIXED — sanitize server-side or at render
import DOMPurify from 'isomorphic-dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.content) }} />
Scanner mapping: the XSS check class flags untrusted values rendered as HTML. On Pro, taint tracking proves the data flow, e.g. a searchParams value reaching the render with no sanitizer in between.
Pattern 2: ref.innerHTML in effects
Same vulnerability, laundered through a ref, harder to spot in review because it doesn't use the scary prop name.
// VULNERABLE
useEffect(() => { ref.current.innerHTML = widget.html; }, [widget]);
// FIXED
useEffect(() => {
ref.current.innerHTML = DOMPurify.sanitize(widget.html);
}, [widget]);
Scanner mapping: the XSS class covers DOM-sink variants, and the DOM clobbering advanced detector (Pro) catches the nastier cousin, attacker-controlled markup that rewrites the DOM's own structure.
Pattern 3: Server Components echoing searchParams
v0.dev generates Server Components that read searchParams directly. Most echoes are safe (JSX escapes), but the moment the value flows into an attribute context the model constructed, an href, an inline event string, a JSON blob in a script tag, escaping rules change.
// VULNERABLE — javascript: URL in an href the model built
<a href={link}>Visit</a> // link = searchParams.get('next')
// FIXED — allow-list the scheme
const safe = link?.startsWith('https://') ? link : '/';
<a href={safe}>Visit</a>
Scanner mapping: taint tracking (Pro) follows searchParams through Server Component code to each sink, flagging the flows that reach dangerous contexts.
Pattern 4: eval / new Function in client components
"Make this configurable" prompts produce client-side eval more often than you'd think. In a "use client" component it's XSS-adjacent code execution in every visitor's browser.
// VULNERABLE
const compute = new Function('x', formula);
// FIXED — parse or map, never compile
const OPS = { double: x => x * 2, half: x => x / 2 };
const compute = OPS[formulaName] ?? (x => x);
Scanner mapping: the eval usage and code injection check classes flag every string-to-code sink.
Pattern 5: JSON in <script> tags without escaping
Server Components often serialize data into the page: <script>window.__DATA__ = {JSON.stringify(data)}</script>. If data contains user content with </script>, the page breaks out of the script context, a stored XSS hiding in plain sight.
// VULNERABLE
<script>window.__DATA__ = {JSON.stringify(data)}</script>
// FIXED — escape the closing tag
<script>window.__DATA__ = {JSON.stringify(data).replace(/</g, '\\u003c')}</script>
Scanner mapping: the XSS class plus taint tracking (Pro) trace user-controlled values into serialized script contexts.
A worked example: reviewing a v0 hero component
Patterns are abstract until you see them in a file. Here's a condensed walkthrough of the kind of component v0.dev produces for a prompt like "build a hero section with a testimonial carousel and a newsletter signup," and where the five patterns hide in it.
First pass: search the file for the escape hatches. dangerouslySetInnerHTML appears once, rendering the testimonial text. The testimonials come from a CMS fetch, which means user-influenced content flowing into an unsanitized sink. That's Pattern 1, and it's the stored XSS that matters most in this file: one malicious testimonial compromises every visitor.
Second pass: follow the props. The newsletter form takes an email state value and, on submit, builds a redirect URL with string concatenation: router.push('/thanks?email=' + email). The thanks page reads searchParams and renders it into the page. JSX escaping covers the default render, but the thanks page also sets it as an href on a "resend confirmation" link. That's Pattern 3: a javascript: URL waiting for someone to type it into the signup form.
Third pass: the carousel. The model implemented autoplay with setTimeout("next(" + index + ")", 3000), a string timer. The index is internal state, not user input, so the exploitability is low. But it's Pattern 4's shape, and the fix is one line: an arrow function. Leave it and the next developer copies the pattern somewhere the input isn't internal.
Fourth pass: the page serializes the CMS data into a script tag for hydration: window.__DATA__ = {JSON.stringify(data)}, no escaping. That's Pattern 5. A testimonial containing </script> breaks out of the script context. The CMS is the attack vector again; the fix is the \u003c replacement.
Four findings in one component, from one prompt, in about fifteen minutes of reading. None of them broke the preview. All of them would have shipped. That's the v0.dev security story in miniature: beautiful output, invisible attack surface, and a review that costs a quarter of an hour per component. Do it before deploy, not after the report.
The v0.dev XSS checklist
- ☐ Search for
dangerouslySetInnerHTML, every instance needs a sanitizer - ☐ Search for
.innerHTML/.outerHTML, same treatment - ☐ Trace
searchParamsintohref,src, and script contexts - ☐ No
eval/new Functionin client components - ☐ Serialized JSON in scripts escapes
< - ☐ CSP header set on the Vercel deployment (defense in depth)
Paste your v0.dev components into Vibe-Code Audit before you deploy. The free scan runs the XSS, eval, and code-injection checks; Pro adds taint tracking that proves which inputs actually reach the sinks, $49 once, not per month.
FAQ
Isn't React safe from XSS by default?
JSX escaping handles the default case. XSS in React apps comes from the escape hatches, dangerouslySetInnerHTML, innerHTML refs, javascript: URLs, unescaped script serialization. v0.dev reaches for those hatches whenever a prompt asks for dynamic HTML.
Can the scanner tell a safe use from an unsafe one?
Taint tracking (Pro) is exactly that distinction: it follows data flow from untrusted sources to sinks and only flags flows with no sanitizer in between. A dangerouslySetInnerHTML fed by a constant string won't rank as a proven risk.
What about the deployed site?
Enter your Vercel URL for the live-site audit: security-header grading (including CSP), .env/.git exposure probes, and a same-domain broken-link crawl.
Does scanning work for TypeScript/TSX?
Yes, paste TSX directly. The code checks handle TypeScript-flavored JavaScript.
Why v0 keeps generating these patterns
v0 is optimized for one thing: beautiful, working UI from a prompt, fast. Its training data is the public web's React and Next.js code — which is full of dangerouslySetInnerHTML, because that's how you render rich text from a CMS, and full of client-side eval patterns, because that's how demos wire up dynamic behavior. The model reproduces the most likely code, not the safest code, and in frontend code the most likely code is often the vulnerable code.
The second cause is how v0 apps get finished. You generate the landing page, then ask it to add a testimonials section fed by an API, and the model reaches for the fastest rendering path: interpolate the API response into JSX via dangerouslySetInnerHTML. It works on the first render. The XSS arrives with the first malicious API response — which, for user-generated content, is a matter of time.
A fix-once workflow for v0 projects
Don't fix these one component at a time. v0 projects share a small set of rendering helpers, so fix the helpers and the patterns disappear everywhere. Create one sanitized rich-text component that wraps a proper HTML sanitizer and use it everywhere user content renders as HTML. Ban raw dangerouslySetInnerHTML with a lint rule or a simple grep in CI so it can't creep back in.
For eval-like sinks, the fix is architectural: v0 sometimes generates dynamic behavior by building code strings. Replace those with data-driven rendering — maps over config objects instead of constructing functions from strings. It's more code up front and dramatically less attack surface.
Then make the scanner part of the loop. v0 lets you iterate fast; pair each iteration with a scan of the changed files. The five patterns above are exactly what a good code scanner flags, each with a paste-back fix prompt — so the fix for every finding goes right back into v0 as the next prompt.