SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Jun 11, 2026·Security·SecureStartKit Team

Render User HTML in Next.js: 5 XSS Boundaries [2026]

Rendering user HTML or Markdown in Next.js? A strict CSP isn't enough. The 5 render boundaries where XSS slips through, and the server-side fix.

Summarize with AI

On this page

  • Table of contents
  • Why isn't a strict CSP enough to stop XSS?
  • Boundary 1: how does dangerouslySetInnerHTML expose XSS?
  • Boundary 2: when does react-markdown become unsafe?
  • Boundary 3: why should you never render user-submitted MDX?
  • Boundary 4: how do javascript: and data: URLs slip through?
  • Boundary 5: why do uploaded SVGs execute scripts?
  • What is the server-side sanitization pattern for Next.js?
  • Render the network layer and the render layer together

On this page

  • Table of contents
  • Why isn't a strict CSP enough to stop XSS?
  • Boundary 1: how does dangerouslySetInnerHTML expose XSS?
  • Boundary 2: when does react-markdown become unsafe?
  • Boundary 3: why should you never render user-submitted MDX?
  • Boundary 4: how do javascript: and data: URLs slip through?
  • Boundary 5: why do uploaded SVGs execute scripts?
  • What is the server-side sanitization pattern for Next.js?
  • Render the network layer and the render layer together

To render user-generated HTML or Markdown in Next.js without opening a cross-site scripting hole, sanitize it on the server, against an allowlist, before it reaches the DOM. A Content Security Policy hardens the browser, but it is a mitigation layered on top, not a substitute. Five render boundaries leak script the moment that sanitization step is skipped.

This post owns the layer that the three-layer CSRF, XSS, and SQL injection guide deliberately defers to. That guide builds the network-layer defense: a strict, nonce-based Content Security Policy applied through the proxy. CSP decides what the browser will execute. Sanitization decides what reaches the DOM in the first place. You need both, because each catches what the other misses.

Everything below assumes the content is user-controlled: a profile bio, a comment, a Markdown note, an uploaded avatar. First-party content you wrote yourself is a different trust model, and the fix section explains why that distinction is the whole game. The danger starts the moment a string the user typed gets rendered as markup.

TL;DR:

  • Sanitize on the server, at the render boundary. A strict CSP hardens the browser, but cross-site scripting is fundamentally a failure to neutralize user input before it reaches the page [7]. The sanitization is the prevention; the CSP is the safety net.
  • React escapes text by default. Rendering {userInput} as element content is safe. The five boundaries that are not: dangerouslySetInnerHTML, react-markdown with rehype-raw, user-submitted MDX, javascript: and data: URLs, and inline-served SVG uploads.
  • DOMPurify is the server-side workhorse. Run it with jsdom in Node, and use rehype-sanitize as the allowlist in any Markdown pipeline [2][4].
  • Store raw, sanitize on output. An allowlist that tightens next month then protects the content you already saved last month.

Table of contents

  • Why isn't a strict CSP enough to stop XSS?
  • Boundary 1: how does dangerouslySetInnerHTML expose XSS?
  • Boundary 2: when does react-markdown become unsafe?
  • Boundary 3: why should you never render user-submitted MDX?
  • Boundary 4: how do javascript: and data: URLs slip through?
  • Boundary 5: why do uploaded SVGs execute scripts?
  • What is the server-side sanitization pattern for Next.js?
  • Render the network layer and the render layer together

Why isn't a strict CSP enough to stop XSS?

A strict CSP is a powerful mitigation, but it is not a substitute for sanitization, because it only holds under two conditions that frequently break. The first: the policy has to actually be strict. The second: it has to govern the document doing the rendering. When either condition fails, the only thing standing between user input and script execution is whether you sanitized at the render boundary.

A nonce-based Content Security Policy with script-src 'self' 'nonce-X' 'strict-dynamic' and no unsafe-inline does real work. It blocks injected <script> tags that carry no nonce, and it blocks inline event-handler attributes. If you have shipped that policy on every route and never loosened it, an injected <script> tag is dead on arrival. That is a genuine defense, and the security headers guide walks the exact header set.

Here is where the two conditions bite. Condition one fails through erosion. Static and ISR routes cannot use per-request nonces, so they fall back to hash-based policies. A third-party widget needs unsafe-inline to load, and someone adds it to script-src for one route. A CSP with unsafe-inline in script-src provides close to zero XSS protection, and the regression is invisible until an audit catches it. Condition two fails by design. When a user uploads an SVG and your app serves it inline from storage, the browser renders that file as its own document with its own response headers. Your application's page CSP never applies to it. The uploaded file carries whatever CSP its storage origin sends, which is usually none.

CWE-79 frames the root cause precisely: the weakness is that "the product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users" [7]. Neutralization is sanitization. It happens in your code, at the point of render, and it does not depend on a header being present and strict three deploys from now. Treat CSP as the second line and sanitization as the first.

Boundary 1: how does dangerouslySetInnerHTML expose XSS?

dangerouslySetInnerHTML is the single most direct XSS boundary in a React app, because it bypasses the automatic escaping that makes everything else safe. React's own documentation is blunt: "Unless the markup is coming from a completely trusted source, it is trivial to introduce an XSS vulnerability this way" [1]. The prop exists precisely to opt out of the protection.

Normal JSX is safe. When you write <div>{userBio}</div>, React escapes the string, so a payload like <img src=x onerror=alert(1)> renders as inert text. The instant you switch to the raw-HTML prop, that guarantee is gone:

// Unsafe: a stored bio rendered straight into the DOM
export function Bio({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}

If html came from a database row a user controls, any markup they saved executes in the next visitor's session. The fix is not to avoid the prop entirely (sometimes you genuinely need to render rich text), it is to make sure the string was sanitized on the server first. DOMPurify is the standard tool. It describes itself as "a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML and SVG" and "works with a secure default" [2]:

import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'

// DOMPurify needs a DOM. On the server, jsdom provides one.
const { window } = new JSDOM('')
const DOMPurify = createDOMPurify(window)

export function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true } })
}

DOMPurify needs a DOM to operate. In the browser it uses the real one; on the server it does not exist, so you provide it with jsdom. The DOMPurify docs are explicit that "jsdom is the tool of choice and we strongly recommend to use the latest version" [2]. Run sanitizeHtml in a Server Component or a Server Action so the parsing stays on the server, then pass the cleaned string to the prop. React's guidance reinforces the placement: build the __html object "as close to where the HTML is generated as possible" so it is obvious which values are raw markup [1].

Boundary 2: when does react-markdown become unsafe?

react-markdown is safe until you add rehype-raw without rehype-sanitize, at which point embedded HTML in the Markdown renders verbatim. The library's documentation states plainly that "use of react-markdown is secure by default" [3]. It renders Markdown to React elements and escapes any raw HTML the author typed, so a comment containing <script>alert(1)</script> shows up as literal text.

The trouble starts when a feature request arrives: "let users embed a bit of HTML in their Markdown." The usual answer is to reach for rehype-raw, which parses that embedded HTML back into real nodes. On its own, that re-opens the XSS surface the default closed. The library points at the fix directly: "To make sure the content is completely safe, even after what plugins do, use rehype-sanitize. It lets you define your own schema of what is and isn't allowed" [3].

import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'

// Unsafe: rehype-raw revives embedded HTML with nothing to filter it
<Markdown rehypePlugins={[rehypeRaw]}>{userMarkdown}</Markdown>

// Safe: sanitize AFTER raw, so the allowlist has the final say
<Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{userMarkdown}</Markdown>

Plugin order matters. rehype-raw has to run first to turn the raw HTML into nodes, then rehype-sanitize strips anything outside its allowlist. rehype-sanitize "drops anything that isn't explicitly allowed by a schema (defaulting to how github.com works)" [4]. That GitHub-shaped default is a sensible starting point: it permits the formatting tags real comments use and removes scripts, event handlers, and dangerous attributes. If you never needed embedded HTML in the first place, the safest move is to drop rehype-raw and let react-markdown stay secure by default.

Boundary 3: why should you never render user-submitted MDX?

Rendering user-submitted MDX is not an XSS bug, it is arbitrary code execution, because MDX compiles to JavaScript before it runs. MDX is not Markdown with some HTML allowances. It is a format that compiles to a React component, which means an MDX source can import modules, call functions, and embed JSX expressions. Feeding user input to an MDX compiler hands the user your runtime.

This is a sharper line than the previous two boundaries. With dangerouslySetInnerHTML or rehype-raw, the attacker is limited to what HTML and inline handlers can express, and a CSP can still constrain them. With MDX, the input is compiled as code, so there is no "sanitize the output" step that makes it safe. The only correct posture is: never compile MDX you did not write.

// Dangerous if `source` is user-controlled: MDX is compiled and run as code
import { MDXRemote } from 'next-mdx-remote/rsc'

<MDXRemote source={userSubmittedMdx} />

This is exactly the distinction that keeps SecureStartKit's own blog safe. The blog renders MDX through next-mdx-remote with a fixed component allowlist and only the GitHub-flavored Markdown remark plugin, and every file it compiles lives in the repository under content/blog. That is the trusted-author trust model: the only person who can write a post is someone with commit access. The render-layer XSS surface this article is about does not apply, because the input is not user-controlled. The moment you want users to author content, do not extend MDX to them. Switch to react-markdown with rehype-sanitize (Boundary 2), which renders user Markdown as data, not as code.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

Boundary 4: how do javascript: and data: URLs slip through?

A javascript: or data: URL in a user-controlled href or src executes script when clicked or loaded, and React's built-in guard only covers part of that surface. React 16.9 began warning on javascript: URLs because, in its own words, they "are a dangerous attack surface because it's easy to accidentally include unsanitized output in a tag like <a href> and create a security hole," and a later major version throws an error on them [5].

That guard is real but narrow. It applies to URLs you pass through JSX attributes. It does nothing for a URL you inject through dangerouslySetInnerHTML, nothing for a link that a Markdown renderer builds from user text, and nothing for data: URLs, which can carry an entire HTML document. So a link a user saved as javascript:fetch('/api/keys').then(...), rendered through any of the raw-HTML boundaries above, runs on click.

The defense is an explicit scheme allowlist, applied on the server, before the URL ever reaches an attribute:

const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])

export function safeUrl(raw: string): string | null {
  try {
    const url = new URL(raw, 'https://placeholder.invalid')
    return ALLOWED_PROTOCOLS.has(url.protocol) ? raw : null
  } catch {
    return null
  }
}

Validate the scheme the same way you validate every other input: as part of the Zod schema on the Server Action that accepts the URL, with a refine that rejects anything outside the allowlist. If the URL flows through a Markdown renderer instead, react-markdown exposes a urlTransform hook for exactly this, and its docs warn that "overwriting urlTransform to something insecure will open you up to XSS vectors" [3]. Allowlist the protocol; never try to denylist javascript: by string match, because encodings and whitespace tricks defeat denylists.

Boundary 5: why do uploaded SVGs execute scripts?

An uploaded SVG executes scripts because SVG is an XML document that the browser parses with the same engine that runs HTML, and the format permits a <script> element. MDN is explicit: the SVG <script> element "allows to add scripts to an SVG document" and "is equivalent to the HTML script element" [6]. An SVG can also carry event-handler attributes like onload, anchor elements with javascript: URLs, and a foreignObject that embeds arbitrary HTML.

The vector opens when your app accepts an SVG as an avatar, a logo, or an attachment, and later serves that file inline from an origin that holds the user's session cookies. The browser treats the response as a live document, runs its script in your origin, and the upload becomes stored XSS against every viewer. This is also where CSP fails its second condition from earlier: the file is served as its own document, so your page's policy never governs it.

There are three durable fixes, and they stack:

  • Serve user files as attachments. Send Content-Disposition: attachment so the browser downloads rather than renders the SVG. If users do not need to preview SVGs inline, this alone closes the boundary.
  • Sanitize before storing or serving. DOMPurify sanitizes SVG as well as HTML [2], so run uploaded SVGs through its SVG profile and keep the cleaned output:
const cleanSvg = DOMPurify.sanitize(uploadedSvg, {
  USE_PROFILES: { svg: true, svgFilters: true },
})
  • Isolate the file origin. Serve user uploads from a separate domain or bucket with no session cookies and a restrictive CSP, so even a script that slips through has nothing to steal.

The upload itself needs the same validate-type-and-size discipline every file gets, which the secure file uploads guide covers end to end. The render-layer point here is narrower and easy to miss: an image format can be an executable document, and "it's just an image" is how the SVG slips past the upload check.

What is the server-side sanitization pattern for Next.js?

The pattern is one server-side sanitization boundary, built on an allowlist, applied on output rather than only on input. Centralize it so every render path runs the same code, and there is no second place to forget. In a Next.js App Router app, that boundary lives in a server module the Server Components and Server Actions both import.

Two design choices make the difference between a fix that holds and one that rots:

  • Allowlist, never denylist. DOMPurify and rehype-sanitize both work by permitting a known-safe set and dropping everything else [2][4]. Denylists ("strip <script>") lose to the next encoding trick. The GitHub-shaped default schema is a strong baseline; widen it deliberately, tag by tag, only when a real feature needs it.
  • Sanitize on output, store the raw input. Save what the user submitted, and sanitize each time you render it. When DOMPurify ships a fix for a new bypass, or you tighten your schema, the content you saved months ago is protected automatically. Sanitizing only on write freezes old rows at the rules that existed the day they were saved.

A single helper covers the HTML path, and the Markdown path uses the plugin pipeline from Boundary 2:

import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'

const DOMPurify = createDOMPurify(new JSDOM('').window)

// One boundary, imported everywhere user HTML is rendered.
export function renderSafeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    USE_PROFILES: { html: true },
    // Widen consciously; start from the secure default.
  })
}

This mirrors the way SecureStartKit treats every other input: validation and authorization run on the server, in code the request cannot bypass, never in the browser. The blog renderer earns its safety not by sanitizing harder but by never accepting untrusted authors in the first place (Boundary 3). When your product does accept untrusted authors, the sanitization boundary is what stands in for that trust. It belongs in the same architectural tier as the Zod validation on your Server Actions and maps to the injection categories in the OWASP Top 10 walkthrough.

Render the network layer and the render layer together

XSS prevention in Next.js is two nets, not one. The network layer is a strict, nonce-based Content Security Policy that decides what the browser is allowed to execute. The render layer is server-side sanitization that decides what enters the DOM at all. The CSP catches the injected script you missed; the sanitization catches the case where the CSP eroded to unsafe-inline or never governed a standalone-served file. Ship both, and the five boundaries above stop being holes.

Concretely: route every piece of user HTML through one DOMPurify boundary with jsdom on the server, render user Markdown with react-markdown plus rehype-sanitize, never compile user MDX, allowlist URL schemes, and serve uploaded SVGs as attachments from an isolated origin. Then layer the CSP on top through the proxy. For the policy itself and the rest of the injection surface, the complete CSRF, XSS, and SQL injection guide is the deeper reference, and SecureStartKit ships these defaults so the boundary exists before your first user types anything. See what the template includes if you would rather inherit the architecture than wire it from scratch.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Common components (e.g. div), React Reference— react.dev
  2. DOMPurify, a DOM-only XSS sanitizer for HTML, MathML and SVG— github.com
  3. react-markdown, Markdown component for React— github.com
  4. rehype-sanitize, plugin to sanitize HTML— github.com
  5. React v16.9.0 and the Roadmap Update (Deprecating javascript: URLs)— legacy.reactjs.org
  6. script, SVG element reference, MDN— developer.mozilla.org
  7. CWE-79: Improper Neutralization of Input During Web Page Generation— cwe.mitre.org

Related Posts

May 15, 2026·Security

Next.js CSRF, XSS, SQLi: The 3-Layer Defense [2026]

CSRF, XSS, and SQL injection prevention in Next.js. Three architectural defenses tied to OWASP A05:2025 and the 2026 Next.js injection CVEs.

Jun 13, 2026·Security

Rotate Leaked API Keys Without Downtime [2026]

Rotating a leaked API key the wrong way logs out every user or breaks your webhooks. The zero-downtime runbook for Supabase, Stripe, and Resend keys.

Jun 12, 2026·Security

Next.js Errors That Fail Open: The OWASP A10 Fix [2026]

A caught redirect() or a swallowed auth check makes Next.js fail open. Where error handling grants access by accident, and the fail-closed fix.