October 23, 2026
We Turned On Trusted Types in Our React 19.3 App. Here’s What Broke.
A candid rollout story from a document workspace with comment previews, search highlights, and a Tiptap editor, and the fixes that got us…
By Faisal Mujtaba
7 min read
We Turned On Trusted Types in Our React 19.3 App. Here's What Broke.
A candid rollout story from a document workspace with comment previews, search highlights, and a Tiptap editor, and the fixes that got us to enforcement.
After the October Next.js security patches, I did something I'd been avoiding. I searched our codebase for every dangerouslySetInnerHTML and counted them.
I did not like the number.
Each one was individually defensible. A comment preview here, a search snippet with highlighted matches there, a few places that render sanitized markdown. But added up, they were a lot of places where one missed sanitization step becomes an XSS bug. Trusted Types was the fix we kept postponing. For years the reason was simple: React stringified everything, so the browser never saw a TrustedHTML object and the whole thing was a non-starter.
That changed. So we finally did it. This is what happened.
What Trusted Types is, quickly
The browser has a set of injection sinks: places where a string becomes live markup or code. innerHTML, outerHTML, insertAdjacentHTML, DOMParser.parseFromString, Range.createContextualFragment, iframe srcdoc, script.src. Normally they accept any string, which is why XSS keeps happening.
Trusted Types changes the deal. You send this header:
Content-Security-Policy: require-trusted-types-for 'script'Content-Security-Policy: require-trusted-types-for 'script'Now assigning a plain string to any of those sinks throws a TypeError. The only way through is a typed object (TrustedHTML, TrustedScript, TrustedScriptURL) minted by a policy you created with trustedTypes.createPolicy(name, { createHTML, createScript, createScriptURL }). A second directive, trusted-types <names>, is an allowlist of policy names. Try to create one that isn't listed and createPolicy throws too.
The point is that you no longer have to audit every call site. You audit the handful of policies instead.
Why it finally works in React
React 19.3 stopped coercing values to strings. A TrustedHTML passes straight through dangerouslySetInnerHTML, and TrustedScript and TrustedScriptURL pass through HTML and SVG attributes and URL attributes like href.
Worth being clear about what React does not do. It doesn't sanitize anything, and it doesn't create a policy for you. It just stops destroying the type on the way to the DOM. Your policy is still the security boundary, and I'll come back to that at the end.
Browser support is no longer the blocker either. It's Baseline 2026: Chrome and Edge 83+, Firefox 148+, Safari 26+. For older browsers there's a tiny shim:
if (typeof trustedTypes === "undefined") {
// @ts-ignore
trustedTypes = { createPolicy: (n, rules) => rules };
}if (typeof trustedTypes === "undefined") {
// @ts-ignore
trustedTypes = { createPolicy: (n, rules) => rules };
}It makes createPolicy return the rules object, so policy.createHTML(x) just calls your function and returns a string. Old browsers get no protection, but nothing breaks.
The rollout plan
We did not flip the switch. Three rules:
- Report-Only first. Violations get logged, nothing breaks.
- One named policy for app code, so there's exactly one place to review.
- An explicit allowlist of every policy name we expect to exist.
The policy
Here's lib/trusted-html.ts:
import DOMPurify from "dompurify";
let policy: TrustedTypePolicy | undefined;
export function toTrustedHTML(html: string): TrustedHTML | string {
// trustedTypes doesn't exist on the server
if (typeof window === "undefined" || !window.trustedTypes) {
return html;
}
policy ??= window.trustedTypes.createPolicy("app-html", {
createHTML: (input) =>
DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: null }),
});
return policy.createHTML(html);
}import DOMPurify from "dompurify";
let policy: TrustedTypePolicy | undefined;
export function toTrustedHTML(html: string): TrustedHTML | string {
// trustedTypes doesn't exist on the server
if (typeof window === "undefined" || !window.trustedTypes) {
return html;
}
policy ??= window.trustedTypes.createPolicy("app-html", {
createHTML: (input) =>
DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: null }),
});
return policy.createHTML(html);
}Two details that matter. The policy is created lazily because trustedTypes doesn't exist on the server. And createHTML must return a string, which DOMPurify.sanitize does by default.
The TRUSTED_TYPES_POLICY: null bit comes up again in the breakage list below.
The component
import { toTrustedHTML } from "@/lib/trusted-html";
export function CommentPreview({ html }: { html: string }) {
return (
<div
className="comment-preview"
dangerouslySetInnerHTML={{ __html: toTrustedHTML(html) }}
/>
);
}import { toTrustedHTML } from "@/lib/trusted-html";
export function CommentPreview({ html }: { html: string }) {
return (
<div
className="comment-preview"
dangerouslySetInnerHTML={{ __html: toTrustedHTML(html) }}
/>
);
}That's the whole migration for a typical call site. It's boring, which is what you want.
The header
In next.config.ts:
const tt =
"require-trusted-types-for 'script'; " +
"trusted-types app-html nextjs nextjs#bundler ProseMirrorClipboard";
export default {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Content-Security-Policy-Report-Only", value: tt },
],
},
];
},
};const tt =
"require-trusted-types-for 'script'; " +
"trusted-types app-html nextjs nextjs#bundler ProseMirrorClipboard";
export default {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Content-Security-Policy-Report-Only", value: tt },
],
},
];
},
};nextjs is the policy Next.js creates itself. nextjs#bundler is the one its webpack client build uses. You need both on the list. ProseMirrorClipboard is there because of the editor, and that's a story for the next section.
Then we deployed and watched the reports.
What broke
More than I expected. Not a catastrophe, but each one was a "huh" moment. Here they are in roughly the order they showed up.
DOMPurify tried to make its own policy
My first version of the policy called DOMPurify.sanitize(input) with no options. DOMPurify, seeing Trusted Types is available, tries to create its own internal policy named dompurify. Our allowlist didn't include that name, so createPolicy threw.
The fix is the option you already saw: { TRUSTED_TYPES_POLICY: null }. That tells DOMPurify to skip its own policy and return a plain string, which our app-html policy then wraps. One policy, one name, one place to look.
I could have added dompurify to the allowlist. I didn't want two policies where one would do.
Pasting into the editor stopped working
Our editor is built on Tiptap and ProseMirror. In the Report-Only logs, a violation appeared tied to the paste handler.
ProseMirror's clipboard code checks for trustedTypes.defaultPolicy. If one exists, it uses it. If not, it creates its own policy called ProseMirrorClipboard. With a strict allowlist and no default policy, that creation throws, and pasting HTML into the editor fails.
The fix was one word in the header: add ProseMirrorClipboard to the trusted-types list. That's why it was already in the config above. I'm glad it showed up in Report-Only rather than in a support ticket that says "paste is broken."
setContent with an HTML string
This one was subtler. Tiptap core parses HTML string content with new DOMParser().parseFromString(string), and that's a Trusted Types sink. So anywhere we called editor.commands.setContent('<p>...</p>') with a string, enforcement would throw. The only escape hatch is a default policy.
We already store documents as ProseMirror JSON in most places, but a few legacy paths (templates, imports) fed the editor HTML strings. The fix was to stop doing that:
// Before: HTML string hits DOMParser
editor.commands.setContent("<p>Hello</p>");
// After: ProseMirror JSON, no string parsing at all
editor.commands.setContent({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
],
});// Before: HTML string hits DOMParser
editor.commands.setContent("<p>Hello</p>");
// After: ProseMirror JSON, no string parsing at all
editor.commands.setContent({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
],
});For the import paths that genuinely receive HTML, we convert on the boundary and hand the editor JSON. It's more work than a one-line policy, but it removes a whole class of "string into parser" bugs. I've argued for JSON over HTML for storage in an earlier post; this is another reason.
Dynamic chunk loads and Turbopack
This is the one that doesn't have a clean answer yet.
Turbopack, the default bundler in Next 16, loads chunks by assigning a plain string to script.src. That's a sink. Dynamic chunk loads violate the directive. It's tracked in a GitHub discussion (vercel/next.js #95772), and as I write this there's no config option to fix it.
Your realistic options are:
- build with
next build --webpack, or - keep Trusted Types in Report-Only until that changes.
We're not Turbopack-only in production, so the webpack route works for us. If you are, this is probably your blocker.
One more thing from the Next.js docs: dev mode needs 'unsafe-eval' because React uses eval for debugging info in development. Production doesn't. So don't be alarmed when your dev CSP looks looser than your production one.
The temporary default policy
Somewhere in the middle of this, I wanted a way to stop the bleeding while we hunted down call sites. The mechanism for that is a policy named default. When enforced, the browser automatically calls it for plain string assignments to sinks.
So we added one, loudly marked as temporary:
// MIGRATION CRUTCH. Delete once Report-Only is quiet.
// Sanitizes and logs every plain-string sink assignment.
if (typeof window !== "undefined" && window.trustedTypes) {
window.trustedTypes.createPolicy("default", {
createHTML: (input) => {
console.warn("[TT default policy] string reached an HTML sink", input.slice(0, 80));
return DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: null });
},
});
}// MIGRATION CRUTCH. Delete once Report-Only is quiet.
// Sanitizes and logs every plain-string sink assignment.
if (typeof window !== "undefined" && window.trustedTypes) {
window.trustedTypes.createPolicy("default", {
createHTML: (input) => {
console.warn("[TT default policy] string reached an HTML sink", input.slice(0, 80));
return DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: null });
},
});
}You also need default in the trusted-types list for this to be allowed. MDN recommends the default policy only during migration, and I agree with that framing. It's a crutch. It makes the app work while you find the real offenders, and the logging is how you find them.
Flipping to enforce
When the reports went quiet for the paths we cared about, we changed one thing: the header name.
{ key: "Content-Security-Policy", value: tt }{ key: "Content-Security-Policy", value: tt }Same value, no -Report-Only. We also removed the default policy at that point, because leaving it in would mean any string we forgot about would quietly get sanitized instead of failing loudly. A hidden default policy is how you end up with a protection you can't see and can't trust.
If the allowlist changes, remember that trusted-types also supports the 'allow-duplicates' keyword if something legitimately creates the same policy name twice.
Pitfalls I'd warn you about
Identity policies defeat the point. createHTML: (s) => s satisfies the browser and protects nothing. You've moved the string around a type and called it secure.
The default policy hides problems. If you forget to remove it, you've turned an enforcement mechanism into a sanitizing shim that nobody reviews.
Third-party scripts may create their own policies. Analytics, chat widgets, anything injected. They'll show up in Report-Only as names you didn't allowlist. Decide for each one: allowlist it, or ask why it needs one.
Report-Only before enforce. Always. The surprises are in the logs, not in your head.
Trusted Types is not a sanitizer. Your policy is. The browser only checks that the value came from a policy. It doesn't care what the policy did. If your createHTML is weak, you've just centralized the weakness. The upside is that it's now one function to review instead of dozens of call sites.
For the future: Element.setHTML() from the Sanitizer API exists, but it isn't Baseline yet. When it is, a lot of this may get simpler. I'm not building on it today.
My recommendation
Do this now if you render user HTML anywhere (comment previews, highlighted search snippets, markdown, a rich text editor) and you ship with webpack or can. The code change is small, the audit value is large, and React 19.3 finally lets the types survive.
Wait if your production build is Turbopack-only. Run Report-Only, collect the data, and revisit once the chunk loading issue has a real answer.
The order I'd follow:
- Send
Content-Security-Policy-Report-Onlywith one app policy and the allowlist (app-html,nextjs,nextjs#bundler, plus editor policies likeProseMirrorClipboard). - Move every
dangerouslySetInnerHTMLto the policy helper. - Add a temporary default policy that logs, and use the logs to find stragglers.
- Fix editor paths to load JSON instead of HTML strings.
- Delete the default policy.
- Switch the header to
Content-Security-Policy.
I still have the same dangerouslySetInnerHTML count I started with. The difference is that now each one has to go through a policy I can read in one file, and the browser enforces it.