# Chat message as Markdown

> An assistant answer rendered as Markdown with Streamdown: a caret while it streams, static once stored, and only safe elements and links.

- Block: [ai-chat](https://bettersupabase.com/blocks/ai-chat.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/chat-message-markdown

## Install the block

```bash
npx better-supabase add ai-chat
```

## Web (Next.js)

From `apps/examples/nextjs/src/features/assistant/components/message-text.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/assistant/components/message-text.tsx):

```tsx
"use client";

import { code } from "@streamdown/code";
import { Streamdown } from "streamdown";

/**
 * Model output is untrusted: only these elements render (no images, raw HTML or
 * forms), and a link must be http, https or mailto.
 */
const ALLOWED_ELEMENTS = [
  "p",
  "br",
  "hr",
  "strong",
  "em",
  "del",
  "a",
  "ul",
  "ol",
  "li",
  "blockquote",
  "h1",
  "h2",
  "h3",
  "h4",
  "h5",
  "h6",
  "code",
  "pre",
  "table",
  "thead",
  "tbody",
  "tr",
  "th",
  "td",
];

const SAFE_URL = /^(https?:|mailto:|#)/i;

const safeUrl = (url: string): string => (SAFE_URL.test(url) ? url : "");

const PLUGINS = { code };

/**
 * An answer's text as Markdown. While it streams, the caret follows the last
 * word; a stored answer renders once, in static mode.
 */
export function MessageText({
  text,
  animating,
}: {
  text: string;
  animating: boolean;
}) {
  return (
    <Streamdown
      mode={animating ? "streaming" : "static"}
      plugins={PLUGINS}
      isAnimating={animating}
      caret="block"
      allowedElements={ALLOWED_ELEMENTS}
      urlTransform={safeUrl}
      linkSafety={{ enabled: true }}
    >
      {text}
    </Streamdown>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Chat message as Markdown" in a React web app that uses shadcn/ui components. An assistant answer rendered as Markdown with Streamdown: a caret while it streams, static once stored, and only safe elements and links.

It uses the better-supabase `ai-chat` block (AI chat). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add ai-chat` and apply the migration.
2. The recipe needs no extra UI components.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/assistant/components/message-text.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/chat-message-markdown.md
- Block docs: https://bettersupabase.com/docs/blocks/ai-chat
```
