When working with React and Next.js, we occasionally face situations that require direct HTML insertion into our components. The infamous dangerouslySetInnerHTML
attribute exists for these edge cases—but as its name suggests, using it improperly can expose your application to security vulnerabilities. This article explores how to leverage this feature safely in production environments.
What is dangerouslySetInnerHTML and Why is it Dangerous?
dangerouslySetInnerHTML
is React's replacement for setting innerHTML
in the browser DOM. This escape hatch allows you to insert HTML directly:
function Component() {
return <div dangerouslySetInnerHTML={{ __html: '<p>Some HTML</p>' }} />;
}
But why the scary name? Because it opens the door to Cross-Site Scripting (XSS) attacks. When you use this attribute, you're effectively telling React to bypass its built-in protection mechanisms that normally prevent injection attacks.
Anti-Patterns: What NOT to Do
Before covering best practices, let's look at some dangerous patterns you should avoid.
Anti-Pattern 1: Directly Rendering User Input
// ❌ DANGEROUS - Never do this!
function CommentDisplay({ userComment }) {
return <div dangerouslySetInnerHTML={{ __html: userComment }} />;
}
This example renders user-provided content directly in the DOM, making your application vulnerable to XSS attacks. A malicious user could inject JavaScript like <script>alert('Hacked!')</script>
or worse.
Anti-Pattern 2: Inadequate Sanitization
// ❌ INADEQUATE - Don't rely on simple replacements
function ArticleContent({ content }) {
// This approach is insufficient and easily bypassed
const sanitized = content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
Simple string replacement methods are insufficient for proper sanitization. Attackers can easily bypass these using techniques like encoding variations or other tag types.
Best Practices for Production Systems
Now let's look at secure, production-ready implementations.
1. Use Established Sanitization Libraries
DOMPurify is the industry standard for client-side HTML sanitization:
// ✅ RECOMMENDED
import DOMPurify from 'dompurify';
function SafeHTML({ content }) {
const sanitizedContent = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel']
});
return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}
This approach:
- Uses a battle-tested sanitization library
- Explicitly defines allowed tags and attributes
- Provides multiple layers of protection
2. Server-Side Sanitization for Next.js Applications
For Next.js applications, consider sanitizing on the server side for enhanced security:
// In a Next.js API route or getServerSideProps
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
export async function getServerSideProps(context) {
const { content } = await fetchContentFromDatabase();
// Server-side DOMPurify setup
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'a', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title']
});
return {
props: {
sanitizedContent
}
};
}
// In your component
function ArticlePage({ sanitizedContent }) {
return (
<article>
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
</article>
);
}
This approach moves the sanitization logic to the server, reducing client-side code and potential attack vectors.
3. Content Security Policy (CSP) Implementation
Implement CSP headers as an additional layer of security in your Next.js application:
// next.config.js
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
`;
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim()
}
]
}
];
}
};
CSP acts as a defense-in-depth strategy, providing protection even if an XSS vulnerability is discovered.
4. Type-Safe HTML Rendering with Custom Hook
Create a reusable hook for safe HTML rendering:
// useSecureHTML.ts
import { useMemo } from 'react';
import DOMPurify from 'dompurify';
interface UseSecureHTMLOptions {
allowedTags?: string[];
allowedAttributes?: string[];
}
export function useSecureHTML(
unsafeHTML: string,
options: UseSecureHTMLOptions = {}
) {
const defaultOptions = {
allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'],
allowedAttributes: ['href', 'target', 'rel']
};
const sanitizationOptions = {
ALLOWED_TAGS: options.allowedTags || defaultOptions.allowedTags,
ALLOWED_ATTR: options.allowedAttributes || defaultOptions.allowedAttributes
};
return useMemo(() => {
return {
__html: DOMPurify.sanitize(unsafeHTML, sanitizationOptions)
};
}, [unsafeHTML, sanitizationOptions.ALLOWED_TAGS, sanitizationOptions.ALLOWED_ATTR]);
}
// Usage in component
function ArticleBody({ content }) {
const secureHTML = useSecureHTML(content, {
allowedTags: ['p', 'h1', 'h2', 'a', 'strong', 'em', 'ul', 'ol', 'li'],
allowedAttributes: ['href', 'class', 'id']
});
return <div dangerouslySetInnerHTML={secureHTML} />;
}
This hook:
- Memoizes the sanitization for performance
- Provides type safety with TypeScript
- Creates a standardized, reusable approach
Real-World Use Case: Rich Text Editor Integration
A common production use case is integrating a rich text editor like TinyMCE, CKEditor, or Quill. Here's a complete example with TinyMCE in Next.js 14:
// components/RichTextEditor.tsx
import { useState, useEffect } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import DOMPurify from 'dompurify';
interface RichTextEditorProps {
initialValue: string;
onSave: (content: string) => void;
}
export default function RichTextEditor({ initialValue, onSave }: RichTextEditorProps) {
const [content, setContent] = useState('');
useEffect(() => {
// Sanitize even the initial value
setContent(DOMPurify.sanitize(initialValue));
}, [initialValue]);
const handleEditorChange = (content: string) => {
setContent(content);
};
const handleSave = () => {
// Always sanitize before saving to database
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'strong', 'em',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'img', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],
});
onSave(sanitizedContent);
};
return (
<div className="rich-text-editor">
<Editor
apiKey="your-tinymce-api-key"
value={content}
onEditorChange={handleEditorChange}
init={{
height: 500,
menubar: true,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'preview', 'anchor', 'searchreplace', 'visualblocks', 'code',
'fullscreen', 'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
],
toolbar: 'undo redo | formatselect | bold italic | ' +
'alignleft aligncenter alignright | bullist numlist | ' +
'removeformat | help',
content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue, sans-serif; font-size: 14px }'
}}
/>
<button
className="px-4 py-2 mt-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={handleSave}
>
Save Content
</button>
</div>
);
}
// components/ContentDisplay.tsx
import { useMemo } from 'react';
import DOMPurify from 'dompurify';
export default function ContentDisplay({ htmlContent }) {
// Create sanitized content when htmlContent changes
const sanitizedContent = useMemo(() => {
return {
__html: DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'strong', 'em',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'img', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],
ADD_ATTR: ['target', 'rel'],
TRANSFORM_TAGS: {
'a': (tagName, attribs) => {
// Add security attributes to all links
if (attribs.href) {
// Force external links to open in new tab with security attributes
if (attribs.href.startsWith('http')) {
return {
tagName,
attribs: {
...attribs,
target: '_blank',
rel: 'noopener noreferrer'
}
};
}
}
return { tagName, attribs };
}
}
})
};
}, [htmlContent]);
return (
<div className="content-display prose max-w-none">
<div dangerouslySetInnerHTML={sanitizedContent} />
</div>
);
}
// pages/article/[id].js
import { useState } from 'react';
import RichTextEditor from '../../components/RichTextEditor';
import ContentDisplay from '../../components/ContentDisplay';
export default function ArticlePage({ article }) {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(article.content);
const handleSave = async (newContent) => {
// Save to database via API
const response = await fetch(`/api/articles/${article.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: newContent }),
});
if (response.ok) {
setContent(newContent);
setIsEditing(false);
}
};
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">{article.title}</h1>
{isEditing ? (
<RichTextEditor initialValue={content} onSave={handleSave} />
) : (
<>
<ContentDisplay htmlContent={content} />
<button
className="px-4 py-2 mt-4 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
onClick={() => setIsEditing(true)}
>
Edit Article
</button>
</>
)}
</div>
);
}
export async function getServerSideProps({ params }) {
// Fetch article from database
const article = await fetchArticleById(params.id);
// Server-side sanitization
const { window } = new JSDOM('');
const purify = createDOMPurify(window);
// Sanitize content before sending to client
article.content = purify.sanitize(article.content);
return {
props: { article }
};
}
This implementation provides:
- Multiple layers of sanitization (server + client)
- Rich editing capabilities
- Security-focused approach with link transformation
- Memoization for performance
Performance and Maintainability Considerations
1. Memoize Sanitized Content
// ✅ Recommended: Memoize sanitized content
import { useMemo } from 'react';
function OptimizedContent({ content }) {
const sanitizedHTML = useMemo(() => {
return { __html: DOMPurify.sanitize(content) };
}, [content]);
return <div dangerouslySetInnerHTML={sanitizedHTML} />;
}
This prevents unnecessary re-sanitization on each render.
2. Implement Content Verification Tests
Add testing to verify your sanitization is working correctly:
// In your test file
import { render, screen } from '@testing-library/react';
import DOMPurify from 'dompurify';
import SafeHTML from '../components/SafeHTML';
describe('SafeHTML Component', () => {
it('properly sanitizes dangerous HTML', () => {
const dangerousContent = '<p>Safe text</p><script>alert("xss")</script>';
const { container } = render(<SafeHTML content={dangerousContent} />);
// Script tag should be removed
expect(container.querySelector('script')).toBeNull();
// Safe text should remain
expect(screen.getByText('Safe text')).toBeInTheDocument();
});
it('preserves allowed HTML', () => {
const content = '<p>Text with <strong>bold</strong> and <a href="https://example.com">link</a></p>';
const { container } = render(<SafeHTML content={content} />);
expect(container.querySelector('strong')).toBeInTheDocument();
expect(container.querySelector('a')).toBeInTheDocument();
expect(container.querySelector('a').getAttribute('href')).toBe('https://example.com');
});
});
3. Isolate and Constrain HTML Content
Limit the potential impact of HTML content by applying CSS containment:
// CSS for containing potentially risky content
.contained-html {
contain: content;
overflow: auto;
max-height: 100%;
max-width: 100%;
}
// Component usage
function SafeHTMLWithContainment({ content }) {
return (
<div
className="contained-html"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}
/>
);
}
This approach uses CSS containment to prevent the HTML content from affecting other elements on the page.
Conclusion
The dangerouslySetInnerHTML
attribute in React and Next.js can be safely used in production systems when approached with proper security practices. By implementing thorough sanitization, defense-in-depth strategies, and performance optimizations, you can leverage this feature without compromising your application's security.
Key takeaways:
- Always sanitize HTML content using a reputable library like DOMPurify
- Apply the principle of least privilege by explicitly defining allowed tags and attributes
- Implement multiple layers of protection including CSP headers
- Consider server-side sanitization for enhanced security
- Optimize for performance and maintainability with memoization and proper testing
By following these evidence-based approaches, you can confidently use dangerouslySetInnerHTML
in your production React and Next.js applications while maintaining security and performance standards.
Top comments (0)