Stream Markdown in Angular with the AI SDK
Use the Chat class from @ai-sdk/angular to receive messages, and render each text part with <comark-markdown [value] [streaming] [caret]>. Comark closes unfinished syntax while the part streams, and runs a full parse when it is complete.
@comark/angular supports Angular 17 or later. See the requirements for the exact range. The Chat class from @ai-sdk/angular stores its state in Angular signals, so the template updates as tokens arrive.
Build the chat
Install the packages
npm install @comark/angular ai @ai-sdk/angularCreate the server route
Chat sends requests to /api/chat by default. This Express route streams the model response in the format that Chat expects:
import express from 'express'
import { convertToModelMessages, streamText } from 'ai'
const app = express()
app.use(express.json())
app.post('/api/chat', async (req, res) => {
const result = streamText({
model: 'anthropic/claude-sonnet-4.6',
messages: await convertToModelMessages(req.body.messages),
})
result.pipeUIMessageStreamToResponse(res)
})
app.listen(3000)Configure the Angular dev server to proxy /api to this server. The server API can change between AI SDK versions. Check the AI SDK docs for your version.
Render the messages
Read chat.messages in the template. Pass part.state === 'streaming' to the streaming input:
import { Component } from '@angular/core'
import { Chat } from '@ai-sdk/angular'
import { Markdown } from '@comark/angular'
@Component({
selector: 'app-chat',
standalone: true,
imports: [Markdown],
template: `
@for (message of chat.messages; track message.id) {
@for (part of message.parts; track $index) {
@switch (part.type) {
@case ('text') {
@if (message.role === 'user') {
<p>{{ part.text }}</p>
} @else {
<comark-markdown [value]="part.text" [streaming]="part.state === 'streaming'" [caret]="true" />
}
}
}
}
}
<form (submit)="send($event, prompt)">
<input #prompt placeholder="Ask something…" />
</form>
`,
})
export class ChatComponent {
chat = new Chat({})
send(event: Event, prompt: HTMLInputElement) {
event.preventDefault()
this.chat.sendMessage({ text: prompt.value })
prompt.value = ''
}
}Map components and add plugins
Use the components input to map Comark tags to Angular components. Use the plugins input to add parser plugins, for example the security plugin for untrusted output. This excerpt adds two properties to the component above:
import security from '@comark/angular/plugins/security'
import { AlertComponent } from './alert.component'
export class ChatComponent {
chat = new Chat({})
components = { alert: AlertComponent }
plugins = [security({ blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style', 'object'] })]
}<comark-markdown
[value]="part.text"
[streaming]="part.state === 'streaming'"
[caret]="true"
[components]="components"
[plugins]="plugins"
/>Comark passes component props as @Input() values. The default slot maps to <ng-content />. See component bindings.
FAQ
@comark/angular doesn't depend on a bundler. It works with the Angular CLI, Vite with AnalogJS, webpack, and esbuild.[caret]="{ class: 'my-caret' }" and style that class. See caret.<comark-markdown-document [value]="document" /> with a document from parseMarkdown(). It renders the document without a parse step. See <comark-markdown-document>.