DEV Community

Muhammad Hamza
Muhammad Hamza

Posted on

Using dangerouslySetInnerHTML Safely in React and Next.js Production Systems

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>' }} />;
}
Enter fullscreen mode Exit fullscreen mode

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 }} />;
}
Enter fullscreen mode Exit fullscreen mode

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 }} />;
}
Enter fullscreen mode Exit fullscreen mode

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 }} />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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()
          }
        ]
      }
    ];
  }
};
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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 }
  };
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Always sanitize HTML content using a reputable library like DOMPurify
  2. Apply the principle of least privilege by explicitly defining allowed tags and attributes
  3. Implement multiple layers of protection including CSP headers
  4. Consider server-side sanitization for enhanced security
  5. 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)