Styling Dynamic Content from a CMS Safely
Content management systems make it easy for editors to publish formatted content, but the HTML they produce can be unpredictable and unsafe. This article explains practical approaches to keep your design consistent while protecting users and your app.
Why this is hard
CMS-produced HTML may include unexpected tags, inline styles, class names that conflict with your CSS, broken markup, or even malicious scripts. Without controls, editorial freedom can break layout, bypass design tokens, or create XSS vulnerabilities.
Core principles
- Sanitize: Strip or transform unsafe markup and attributes before rendering.
- Scope: Limit how CMS content affects global styles and vice versa.
- Structure: Prefer structured, component-driven content rather than raw HTML when possible.
- Policy: Enforce Content Security Policy (CSP) and upload/URL validation.
- Test & Monitor: Validate style regressions and security through CI and runtime checks.
Sanitization and whitelisting
Never insert raw HTML into the DOM without sanitizing. Use a well-known library on the server or at render-time to apply a strict allowlist of tags and attributes. Common options:
- DOMPurify (browser / Node)
- sanitize-html (Node)
- Bleach (Python)
Example (DOMPurify):
// Using DOMPurify to remove scripts and unexpected attributes
var clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['p','br','strong','em','a','ul','ol','li','img','h2','h3','h4','table','thead','tbody','tr','th','td'],
ALLOWED_ATTR: ['href','src','alt','title','width','height','target','rel']
});
document.querySelector('.cms-content').innerHTML = clean;
Notes:
- Disallow inline event handlers (onclick) and style attributes if you want to centralize styling.
- Always remove <script> and <style> tags coming from unsanitized editors.
Prefer structured content over raw HTML
Where possible, model content as structured blocks (rich text blocks, portable text, or custom JSON) and map blocks to your components. This eliminates most surprises and makes it easy to render content with consistent design and behavior.
Example pattern:
- CMS stores blocks like {type: 'paragraph', text: '...'} or {type: 'image', src: '...'}.
- Your renderer maps each type to a safe, styled component.
CSS scoping and isolation
Even sanitized markup can introduce classes and elements that collide with your styles. Use one or more of these options to keep CMS content visually contained:
- Wrapper selector: Prefix all CMS-specific styles with a container class (e.g., .cms-content). This is the simplest approach.
- CSS resets inside the container: Reset margins, font sizes, and inherit design tokens to reduce surprises.
- Shadow DOM or iframe: Full isolation via Shadow DOM or an iframe prevents style leakage entirely. Use this for untrusted or highly variable content, but be mindful of accessibility and sizing.
- Render components: If you use structured content, the components control markup and CSS, removing the need to style arbitrary user markup.
Example CSS scoping with a reset and token application:
/* Scope styles to CMS content and normalize basics */
.cms-content { --type-scale-1: 1rem; --type-scale-2: 1.125rem; color: #0f1724; font-family: system-ui, sans-serif; }
.cms-content * { box-sizing: border-box; }
.cms-content h2, .cms-content h3, .cms-content h4 { margin: 1rem 0 0.5rem; }
.cms-content p { margin: 0 0 1rem; font-size: var(--type-scale-1); }
.cms-content img { max-width: 100%; height: auto; display: block; margin: 0.5rem 0; }
.cms-content table { width: 100%; border-collapse: collapse; }
.cms-content table th, .cms-content table td { border: 1px solid #e6eef8; padding: 0.5rem; }
If you need nearly perfect isolation, Shadow DOM example (sanitize first):
// Create a shadow root and inject safe HTML and styles
const host = document.getElementById('cms-host');
const shadow = host.attachShadow({ mode: 'open' });
const styles = `
:host { font-family: system-ui, sans-serif; color: #111; }
p { margin: 0 0 1rem; }
`;
shadow.innerHTML = `<style>${styles}</style><div class="cms-inner">${clean}

