---
title: Markdown rendering
description: Compose Markdown as one of a component's render targets, sharing data and typed children across formats.
---

Markdown is an interface for documentation, feeds, command-line clients, and content inside conversations. These consumers need a useful representation of the same application data a browser displays.

Proa makes rendering a trait rather than a fixed output format. A Rust component can share fields and typed children across HTML, Markdown, and other render targets, defining its output for each target. HTML uses `WebRenderSync` and Markdown uses `MdRenderSync`; other targets can define their own rendering traits. Use `WebRender` and `MdRender` when the HTML or Markdown implementation needs to await work.

For an agent consuming content, direct Markdown rendering also avoids extracting text from browser markup. The Markdown implementation chooses which content and structure to include, using the same component data as the HTML implementation.

```rust filename="src/components/product_summary.rs"
use proa::{DataLoader, MdRenderSync, WebContext, WriteError};
use proa::md_sync;

pub struct ProductSummary {
    pub title: &'static str,
    pub description: &'static str,
}

impl<L: DataLoader> MdRenderSync<L> for ProductSummary {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            ## {self.title}

            {self.description}
            "###
        }
        .render_md(cx)
    }
}
```

That makes Markdown composable. Components nest, loop, and take children, so a `SearchPage` builds a document out of fifty `Listing` components and none of them concatenates a string. Change `Listing` and every document containing one changes.

## Writing a template

Reach for this when a route serves docs source, an agent-readable representation, a feed, or any dense text output that is not HTML.

Follow [Editor setup](/docs/guides/editor-setup) for go-to-definition, hover,
completion, and rename inside `r###"..."###` templates in VS Code, Neovim,
and other LSP editors.

> **Good to know**: `cargo proa fmt` re-anchors a template to its macro's indentation and `cargo proa lint` reports one that has drifted, but both tools only see brace-delimited invocations. Write `md_sync! { ... }`, not `md_sync!( ... )`.

## Choosing a trait and a macro

| Trait | Template macro | Use it when |
|-------|----------------|-------------|
| `MdRenderSync<L, B>` | `md_sync!` | The component writes Markdown without `.await`. |
| `MdRender<L, B>` | `md!` | The component may await, or composes async Markdown children. |

HTML and Markdown share `WebContext<L, B>`. Both Markdown traits default to
`L = NoLoader` and `B = RenderBuffer`, in that order. Application components
usually implement `MdRenderSync<L>` or `MdRender<L>` and take
`&mut WebContext<L>`; the owned buffer needs no lifetime parameter. Keep `B` as
the second generic only when a component needs to support custom writers.

Create an owned root with `WebContext::new()`, render with
`component.render_md(&mut cx)`, and consume `cx.into_output()` afterward.
`WebContext::with_buffer(&mut out)` also accepts a borrowed writer for components
implemented over that writer type.

Use `md_sync!` by default:

```rust filename="src/pages/summary.rs"
use proa::md_sync;

let summary = md_sync! { r###"
    # {title}

    {description}
    "###
};
```

Use `md!` when an interpolated child awaits:

```rust filename="src/pages/report.rs"
use proa::render_md_to_string_async;
use proa::md;

let output = render_md_to_string_async(
    md! { r###"
        # Report

        {proa::await_md_component(load_report_body())}
        "###
    },
)
.await;
```

Strings and numbers work directly in both macros. Lift a custom sync-only child into an async tree with `to_md_async(...)`, and use `await_md_component(...)` when a future resolves to a Markdown child.

The canonical form is one multiline raw Markdown template. Use three hashes by default: `md_sync! { r###"..."### }` or `md! { r###"..."### }`. Source newlines render directly, so ordinary paragraphs, sections, and lists do not need `"\n"` or `"\n\n"`. Common Rust source indentation is stripped while deeper relative Markdown indentation is preserved.

Each `{expression}` is a typed render child. Slots contain ordinary Rust control flow; branches re-enter Markdown through nested templates. Braces inside fenced code blocks and inline code spans remain literal Markdown. Outside code, write `{{` or `}}` for a literal brace. Three hashes let ordinary quotes and Rust raw strings with fewer hashes appear without escaping; increase the delimiter only when the document itself contains the exact closing sequence `"###`.

The body is always one string literal. Token composition such as `md_sync! { "# " { title } }` is a compile error that points back to the string form. To render children in sequence without authoring Markdown, use a tuple: `(overview, impact).render_md(cx)`.

An async child cannot appear inside `md_sync!`. Move the boundary to `MdRender` and `md!`.

The Markdown macros use the default-enabled `markdown` feature of `proa`.
An HTML/XML-only application can disable default features to omit the Markdown
parser dependencies. If defaults are disabled, opt in with `features = ["markdown"]`
when using `md!` or `md_sync!`. Cargo enables a feature if any dependency requests it.

## Loops and branches

The template has one lexical rule at every depth: text outside `{ ... }` is Markdown, and text inside is a Rust expression. Control flow is ordinary Rust, exactly as in `html_sync!`, and a branch renders only its final expression. Earlier statements retain ordinary Rust side effects; a semicolon discards a value. Compose multiple fragments in one nested template. Markdown re-enters a branch through a nested `md_sync!` call:

```rust filename="src/pages/summary.rs"
let summary = md_sync! { r###"
    # {title}

    ## Features

    {for feature in features.iter() {
        md_sync! { "- {feature}\n" }
    }}

    {if in_stock {
        md_sync! { "**In stock**" }
    } else {
        md_sync! { "Backordered" }
    }}
    "###
};
```

A bare string tail such as `"Backordered"` renders as escaped text; markup needs the nested template. Use ordinary one-line string templates for short fragments, such as `md_sync! { "- {feature}\n" }`. Keep the row-ending `\n` so iterations do not run together. Reserve raw literals for substantial multiline sections or when they substantially reduce escaping, and use fewer hashes for nested raw literals:

```rust filename="src/pages/report.rs"
md_sync! { r###"
    {for entry in self.entries {
        md_sync! { r#"
            ### {entry.question}

            {entry.answer}

            "#
        }
    }}
    "###
}
```

Because plain Markdown is never parsed inside a slot, `if`, `for`, and `match` are ordinary words in prose, and a line means the same thing at the top level and inside a branch. Writing Markdown directly inside a branch is a compile error that points at the nested-template form. Both macros share this grammar; in `md!` the nested templates and interpolated children may await.

> **Good to know**: Interpolation, slots, and children follow the same rules as the HTML macros. See [Components](/docs/core/components) for the composition model; the difference here is that a Markdown template is one string literal, so control flow lives inside it.

## Composing documents from components

A slot holds a component, not a string. Give the child an `MdRenderSync` impl:

```rust filename="src/components/listing.rs"
impl<L: DataLoader> MdRenderSync<L> for &Listing {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            ### {self.title}

            **${self.price}/night** · ★{self.rating}
            "###
        }
        .render_md(cx)
    }
}
```

Then interpolate it. The `{listing}` slot dispatches into that impl, the same way an `html_sync!` slot dispatches into a `WebRenderSync` child:

```rust filename="src/pages/search.rs"
impl<L: DataLoader> MdRenderSync<L> for &SearchPage {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            # {self.title}

            {self.listings.len()} stays

            ---

            {for listing in self.listings.iter() { listing }}
            "###
        }
        .render_md(cx)
    }
}
```

```md filename="output"
# Malibu stays

2 stays

---

### Cliffside cottage

**$420/night** · ★4\.9

### Surf shack

**$180/night** · ★4\.7
```

The page never learns that a listing renders as an `h3`, and never allocates a `String` for one.

Note the `4\.9`: `rating` is a string here, and string interpolations are Markdown-escaped, so data cannot inject structure. A title of `# Free money` arrives as text, not a heading. Numbers are written verbatim, so an `f64` rating would print `4.9`.

## One component, multiple representations

Each render target has its own implementation on the same struct. Here, the HTML implementation emits a browser callout and the Markdown implementation emits a blockquote. The same component data can support other formats or rendering backends through additional implementations:

```rust filename="src/components/callout.rs"
impl<L: DataLoader> WebRenderSync<L> for &Callout {
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            <aside data-callout role="note">
                <strong>{self.label}</strong>
                <p>{self.body}</p>
            </aside>
        }
        .render(cx)
    }
}

impl<L: DataLoader> MdRenderSync<L> for &Callout {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            > **{self.label}:** {self.body}
            "###
        }
        .render_md(cx)
    }
}
```

One value, rendered two ways:

```html filename="Accept: text/html"
<aside data-callout role="note"><strong>Note</strong><p>Cross-origin writes return 403.</p></aside>
```

```md filename="Accept: text/markdown"
> **Note:** Cross\-origin writes return 403\.
```

A component with no text form renders its content instead: a button becomes its label, a tab strip becomes its panels. The component decides, because it is the only thing that knows. See [Content negotiation](/docs/framework/content-negotiation) for serving both from one URL.

## Block boundaries: the contract that makes nesting safe

Markdown is whitespace-sensitive, which is what usually makes it uncomposable. A heading is only a heading at the start of a line, and a component cannot know what its previous sibling wrote.

A physically multiline raw template establishes block boundaries automatically:

```rust filename="src/components/section.rs"
impl<L: DataLoader> MdRenderSync<L> for Heading {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r#"
            ## {self.0}
            "# }.render_md(cx)
    }
}
```

It writes only the newlines that are missing: two of these back to back produce one blank line, not four, and the first one in a document writes nothing.

```md filename="output"
Intro line.

## First

## Second
```

That is what lets any block component sit next to any other, in any order.

An ordinary-string template selects inline mode and adds no automatic paragraph boundaries. Explicit newlines, such as the one in `md_sync! { "- {item}\n" }`, remain output. Use an inline template for a label:

```rust filename="src/components/button/md.rs"
md_sync! { "{self.children}" }.render_md(cx)?;
```

The mode is selected when the template renders, not when it is constructed. Nested scopes restore their enclosing mode on success, error, and panic unwinding. Async `md!` also restores it when a pending render is dropped. No closure or manual `md_set_inline` restoration is needed. Partial bytes and boundary tracking remain live after failure; error cleanup adds no trailing boundary.

Block templates inside an inline scope are errors, including typed children in link labels and table cells. A directly rendered qualified block macro is rejected at compile time when detectable; other block children fail at render time before writing their boundary or body. Earlier prefix bytes remain. Supply an inline representation instead of silently flattening a block.

Raw-string hashes only control delimiting. All physically multiline raw literals select block mode, including `r"..."`; single-line raw literals select inline mode. Ordinary strings stay inline even when they contain newlines. Existing dedenting and authored whitespace are unchanged: only automatic boundaries are deduplicated, and an empty block can still establish a boundary after preceding prose. The line breaks directly after a slot at block position are boundaries too: written after inline text, skipped after a child that already ended its line or paragraph, so a block child on its own line keeps exactly one blank line after it.

Inline/block scopes restore rendering mode only. Do not restore a full `MdState` snapshot to clean up formatting: it would replace suffix tracking without undoing emitted bytes.

## Embedded HTML

Markdown permits inline HTML, but keep it on the typed HTML path instead of mixing an HTML parser into the Markdown macro:

```rust filename="src/components/callout.rs"
use proa::html_sync;

cx.render_html_in_md(html_sync! {
    <aside data-callout>
        <strong>{self.title}</strong>
    </aside>
})?;
```

`WebContext::render_html_in_md(...)` reuses the current loader, output, and request metadata while tracking Markdown boundaries. Its child receives an observed writer; custom HTML components used here must support generic `B: WriteBuf`. `html_sync!` applies the normal compile-time HTML validation and escaping rules.

> **Good to know**: Slots, children, and branching behave identically to the HTML macros. See [Components](/docs/core/components); everything there applies with `md_sync!` in place of `html_sync!`.

## Data loading

Markdown uses the same `DataLoader` API as HTML. An `MdRender<L>` implementation
can await `cx.load_typed(...)`, `cx.load_bytes(...)`, or `cx.load_json(...)`.
Start the root with `WebContext::with_loader(Arc::clone(&loader))`; nested
Markdown children and tracked HTML bridges keep that same loader instance.
`MdRenderSync<L>` can participate in the same tree, but cannot await a load.
See [Data loading](/docs/framework/data-loading) for request-scoped loaders.

For an async Axum route, return the plan from
`negotiate_async_with_loader(...)` or `explicit_markdown_async_with_loader(...)`.
The adapter creates the local render future during finalization, so the handler
can remain `Send` without requiring a `Send` Markdown render future.

## Streaming Markdown

Async Markdown roots can use the in-order stream driver from `proa_stream`:

```rust filename="src/pages/report.rs"
use std::rc::Rc;

use proa::StreamChunkFlusher;
use proa::stream::render_md_in_order_to_flusher;

let flusher: Rc<dyn StreamChunkFlusher> = Rc::new(MyMarkdownFlusher::new());

render_md_in_order_to_flusher(PageMd, flusher).await?;
```

The driver renders through the shared `WebContext` into the standard `RenderBuffer`, flushes through `StreamChunkFlusher`, and preserves Markdown-local state across `Suspense` boundaries. Pass an existing request loader with `render_md_in_order_to_flusher_with_loader(PageMd, loader, flusher)` when the page loads data.

Use it for `text/markdown` responses where a generated document should stream top to bottom instead of buffering the whole body first.

## Next steps

- [HTML rendering](/docs/core/html-rendering)
  - Render typed Rust values to HTML with render traits and the html! macros.
- [Components](/docs/core/components)
  - Nest components, pass children, and branch inside a render tree.
- [Streaming SSR](/docs/core/streaming-ssr)
  - Flush the page shell first and stream slow sections as they resolve.
- [Sink](/docs/core/sink)
  - Migrate deprecated page-pool writers to RenderBuffer.
- [Content negotiation](/docs/framework/content-negotiation)
  - Serve HTML and Markdown representations of one resource through HTTP content negotiation.
