Stream Markdown in Svelte and SvelteKit with the AI SDK

Build a Svelte 5 chat that streams AI SDK responses and renders each message as Markdown with Comark while it arrives.

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

Terminal
npm install @comark/svelte ai @ai-sdk/svelte

Create the server route

Chat sends requests to /api/chat by default. Create a SvelteKit endpoint at that path:

src/routes/api/chat/+server.ts
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:

src/routes/+page.svelte
<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>:

src/routes/docs/+page.server.ts
import { parseMarkdown } from 'comark'

export const load = async () => {
  return { tree: await parseMarkdown('# Release notes\n\nThis page renders on the server.') }
}
src/routes/docs/+page.svelte
<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

Next steps

© 2026 Vercel, Inc.