Svelte’s {@html} is one of those features that feels harmless right up until it isn’t.

It solves a real problem: sometimes you need to render trusted HTML. CMS content, rich text, server-generated markup, documentation snippets, email previews — all valid use cases. But {@html} is also the fastest way to turn a Svelte app into an XSS delivery mechanism if you feed it untrusted input.

This guide is the practical version: what’s dangerous, what actually works, and what I’d ship.

What {@html} does

{@html ...} tells Svelte to inject a string as raw HTML into the DOM.

<script>
  let content = "<p>Hello <strong>world</strong></p>";
</script>

<div>{@html content}</div>

That bypasses Svelte’s normal HTML escaping. If content contains attacker-controlled markup, the browser parses it like any other HTML.

That means this is unsafe:

<script>
  let userBio = '<img src=x onerror="alert(1)">';
</script>

<div>{@html userBio}</div>

If your threat model includes “users can control this string” or “this came from a CMS/API/Markdown pipeline I don’t fully trust”, treat {@html} as a sink.

The core rule

If data is untrusted, do not pass it directly into {@html}.

Bad:

<div>{@html comment.body}</div>

Still bad, just hidden behind a helper:

<div>{@html renderComment(comment.body)}</div>

The only safe approaches are:

  1. Don’t use {@html} at all.
  2. Sanitize HTML before rendering.
  3. Strictly control the source so only trusted, generated markup can reach the sink.

Safer default: render text, not HTML

If you don’t need actual markup, let Svelte escape it.

<script>
  let comment = '<img src=x onerror="alert(1)"> hello';
</script>

<p>{comment}</p>

That renders as text, not executable HTML.

A lot of XSS bugs happen because someone wanted line breaks or bold text and jumped straight to {@html}. Usually there’s a safer UI pattern.

Example: preserve line breaks without raw HTML

<script>
  let bio = "First line\nSecond line\nThird line";
</script>

<p style="white-space: pre-line;">{bio}</p>

No sanitizer needed. No XSS sink introduced.

When you really need HTML: sanitize first

If you must render user-controlled or semi-trusted HTML, sanitize it before it hits {@html}.

The most common choice is DOMPurify.

Install DOMPurify

npm install dompurify

Basic Svelte usage

<script>
  import DOMPurify from 'dompurify';

  export let content = '';

  $: safeContent = DOMPurify.sanitize(content);
</script>

<div>{@html safeContent}</div>

That’s the baseline pattern.

Copy-paste component: SafeHtml.svelte

I like wrapping this in a dedicated component so raw HTML rendering is easy to grep for during reviews.

<script lang="ts">
  import DOMPurify from 'dompurify';

  export let html: string = '';

  const config = {
    USE_PROFILES: { html: true }
  };

  $: sanitized = DOMPurify.sanitize(html, config);
</script>

<div>{@html sanitized}</div>

Usage:

<script>
  import SafeHtml from './SafeHtml.svelte';

  let articleBody = `
    <p>Hello <strong>world</strong></p>
    <img src="x" onerror="alert(1)">
  `;
</script>

<SafeHtml html={articleBody} />

The onerror handler gets stripped.

Restrict what HTML is allowed

The default sanitizer config is decent, but I usually prefer a narrower allowlist for rich text content.

<script lang="ts">
  import DOMPurify from 'dompurify';

  export let html = '';

  const config = {
    ALLOWED_TAGS: [
      'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
      'a', 'blockquote', 'code', 'pre', 'h1', 'h2', 'h3'
    ],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  };

  $: sanitized = DOMPurify.sanitize(html, config);
</script>

<div class="prose">{@html sanitized}</div>

This is a better fit for blog comments, docs content, and CMS fields than “allow a giant chunk of HTML and hope for the best.”

Don’t forget dangerous URLs

HTML sanitization isn’t just about <script> tags. Browsers execute code through attributes and URL schemes too.

Bad payloads often look like this:

<a href="javascript:alert(1)">click me</a>
<img src="x" onerror="alert(1)">
<svg onload="alert(1)"></svg>

A good sanitizer will strip or neutralize these. Still, if links matter in your app, validate them explicitly.

Example: post-process links

<script lang="ts">
  import DOMPurify from 'dompurify';

  export let html = '';

  function sanitizeHtml(input: string) {
    return DOMPurify.sanitize(input, {
      ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
      ALLOWED_ATTR: ['href', 'rel', 'target']
    });
  }

  $: sanitized = sanitizeHtml(html);
</script>

<div>{@html sanitized}</div>

For high-risk apps, I’d go further and rewrite outbound links on the server or in a DOM pass so only http:, https:, and maybe mailto: survive.

Server-side sanitization is usually better

Client-side sanitization is useful, but if the content is stored or reused, sanitize on the server too.

Why:

  • You avoid serving dangerous HTML to any client.
  • Other consumers of the same data stay protected.
  • You don’t depend on every frontend rendering path doing the right thing.

Good pattern:

  1. Accept rich text input.
  2. Sanitize it on the server.
  3. Store sanitized HTML or store source + sanitized derivative.
  4. Render sanitized content with {@html}.

Still sanitize consistently if content can come from old records, imports, admin tools, or third-party systems.

SvelteKit example with server-side sanitization

// src/routes/articles/+page.server.ts
import DOMPurify from 'isomorphic-dompurify';

export async function load() {
  const article = await getArticleFromCMS();

  return {
    article: {
      ...article,
      body: DOMPurify.sanitize(article.body, {
        ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'li', 'h2', 'h3'],
        ALLOWED_ATTR: ['href', 'title']
      })
    }
  };
}
<!-- src/routes/articles/+page.svelte -->
<script>
  export let data;
</script>

<h1>{data.article.title}</h1>
<div class="prose">{@html data.article.body}</div>

That’s much closer to something I’d trust in production.

Common mistakes

1. “It comes from our CMS, so it’s trusted”

Nope. CMS content is often edited by many people, imported from plugins, synced from Markdown, or pasted from Word/Google Docs. Trust boundaries get fuzzy fast.

If people can paste HTML, or if your pipeline converts markup into HTML, sanitize it.

2. “We strip <script> tags, so we’re safe”

That’s not enough.

These can still execute:

<img src=x onerror=alert(1)>
<a href=javascript:alert(1)>x</a>
<div style="background:url(javascript:alert(1))"></div>
<svg onload=alert(1)>

Use a real sanitizer, not regex.

3. Sanitizing sometimes

If one code path sanitizes and another doesn’t, attackers will find the unsanitized one. Wrap the behavior in a shared component or utility and make {@html} rare.

4. Mixing template generation with raw user input

Bad:

<script>
  let username = '<img src=x onerror=alert(1)>';
  let html = `<p>Welcome ${username}</p>`;
</script>

<div>{@html html}</div>

If you need mixed trusted markup and untrusted data, escape the untrusted part before building HTML, or better, render it as normal Svelte content.

Better:

<script>
  let username = '<img src=x onerror=alert(1)>';
</script>

<p>Welcome {username}</p>

CSP helps, but it does not fix unsafe {@html}

A good Content Security Policy can reduce the blast radius of XSS, especially by blocking inline scripts and limiting script sources. You should absolutely have one.

But CSP is backup defense, not a license to inject raw HTML.

If you want implementation details, csp-guide.com is a solid reference. And if you want to quickly inspect your site’s security headers in the real world, I’d use HeaderTest.

A basic CSP might look like:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

That can block a lot of common payloads, but not all DOM-based abuse, UI redressing, malicious links, or sanitizer bypass fallout.

Practical review checklist

When I review Svelte code, I check {@html} like this:

  • Is the input fully trusted?
  • If not, where is it sanitized?
  • Is the sanitizer using an allowlist?
  • Are dangerous URL schemes handled?
  • Is there a shared SafeHtml component instead of ad hoc usage?
  • Is CSP enabled?
  • Can this be rendered as plain text or structured components instead?

Good and bad patterns

Bad

<div>{@html post.content}</div>

Better

<script>
  import DOMPurify from 'dompurify';
  export let content = '';
  $: safe = DOMPurify.sanitize(content);
</script>

<div>{@html safe}</div>

Best for most apps

<script lang="ts">
  import DOMPurify from 'dompurify';
  export let html = '';

  const config = {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  };

  $: sanitized = DOMPurify.sanitize(html, config);
</script>

<div class="rich-text">{@html sanitized}</div>

Or skip {@html} entirely:

<p>{message}</p>

That’s still the cleanest fix when it fits.

Bottom line

Treat {@html} as a high-risk sink. If the content is untrusted, sanitize it with a real HTML sanitizer, preferably on the server and again consistently at render boundaries if needed. Keep the allowed HTML small. Back it up with CSP. And if plain text or normal Svelte templating works, use that instead.

That’s the version that holds up during audits and incident reviews.