---
title: Status, redirects, and errors
description: Set status codes, return redirects, and render error pages.
---

A Proa route is an Axum handler: return an Axum response to decide early, or `RouteResponse` to let the Proa finalizer render a page. This page covers [status in the handler](#setting-status-in-the-handler), [rendered 404 pages](#rendering-a-404-page), [redirects](#redirecting), and [framework errors](#handling-framework-errors).

## Setting status in the handler

Return ordinary Axum responses when the handler can decide before rendering:

```rust filename="src/routes/product.rs"
use axum::{
    extract::Path,
    http::StatusCode,
    response::{IntoResponse, Response},
};
use proa::axum::RouteResponse;

pub async fn product(Path(id): Path<String>) -> Response {
    let Some(product) = load_product(&id).await else {
        return (StatusCode::NOT_FOUND, "Not found").into_response();
    };

    RouteResponse::ssr(ProductPage { product }).into_response()
}
```

This keeps missing records, auth failures, and validation failures out of the render tree when they do not need a full page shell.

Some error states do need that shell, and those become real pages.

## Rendering a 404 page

Render a full Proa document when the error state needs the site's layout, metadata, or island bootstrap. For a managed document, declare the selected Page and set its status in the head hook:

```rust filename="src/pages/not_found.rs"
use http::StatusCode;
use proa::{ComponentRole, DataLoader, SyncHeadOperation, WebContext, WebRenderSync, WriteError};
use proa::html_sync;

pub struct NotFoundPage;

impl<L: DataLoader> WebRenderSync<L> for NotFoundPage {
    const ROLE: ComponentRole = ComponentRole::PAGE;

    fn render_head(&self, cx: &mut WebContext<L>) -> impl SyncHeadOperation {
        if !cx.response_mut().set_status(StatusCode::NOT_FOUND) {
            return Err(WriteError::render("response headers already committed"));
        }
        cx.head_mut().title("Page not found")
    }

    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            <main>
                <h1>"Page not found"</h1>
                <p>"The page may have moved or no longer exists."</p>
            </main>
        }
        .render(cx)
    }
}
```

Compose it inside the `RootDocument` from [Layouts and Pages](/docs/framework/layouts-and-pages), then return `RouteResponse::ssr(RootDocument { children: NotFoundPage })`. Enable managed metadata with `FrameworkBuilder::with_head(...)` or the response's `with_head(...)`. This hook works for buffered and streamed documents; the status is decided before the Page body starts.

The same response context also stages headers and cookies.

## Setting headers during render

Components stage response metadata through `cx.response_mut()`:

```rust filename="src/pages/not_found.rs"
use http::{header, HeaderValue, StatusCode};

cx.response_mut().set_status(StatusCode::CREATED);
cx.response_mut().insert_header(
    header::CACHE_CONTROL,
    HeaderValue::from_static("public, max-age=60"),
);
cx.response_mut().append_set_cookie(HeaderValue::from_static("theme=dark; Path=/; SameSite=Lax"));
```

`set_status`, `insert_header`, `append_header`, and `append_set_cookie` return `bool`. Treat `false` as a late mutation.

Late mutations are exactly what streaming makes possible, so streamed routes need a tighter rule.

## Setting status on a streamed route

Set status and headers in the handler when the decision is already known:

```rust filename="src/pages/dashboard.rs"
use axum::http::{header, StatusCode};

(
    StatusCode::OK,
    [(header::CACHE_CONTROL, "private, no-store")],
    RouteResponse::ssr_stream(DashboardPage),
)
```

For managed documents, layout and Page `render_head` hooks can also stage status, headers, and cookies before publication. Put data-dependent HTTP decisions there, before the selected Page body starts. Once the Page metadata is sealed and the head outlet is available, Proa publishes those decisions; `can_mutate_headers()` becomes `false` and later mutations are refused.

Manual streaming documents commit their response head before root rendering, so make their HTTP decisions in the handler. Buffered sync and async routes can stage response metadata until finalization. See [Metadata and streaming](/docs/framework/metadata#metadata-and-streaming).

Redirects avoid the problem entirely: they never enter the render tree.

## Redirecting

Use Axum redirects for moved routes, auth gates, and canonical URLs:

```rust filename="src/routes/legacy.rs"
use axum::response::Redirect;

pub async fn old_docs_path() -> Redirect {
    Redirect::permanent("/docs/framework")
}
```

When a handler may either redirect or render, return `Response`:

```rust filename="src/routes/account.rs"
use axum::response::{IntoResponse, Redirect, Response};
use proa::axum::RouteResponse;

pub async fn account() -> Response {
    if !is_signed_in().await {
        return Redirect::to("/login").into_response();
    }

    RouteResponse::ssr(AccountPage).into_response()
}
```

Your own error types can carry the same decision, once you give them a status code.

## Mapping application errors

Map application errors to explicit status codes. Keep sensitive details in logs, not response bodies:

```rust filename="src/error.rs"
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
};

pub enum AppError {
    NotFound,
    Unauthorized,
    Upstream,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            Self::NotFound => (StatusCode::NOT_FOUND, "Not found").into_response(),
            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
            Self::Upstream => (StatusCode::BAD_GATEWAY, "Temporary upstream error").into_response(),
        }
    }
}
```

Then use `Result<Response, AppError>` or `Result<impl IntoResponse, AppError>` in handlers.

Failures below your code, in render or transport, never reach an `AppError`.

## Handling framework errors

The framework protects the response shape for infrastructure failures:

| Source | Response |
|--------|----------|
| Render, bootstrap, or invariant error | `500` with a terse body such as `render error` |
| Encoding error | `500 encoding error` |
| Default request timeout | `408 Request Timeout` |
| Rejected cross-origin unsafe browser request | `403 Forbidden` |
| Missing embedded static asset | `404 Not Found` |

Use `tracing` for the detailed error path and return short public messages to users.

## Checking the error paths

Before shipping:

- Decide in the handler when data is missing before rendering.
- Render a full error page only when it needs the site shell.
- Set streaming HTTP decisions in the handler or, for managed documents, in a layout/Page head hook before publication.
- Check the `bool` returned from response-context mutations in streaming code.
- Use Axum `Redirect` for redirects instead of rendering meta-refresh pages.
- Test `404`, redirect, validation, timeout, and upstream-error paths.

## Next steps

- [Route responses](/docs/framework/responses)
  - Return synchronous, buffered async, or streaming SSR with islands.
- [Data loading and streaming](/docs/framework/data-loading)
  - Load request data, apply cache hints, and stream slow sections with Suspense.
- [Forms and actions](/docs/guides/forms-actions)
  - Handle submissions with Axum and render invalid field state back to the user.
- [Testing](/docs/guides/testing)
  - Unit, snapshot, and integration tests for the paths above.
