---
title: "Stream Markdown in Svelte and SvelteKit with the AI SDK"
description: "Build a Svelte 5 chat that streams AI SDK responses and renders each message as Markdown with Comark while it arrives."
canonical_url: "https://comark.dev/use-cases/svelte-ai-chat"
---
# 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

::steps{level="3"}
### Install the packages

```bash [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:

```typescript [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](https://ai-sdk.dev/docs/getting-started/svelte) 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:

```svelte [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>`:

```typescript [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.') }
}
```

```svelte [src/routes/docs/+page.svelte]
<script lang="ts">
  import { MarkdownDocument } from '@comark/svelte'

  let { data } = $props()
</script>

<MarkdownDocument value={data.tree} />
```

The [SvelteKit example](https://comark.dev/examples/frameworks/sveltekit) 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](https://comark.dev/plugins/built-in/security) and pass it in the `plugins` prop:

```svelte
<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

::accordion
  :::accordion-item{label="Does @comark/svelte support Svelte 4?"}
  No. `@comark/svelte` provides Svelte 5 components that use runes such as `$state` and `$effect`.
  :::

  :::accordion-item{label="Why is my Markdown missing from the server HTML?"}
  `<Markdown>` renders only in the browser. Parse on the server and use `<MarkdownDocument>`, or use the experimental [`<MarkdownAsync>`](https://comark.dev/rendering/svelte#code-markdownasync-code-experimental).
  :::

  :::accordion-item{label="How do I map a component to a Svelte component?"}
  Pass a `components` map, for example `components={{ alert: Alert }}`. See [Svelte components](https://comark.dev/rendering/svelte#code-markdown-props-code-components).
  :::
::

## Next steps

- [Render streaming Markdown from an LLM](https://comark.dev/use-cases/ai-chat-streaming)
- [Let an LLM render UI components](https://comark.dev/use-cases/generative-ui)
- [Svelte streaming reference](https://comark.dev/rendering/svelte#streaming)
- [SvelteKit example](https://comark.dev/examples/frameworks/sveltekit)

---

- [Svelte rendering](https://comark.dev/rendering/svelte)
- [SvelteKit example](https://comark.dev/examples/frameworks/sveltekit)


## Sitemap

See the full [sitemap](https://comark.dev/sitemap.md) for all pages.
