Type something to search...
How to render raw HTML safely with set:html in Astro?

How to render raw HTML safely with set:html in Astro?

To render raw HTML in Astro, add the set:html directive to an element or a Fragment and pass it the HTML string: <div set:html={html} /> or <Fragment set:html={html} />. Astro inserts the string as markup, much like setting innerHTML, instead of escaping it as it does for normal {expressions}. The crucial detail is that Astro does not escape or sanitise the value, so it's only safe with HTML you wrote or fully trust. Anything that could contain user input, such as comments, form submissions or third-party content, must be run through an HTML sanitiser like sanitize-html first, or you open the page to cross-site scripting (XSS).

There are plenty of legitimate reasons to inject HTML: content from a headless CMS, Markdown rendered by a library, inline SVG icons, JSON-LD structured data, or legacy pages you're migrating. In this article you'll see how escaping works by default, where set:html fits, what an XSS attack through it looks like, how to sanitise content with sanitize-html, how to output JSON-LD without a script breakout, how set:html handles promises and Response objects, and which alternatives are safer when you don't actually need raw HTML. Every output shown here comes from an Astro 7.4.2 build.

Default Escaping vs set:html

By default, Astro escapes every value you render in an expression. Characters like <, > and & become entities, so a string can never turn into markup:

---
const rawHTMLString = "Hello <strong>World</strong>";
---
<h1>{rawHTMLString}</h1>
<h1 set:html={rawHTMLString} />
<h1>Hello &lt;strong&gt;World&lt;/strong&gt;</h1><h1>Hello <strong>World</strong></h1>

The first heading shows the tags as visible text. The second renders "World" in bold. That's the whole feature: set:html turns off escaping for one value.

A few mechanics:

  • On an element: the string replaces the element's children. Write it self-closing, <div set:html={html} />, or with an empty body.
  • On a Fragment: the string is inserted with no wrapper element, which is handy for SVGs and CMS fragments. More in how to use the Fragment component.
  • Any value works: non-string values are converted to strings, and null or undefined render nothing.
  • set:text is the escaped twin: <p set:text={value} /> is equivalent to <p>{value}</p>, so it's rarely needed.

Why Unescaped HTML Is Dangerous

If an attacker can get their own HTML into a string you pass to set:html, their markup runs on your page with your origin's privileges. The classic payloads don't even need a <script> tag:

<img src="x" onerror="alert('xss')">
<a href="javascript:alert(1)">click</a>
<a href="https://example.com" onclick="evil()">ok</a>

An event handler attribute or a javascript: URL is enough to read cookies that aren't HttpOnly, make requests as the logged-in user or rewrite the page. Static sites aren't immune: if your build pulls comments, reviews or CMS fields that editors outside your team can change, a malicious value is baked into the HTML and served to everyone.

The rule of thumb:

Source of the HTMLSafe to pass to set:html directly?
A string literal you wrote in the componentYes
SVG files in your own repositoryYes
Your own Markdown, rendered at build timeGenerally yes
A CMS edited only by trusted staffUsually, but sanitising is cheap insurance
User comments, form input, query parametersNo, always sanitise
Third-party APIs and scraped contentNo, always sanitise

Sanitising HTML with sanitize-html

sanitize-html is a widely used Node.js library that parses HTML and keeps only the tags, attributes and URL schemes you allow. Because Astro component scripts run on the server, it fits naturally. Install it, plus its types if you use TypeScript:

npm install sanitize-html
npm install -D @types/sanitize-html

Wrap it in a small helper so every component uses the same rules:

// src/lib/sanitize.ts
import sanitizeHtml from "sanitize-html";

export function cleanHtml(dirty: string): string {
  return sanitizeHtml(dirty, {
    allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
    allowedAttributes: {
      ...sanitizeHtml.defaults.allowedAttributes,
      img: ["src", "alt", "width", "height", "loading"],
    },
    allowedSchemes: ["https", "mailto"],
  });
}

The defaults cover common text formatting, lists, tables and links. This configuration also allows images with a short list of attributes, and permits only https: and mailto: URLs. Then sanitise right before rendering:

---
// src/components/Comment.astro
import { cleanHtml } from "../lib/sanitize";

interface Props {
  body: string;
}
const { body } = Astro.props;
---
<div class="comment" set:html={cleanHtml(body)} />

I passed a hostile comment through that helper:

---
const comment = `<p>Nice post!</p><img src="x" onerror="alert('xss')"><script>steal()</script><a href="javascript:alert(1)">click</a><a href="https://example.com" onclick="evil()">ok</a>`;
---
<div set:html={cleanHtml(comment)} />
<div><p>Nice post!</p><img src="x" /><a>click</a><a href="https://example.com">ok</a></div>

The <script> element is gone, the onerror and onclick handlers were stripped, and the javascript: link lost its href. The harmless parts survived. Note that the relative src="x" was kept: allowedSchemes restricts URLs that have a scheme, so decide separately whether relative URLs are acceptable for your content.

Some guidelines for sanitiser configuration:

  • Allow, don't block: list the tags and attributes you want. A blocklist always misses something.
  • Never allow style or on* attributes from untrusted sources: they're the most common ways around a weak sanitiser.
  • Sanitise on output, at the last step: if you sanitise when saving and later change the rendering, old content may not match your new assumptions.
  • Keep the library updated: sanitisers receive security fixes, so treat them like any security dependency.

If you prefer DOMPurify, the isomorphic-dompurify package runs it on the server. Whichever you choose, use one helper everywhere so the rules stay consistent.

Rendering Markdown from an External Source

A common case is Markdown stored in a database or CMS field. Convert it to HTML, then sanitise, because Markdown allows raw HTML inline:

---
// src/components/Prose.astro
import { marked } from "marked";
import { cleanHtml } from "../lib/sanitize";

interface Props {
  markdown: string;
}
const { markdown } = Astro.props;
const html = cleanHtml(await marked.parse(markdown));
---
<div class="prose" set:html={html} />

Given # Hi\n\nSome *text* <script>bad()</script> [link](javascript:alert(1)), the output was:

<div class="prose"><h1>Hi</h1>
<p>Some <em>text</em>  <a>link</a></p>
</div>

For Markdown that lives in your project, you don't need any of this. Put it in a content collection and render it with render() from astro:content, which handles headings, images and syntax highlighting for you. See content collections in Astro.

JSON-LD Without a Script Breakout

set:html is also the standard way to put JSON-LD structured data in a page, because the contents of a <script> tag must not be HTML-escaped. The usual example passes JSON.stringify() straight in. That's fine for constant data, but it has a hidden flaw when any value comes from content:

---
const title = `Breaking </script><script>alert(1)</script>`;
---
<script type="application/ld+json" set:html={JSON.stringify({ headline: title })} />
<script type="application/ld+json">{"headline":"Breaking </script><script>alert(1)</script>"}</script>

JSON.stringify() doesn't escape <, so the browser sees </script>, closes the JSON block and runs the next script. The fix is to escape the characters that matter in HTML. JSON allows < style escapes, so the data is unchanged for parsers:

// src/lib/json-ld.ts
export function jsonLd(data: unknown): string {
  return JSON.stringify(data)
    .replace(/</g, "\\u003c")
    .replace(/>/g, "\\u003e")
    .replace(/&/g, "\\u0026");
}
---
// src/components/JsonLd.astro
import { jsonLd } from "../lib/json-ld";

interface Props {
  data: Record<string, unknown>;
}
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={jsonLd(data)} />
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Article","headline":"Breaking </script><script>alert(1)</script>"}</script>

The headline is intact for search engines, and there's no way out of the script element. A <script> with a type attribute is left inline by Astro rather than bundled, which is exactly what JSON-LD needs. Structured data is covered fully in how to add JSON-LD structured data in Astro.

Inline SVG

Inlining an SVG lets you style it with CSS, for example with fill: currentColor. If the SVG markup is a string, set:html on a Fragment inserts it without a wrapper:

---
const svgIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path d="M12 2 2 22h20z"/></svg>`;
---
<span class="icon" set:html={svgIcon} />
<Fragment set:html={svgIcon} />

For SVG files in your project, though, there's a better option. Astro lets you import a local .svg file and use it as a component, which inlines the SVG and lets you pass attributes such as width, height and fill:

---
import Logo from "../assets/logo.svg";
---
<Logo width={32} height={32} />

That avoids set:html entirely for your own icons. Keep set:html for SVG strings that arrive at runtime, and sanitise them if they come from users: SVG can contain scripts and event handlers too.

Promises and Response Objects

set:html also accepts a Promise that resolves to a string, and a Promise that resolves to a Response. Astro waits for it and inserts the result:

---
import { getArticleHtml } from "../lib/db";
---
<article set:html={getArticleHtml(Astro.props.id)} />
<article set:html={fetch("https://legacy.example.com/posts/making-soup.html")} />

I tested both forms with stand-in promises, and each rendered its HTML inside the <article>:

<article><p>Loaded from a promise</p></article><article><p>Loaded from a Response</p></article>

This is convenient for migrations, such as embedding pages from an old site generator. But notice that the fetched HTML goes in unsanitised. If you need to clean it, fetch it in the component script, call .text(), pass the string through your sanitiser, and then render it. Migrating old sites is covered in migrating WordPress to Astro.

Safer Alternatives

Before reaching for set:html, check whether you need raw HTML at all:

  • Plain text with formatting: if a CMS field is just text, render it with {value} and style the container. Escaping is automatic.
  • Line breaks in plain text: split on newlines and render paragraphs with .map() instead of converting \n to <br> strings.
  • Structured rich text: many headless CMSs can return rich text as JSON blocks. Mapping blocks to Astro components gives you full control with no raw HTML. See using a headless CMS with Astro.
  • Local Markdown: use content collections and render().
  • Local SVGs: import them as components.

FAQ: set:html in Astro

No. set:html inserts the string exactly as given, without escaping or sanitising. It's only safe with trusted HTML, so run anything that may contain user input through a sanitiser such as sanitize-html first.

Put set:html on the built-in Fragment component, giving set:html your HTML string as its value. The HTML is inserted directly with no surrounding element.

set:html inserts the value as markup without escaping. set:text escapes the value and inserts it as text, which is the same as rendering the value in curly braces.

Only for trusted, constant data. JSON.stringify doesn't escape the less-than sign, so a value containing a closing script tag can break out of the script element. Replace the less-than, greater-than and ampersand characters with Unicode escapes first.

Yes. set:html accepts a promise that resolves to a string or to a Response, and Astro waits for it before rendering. The content still isn't sanitised, so only use it with trusted sources.

Any server-side HTML sanitiser works because Astro component scripts run on the server. sanitize-html is a popular choice, and isomorphic-dompurify runs DOMPurify on the server. Configure an allowlist of tags and attributes.


Conclusion

set:html is a deliberate escape hatch from Astro's automatic escaping. Put it on an element to fill that element with an HTML string, or on a Fragment to insert the HTML with no wrapper. It also accepts promises and Response objects, which makes it useful for CMS content, migrations and embedded fragments.

Because it does no escaping or sanitising, the safety is entirely up to you. Use it freely with HTML you wrote, sanitise everything else on the server with an allowlist-based library, escape <, > and & when you put data in JSON-LD, and prefer safer tools such as content collections, SVG components and plain {expressions} whenever you don't truly need raw HTML.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper