This changes everything for how we handle dynamic content. For years, we've treated dangerouslySetInnerHTML like a necessary evil—a dark corner of our React apps where we just hoped our sanitisation logic held up. But with the release of React 19.3 on September 9, 2026, we’ve finally got a way to bridge the gap between React's component model and the browser’s native security primitives.

I’m talking about Trusted Types. While the API has been around in Chromium for a while, React previously had a habit of turning everything into a string before it hit the DOM. That effectively broke the security chain. Now, React 19.3 preserves the "branded" nature of these objects, allowing the browser to actually enforce your security policies.

The Problem with String-Based Security

We've all been there: you're building a CMS or a dashboard that needs to render user-generated HTML. You pull in DOMPurify, run it on the string, and pass it to React. The problem? The browser has no way of knowing that the string you're passing is actually safe. To the browser's Content Security Policy (CSP), a string is just a string, and strings are dangerous.

If you enable require-trusted-types-for 'script' in your CSP, the browser will flat-out block any attempt to pass a plain string into an "injection sink" like innerHTML. Before 19.3, even if you created a Trusted Type object, React would coerce it back to a string, causing the browser to reject it. It was a frustrating catch-22.

Defining Your Security Policy

Trusted Types isn't a magic wand that sanitises your HTML for you. It's an infrastructure layer. You have to define a policy that tells the browser: "If this object was created by this specific function, you can trust it."

import DOMPurify from 'dompurify';

// Create a policy at the entry point of your app
const contentPolicy = window.trustedTypes?.createPolicy("app-rich-content", {
  createHTML(input) {
    // This is where the actual transformation happens
    return DOMPurify.sanitize(input, {
      RETURN_TRUSTED_TYPE: true,
    });
  },
});

By using RETURN_TRUSTED_TYPE: true, DOMPurify returns a TrustedHTML object instead of a string. This object carries a hidden "brand" that the browser recognises as valid according to the app-rich-content policy.

Building a Design System Boundary

In my experience, the best way to roll this out is to treat it as a design system constraint. You don't want every feature developer creating their own policies or calling dangerouslySetInnerHTML directly. Instead, create a single, hardened primitive.

export function SafeRichContent({ html }) {
  // Type checking at runtime ensures no one sneaks a string in
  if (window.TrustedHTML && !(html instanceof TrustedHTML)) {
    throw new TypeError("SafeRichContent requires a TrustedHTML object. Use the app policy to create one.");
  }

  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

This creates a clear separation of concerns. Your ingestion boundary (where you fetch data) handles the sanitisation. Your design system boundary provides the only approved way to render it. If a developer tries to pass a raw string from an API, the app fails fast rather than opening a security hole.

Enforcing the Policy with CSP

Once your code is ready, you need to tell the browser to stop accepting strings entirely. You do this via your HTTP headers. I'd recommend starting with a Content-Security-Policy-Report-Only header to see what breaks before you go full enforcement.

Content-Security-Policy: 
  require-trusted-types-for 'script'; 
  trusted-types app-rich-content dompurify;

The trusted-types directive acts as an allowlist. Only policies named here can be created. This prevents an attacker from injecting a script that creates its own "pass-through" policy to bypass your checks.

Common Pitfalls to Avoid

  • Don't stringify: The moment you do `${trustedHtml}` or JSON.stringify(trustedHtml), you lose the brand. It becomes a plain string again, and the browser will reject it.
  • Polyfill limitations: While polyfills exist for non-Chromium browsers, they can't provide the same hardware-level enforcement. They are great for development, but the CSP header is your real shield.
  • Permissive policies: A policy that just returns the input without sanitising it is worse than no policy at all. It gives you a false sense of security.

It's also worth noting that React 19.3 doesn't automatically make your app safe. If you pass a string to dangerouslySetInnerHTML and you don't have a CSP enforcing Trusted Types, you are just as vulnerable as you were in React 18. This is an integration tool, not a magic shield.

• • •

Wrapping Up

  • React 19.3 preserves Trusted Type objects, allowing for true browser-level XSS prevention.
  • Centralise your sanitisation logic into a single policy and wrap it in a design system component.
  • Use CSP headers to lock down injection sinks and prevent string-based bypasses.

I'd really encourage you to experiment with this in a small internal tool first. It forces you to think about data flow in a much more disciplined way, and the security benefits are massive.

If you want to go deeper and learn how to build real, production-ready CSS design systems step by step, check out my full course here: CSS Design Systems Course

I cover the full pipeline in Ship Your Design System, 200 pages on Amazon Kindle.

For more tips on UX engineering and React security, let's connect on Twitter or LinkedIn. I'd love to hear how you're handling rich content in your own projects!