Cross-site scripting in React and Next.js is one of those topics that sounds simpler than it really is. A lot of developers hear “React escapes output by default” and mentally file XSS away as mostly solved. That’s true right up until you touch dangerouslySetInnerHTML, render CMS content, build a markdown feature, pass untrusted values into URLs, or mix server and client rendering in ways that make assumptions drift.

The good news: React and Next.js give you a strong baseline. The bad news: they do not make you XSS-proof automatically.

This tutorial walks through how XSS actually shows up in React and Next.js apps, what React protects you from, where the sharp edges still are, and how to build safe patterns you can reuse.

Why XSS still matters in React apps

XSS happens when untrusted input gets interpreted as code in the browser instead of being treated as data. In practical terms, that usually means an attacker finds a way to inject HTML or JavaScript that runs in another user’s session.

Typical impact includes:

  • stealing session tokens
  • making authenticated requests as the victim
  • reading sensitive page data
  • changing UI to phish credentials
  • abusing admin panels

React helps because it escapes text content by default. If you do this:

export default function Profile({ bio }) {
  return <p>{bio}</p>;
}

and bio contains:

<script>alert('xss')</script>

React renders it as text, not executable markup. That default behavior prevents a huge class of DOM-based and reflected XSS bugs.

But “default safe” is not the same as “always safe.”

What React escapes by default

React treats interpolated values inside JSX as text unless you explicitly tell it otherwise.

This is safe:

function Comment({ message }) {
  return <div>{message}</div>;
}

This is also generally safe for attributes:

function Avatar({ alt }) {
  return <img src="/avatar.png" alt={alt} />;
}

React escapes special characters so user-controlled strings do not become raw HTML.

That said, React’s escaping does not protect you when:

  • you render raw HTML
  • you generate unsafe URLs
  • you pass attacker-controlled data into dangerous browser APIs
  • you build HTML strings outside React and insert them manually
  • you rely on third-party components that do any of the above

Those are the places to focus.

The biggest XSS footgun: dangerouslySetInnerHTML

If you remember one thing from this tutorial, make it this: dangerouslySetInnerHTML is exactly as dangerous as the name suggests.

Example:

function PostBody({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

If html comes from a user, a CMS, markdown conversion, or even an internal admin tool, you now have a potential XSS sink.

An attacker might submit:

<img src=x onerror=alert('xss')>

or:

<a href="javascript:alert('xss')">click me</a>

If that content is inserted without sanitization, the browser interprets it as markup and script-bearing attributes may execute.

The right fix: sanitize before rendering

If you need to render rich text, sanitize it first using a well-maintained HTML sanitizer such as DOMPurify.

Client-side example:

import DOMPurify from 'dompurify';

function SafeHtml({ html }) {
  const clean = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Better yet, sanitize on the server before storing or before rendering, so you are not trusting every client to do the right thing.

In a Next.js server component or route handler, you might sanitize before passing content down:

import DOMPurify from 'isomorphic-dompurify';

export default async function Page() {
  const post = await getPost();
  const cleanHtml = DOMPurify.sanitize(post.html);

  return <article dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}

The key idea is simple: if you must render HTML, sanitize it using a whitelist-based sanitizer. Do not try to strip <script> tags with regex. That approach fails in creative and embarrassing ways.

Markdown is not automatically safe

A very common React and Next.js feature is markdown rendering for blogs, docs, comments, or CMS content. Developers often assume markdown is harmless because it looks plain-text-ish. It isn’t.

Depending on the parser and plugins you use, markdown can allow raw HTML or produce dangerous links.

Unsafe pattern:

const html = markdownToHtml(userMarkdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;

Safer approach:

  • disable raw HTML in markdown if possible
  • sanitize the generated HTML
  • validate generated links and image URLs

If your markdown toolchain supports it, prefer configurations that treat raw HTML as text instead of rendering it.

Another easy mistake is assuming that all string props are equally safe. They are not. Rendering text is different from using a string as a URL.

Example:

function WebsiteLink({ url }) {
  return <a href={url}>Visit site</a>;
}

If url is attacker-controlled, they may supply:

javascript:alert('xss')

Clicking that link executes script.

Validate allowed URL schemes

Only allow schemes you explicitly trust, usually http: and https:.

function isSafeUrl(value) {
  try {
    const url = new URL(value, 'https://example.com');
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
}

function WebsiteLink({ url }) {
  if (!isSafeUrl(url)) {
    return <span>Invalid link</span>;
  }

  return <a href={url}>Visit site</a>;
}

This matters in React and Next.js because URL props are everywhere: links, redirects, image sources, iframe sources, router pushes, and custom navigation components.

Be careful with Next.js router navigation

If you do something like this:

router.push(userControlledValue);

validate that value first. Open redirects and scriptable URLs are often cousins of XSS problems, especially when untrusted input crosses trust boundaries.

Inline event handlers and manual DOM manipulation

React’s declarative event system is generally safer than building HTML strings, but you can still create trouble if you step outside it.

Bad pattern:

useEffect(() => {
  const el = document.getElementById('output');
  el.innerHTML = userContent;
}, [userContent]);

That completely bypasses React’s escaping protections.

Safer pattern:

function Output({ content }) {
  return <div>{content}</div>;
}

Or if you truly need HTML:

import DOMPurify from 'dompurify';

function Output({ content }) {
  const clean = DOMPurify.sanitize(content);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

As a rule, if you see innerHTML, outerHTML, insertAdjacentHTML, or direct DOM writes in a React app, stop and review carefully.

Server components, SSR, and hydration gotchas

Next.js adds a server-rendering layer, which is great for performance and SEO, but it can also hide where dangerous content enters the page.

The important thing to understand is this: server-rendered HTML is still HTML. If untrusted data is turned into unsafe markup on the server, React does not magically rescue you during hydration.

Unsafe server-side rendering example:

export default async function Page() {
  const profile = await getProfile();

  return (
    <div dangerouslySetInnerHTML={{ __html: profile.aboutHtml }} />
  );
}

If aboutHtml is tainted, the server sends dangerous markup directly to the browser.

The fix is exactly the same as on the client: sanitize before rendering.

Also be cautious when injecting serialized data into scripts. Next.js handles a lot of this safely for you, but custom document templates, custom script injection, or hand-rolled state hydration code can reintroduce classic XSS.

Bad idea:

<script>
  window.__DATA__ = {"name": "</script><script>alert(1)</script>"}
</script>

If you are ever manually serializing data into HTML, use safe serialization tools and avoid embedding raw user data in executable script blocks.

Secure patterns for React and Next.js apps

Here’s the opinionated version: most teams should create a tiny set of approved rendering utilities and ban ad hoc HTML rendering.

Pattern 1: render plain text by default

function SafeText({ children }) {
  return <>{children}</>;
}

This sounds trivial, but the mindset matters. Most content should stay plain text.

Pattern 2: centralize sanitized HTML rendering

import DOMPurify from 'isomorphic-dompurify';

export function SanitizedHtml({ html }) {
  const clean = DOMPurify.sanitize(html, {
    USE_PROFILES: { html: true }
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Now your codebase has one obvious place to audit.

Pattern 3: centralize URL validation

export function safeHref(value) {
  try {
    const url = new URL(value, 'https://example.com');
    if (url.protocol === 'http:' || url.protocol === 'https:') {
      return url.toString();
    }
  } catch {}

  return null;
}

Use it consistently anywhere user input can become a navigable URL.

Add browser-level defenses too

Framework-level escaping is not enough. You also want defense in depth from HTTP headers and browser policies.

The most important one is Content Security Policy, or CSP. A good CSP can significantly reduce the impact of XSS by blocking inline scripts and restricting where scripts can load from.

At a high level, aim for policies that:

  • disallow inline scripts where possible
  • use nonces or hashes for approved scripts
  • restrict script sources to trusted origins
  • restrict object/embed usage
  • control framing if needed

In Next.js, you can set security headers in middleware, next.config.js, or your deployment platform.

You should also consider:

  • HttpOnly cookies for session tokens
  • SameSite cookies to reduce cross-site abuse
  • X-Content-Type-Options: nosniff
  • Referrer-Policy
  • Permissions-Policy

Scan your site for XSS vulnerabilities and other security issues at headertest.com - free, instant, no signup required.

Common mistakes I see in real projects

“It’s internal, so it’s trusted”

Internal tools get compromised too. Admin panels are actually high-value XSS targets because they expose privileged sessions.

“The CMS sanitizes content already”

Maybe. Maybe not. Or maybe only in one field. Verify exactly what it sanitizes, with what configuration, and whether that configuration matches your threat model.

“We only allow basic formatting”

Unless you enforce that with a sanitizer or structured editor model, you probably allow more than you think.

“React escapes everything”

No, React escapes text insertions. That is not the same thing as securing all output contexts.

A practical checklist

If you want a usable checklist for React and Next.js XSS prevention, use this:

Safe by default

  • Render user input as text, not HTML
  • Avoid manual DOM manipulation

If rendering HTML

  • Sanitize with DOMPurify or equivalent
  • Do it server-side when possible
  • Use one shared component for sanitized HTML

For URLs

  • Validate scheme and format
  • Allow only http and https unless there is a strong reason otherwise

For markdown and CMS content

  • Disable raw HTML if possible
  • Sanitize generated HTML
  • Review plugins and renderer configuration

For Next.js and SSR

  • Never trust server-rendered HTML just because it came from your backend
  • Avoid custom unsafe script serialization
  • Review server components and route handlers for HTML sinks

Defense in depth

  • Deploy a solid CSP
  • Set secure headers
  • Protect session cookies with HttpOnly

Final take

React and Next.js give you a much better starting point than old-school jQuery apps ever did. That’s real progress. But the dangerous parts of XSS haven’t disappeared; they’ve just moved into a smaller number of high-risk patterns.

If you avoid raw HTML by default, sanitize aggressively when you do need it, validate URLs, and add a real CSP, you’ll eliminate the vast majority of XSS risk in a React or Next.js codebase.

My blunt advice: treat every dangerouslySetInnerHTML usage as a security review event. Most teams do not need many of them, and every single one deserves scrutiny. That habit alone will save you from a lot of painful bugs.