---
title: Route responses
description: Return synchronous, buffered async, or streaming SSR.
---

Handlers return `RouteResponse` when they want Proa's framework finalizer to render a component and finalize the document.

Use ordinary Axum responses for JSON APIs, form handlers, early status codes, redirects, and handler-level errors. See [Route handlers](/docs/framework/route-handlers) for endpoint patterns and [Status, redirects, and errors](/docs/framework/errors) for 404 pages, response headers, streaming constraints, and public error bodies.

## Synchronous SSR

Use `RouteResponse::ssr` for ordinary `WebRenderSync` components.

```rust
use proa::axum::RouteResponse;

pub async fn handler() -> RouteResponse {
    RouteResponse::ssr(HomePage)
}
```

Write ordinary page components as `WebRenderSync<L>`; the framework supplies
the owned `RenderBuffer` response writer.

## Async SSR

Use `RouteResponse::ssr_async` for components that implement `WebRender` and do not use a loader. The full render completes before the response begins, so root errors, status, and headers remain available to the HTTP layer.

```rust
pub async fn handler() -> RouteResponse {
    RouteResponse::ssr_async(PageWithAwait)
}
```

For typed data loaders, return `impl IntoResponse` and pass the loader explicitly:

```rust
use std::sync::Arc;

pub async fn handler() -> impl axum::response::IntoResponse {
    let loader = Arc::new(MyLoader::new());
    RouteResponse::ssr_async_with_loader(Page, loader)
}
```

Prefer typed loaders when the concrete loader type is known. Use the `*_dyn` constructors only at real dynamic boundaries.

See [Data loading and streaming](/docs/framework/data-loading) for loader keys, cache hints, request-local dedupe, and preloading.

## Streaming SSR

Use `ssr_stream` or `ssr_stream_with_loader` when the page should flush work incrementally.

```rust
pub async fn handler() -> impl axum::response::IntoResponse {
    let loader = Arc::new(MyLoader::new());
    RouteResponse::ssr_stream_with_loader(StreamedPage, loader)
}
```

Streaming responses default to out-of-order chunked rendering. Use `with_render_mode` when a route needs a different render mode.

Use [Data loading and streaming](/docs/framework/data-loading) for `Suspense` boundaries, `PreparedLoader`, and streamed data patterns.

`RouteResponse::ssr_stream*` is the HTML response surface. For generated `text/markdown` responses, use `proa::stream::render_md_in_order_to_flusher` or `InOrderMarkdownDriver` from a custom handler/adapter and set the response content type yourself. See [Streaming Markdown Output](/docs/framework/data-loading#streaming-markdown-output).

## Responses outside the render pipeline

Already-rendered HTML is an ordinary HTTP response, not a render plan. Return Axum's `Html` response directly:

```rust
use axum::response::Html;

pub async fn handler() -> Html<&'static str> {
    Html("<main>Already rendered</main>")
}
```

If a compile-time HTML fragment must participate in Proa finalization or browser navigation, treat it as a renderable node instead of a response kind:

```rust
RouteResponse::ssr(proa::static_html(b"<main>Static fragment</main>"))
```

Serve browser-rendered applications through Axum file services and configure their fallback routing explicitly. `RouteResponse` handles deferred server rendering.

## Rendering without a server

`RouteResponse::render_to_bytes` finalizes a response into one HTML document with no HTTP server, no island manifest, and no async runtime. It applies the same managed head and RSJS runtime tags the Axum adapter would, so static exports, snapshot tests, and plain `fn main` programs render the exact bytes a route serves.

```rust filename="src/main.rs"
use proa::HeadConfig;
use proa::framework::RouteResponse;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let html = RouteResponse::ssr(RootDocument { children: HomePage })
        .with_head(HeadConfig::default())
        .render_to_bytes()?;
    std::fs::write("./output/index.html", html)?;
    Ok(())
}
```

`render_to_string` returns the same document as a `String`, and the `proa::axum::RouteResponse` wrapper exposes both methods. What differs from a served request:

- Async plans render buffered. `Suspense` content appears inline rather than as a fallback plus a later swap, and the streaming driver is never involved.
- There is no request. The base path is the origin root, route parameters are absent, and `render_metadata_head` resolves to nothing. Pages that read those should go through the built router with `tower::ServiceExt::oneshot`.
- Headers are discarded, and a status outside 2xx is returned as `FrameworkError::StaticStatus` instead of being written out as a page.
- Call it from synchronous code. Inside async code, await `into_framework_response`; an async plan called from a current-thread tokio runtime returns `FrameworkError::StaticRenderInsideRuntime` rather than deadlocking.

Programs that only render this way can depend on `proa_framework_core` with `default-features = false`, which leaves tokio out of the build. Eager `proa_stream` preloads need a tokio runtime and are the one feature that does not work there.
