Stream Markdown in Svelte and SvelteKit with the AI SDK
Use the Chat class from @ai-sdk/svelte to receive messages, and render each text part with <Markdown value streaming caret /> from @comark/svelte. Comark closes unfinished syntax on every update, and runs a full parse when streaming becomes false.
This guide uses Svelte 5 and SvelteKit. It assumes that you have a SvelteKit app and an AI provider key.
Build the chat
Install the packages
npm install @comark/svelte ai @ai-sdk/svelteCreate the server route
Chat sends requests to /api/chat by default. Create a SvelteKit endpoint at that path:
import { convertToModelMessages, createGateway, streamText, type UIMessage } from 'ai'
import { AI_GATEWAY_API_KEY } from '$env/static/private'
const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY })
export async function POST({ request }) {
const { messages }: { messages: UIMessage[] } = await request.json()
const result = streamText({
model: gateway('anthropic/claude-sonnet-4.6'),
messages: await convertToModelMessages(messages),
})
return result.toUIMessageStreamResponse()
}Vite doesn't copy environment variables to process.env, so the route reads the key from $env/static/private. The server API can change between AI SDK versions. Check the AI SDK Svelte guide for your version.
Render the messages
Pass part.state === 'streaming' to the streaming prop. Each part then runs its final full parse as soon as it is complete:
<script lang="ts">
import { Chat } from '@ai-sdk/svelte'
import { Markdown } from '@comark/svelte'
let input = $state('')
const chat = new Chat({})
function handleSubmit(event: SubmitEvent) {
event.preventDefault()
chat.sendMessage({ text: input })
input = ''
}
</script>
{#each chat.messages as message (message.id)}
{#each message.parts as part, index (index)}
{#if part.type === 'text'}
{#if message.role === 'user'}
<p>{part.text}</p>
{:else}
<Markdown value={part.text} streaming={part.state === 'streaming'} caret />
{/if}
{/if}
{/each}
{/each}
<form onsubmit={handleSubmit}>
<input bind:value={input} placeholder="Ask something…" />
</form>Don't destructure chat. A destructured messages value is a copy and doesn't update.
Server-side rendering
The Svelte <Markdown> component uses $effect, so it doesn't render during SSR. This is fine for chat, because the messages arrive in the browser.
For content that must be in the server HTML, parse it in a load function and render it with <MarkdownDocument>:
import { parseMarkdown } from 'comark'
export const load = async () => {
return { tree: await parseMarkdown('# Release notes\n\nThis page renders on the server.') }
}<script lang="ts">
import { MarkdownDocument } from '@comark/svelte'
let { data } = $props()
</script>
<MarkdownDocument value={data.tree} />The SvelteKit example shows both routes, including lazy components with <MarkdownAsync>.
Sanitize model output
Raw HTML in model output is parsed into nodes by default. Add the security plugin and pass it in the plugins prop:
<script lang="ts">
import security from '@comark/svelte/plugins/security'
const plugins = [security({ blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style', 'object'] })]
</script>
<Markdown value={part.text} streaming={part.state === 'streaming'} {plugins} caret />FAQ
@comark/svelte provides Svelte 5 components that use runes such as $state and $effect.<Markdown> renders only in the browser. Parse on the server and use <MarkdownDocument>, or use the experimental <MarkdownAsync>.