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.
URL-based XSS in links and navigation
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:
HttpOnlycookies for session tokensSameSitecookies to reduce cross-site abuseX-Content-Type-Options: nosniffReferrer-PolicyPermissions-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
httpandhttpsunless 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.