Return Values

All the types route handlers can return and what they mean

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

The type is the intent

Every route handler returns a value, and the type of that value is the intent.Template means a full page, Fragment means one named template block, Pagelets Chirp choose between them based on the request, and a handful of others cover streaming, redirects, and mutations.

Chirp reads the return type and does content negotiation — picking full-page versus fragment, composing layouts, and adding htmx headers — for you. Nomake_response(), no jsonify(), no manual content-type wiring.

return "Hello"                                       # -> 200, text/html
return {"users": [...]}                              # -> 200, application/json
return Template("page.html", title="Home")           # -> 200, full page via kida
return Fragment("page.html", "results", items=x)     # -> 200, one rendered block
return Page("page.html", "results", **ctx)           # -> full page or block (auto)
return Suspense("dashboard.html", stats=get_stats()) # -> 200, shell + OOB swaps
return Stream("dashboard.html", **async_ctx)         # -> 200, streamed HTML
return EventStream(generator())                      # -> SSE stream
return MutationResult("/contacts", *fragments)       # -> fragments (htmx) or 303
return ValidationError("page.html", "form", errors=e)# -> 422 + re-rendered form
return Redirect("/login")                            # -> 302

This page is the map. Use the decision table to find your return type, then jump to that type's section.

Pick a return type

I want to… Return type
Render a full page (no htmx awareness) Template("page.html", **ctx)
Render one named block of a template Fragment("page.html", "block", **ctx)
Serve a full page to browsers and a fragment to htmx, from one handler Page("page.html", "block", **ctx)
Update several swap targets in one response OOB(main, *oob_fragments)
Paint a shell instantly, then stream slow sections in Suspense("page.html", stats=get_stats())
Stream page sections as they finish (no shell-first) Stream("page.html", **async_ctx)
Push updates after the page has loaded EventStream(async_generator())
Re-render a form with validation errors ValidationError("page.html", "form", errors=e)
Handle a mutation: fragments for htmx, redirect for plain POST MutationResult(url, *fragments)
Redirect Redirect("/login") (plain) or hx_redirect("/dashboard")(htmx-aware)

Template

Renders a full template via Kida:

from chirp import Template

@app.route("/")
def index():
    return Template("index.html", title="Home", items=items)

The first argument is the template path (relative to your template_dir). Everything else becomes template context.

Chirp auto-injectscurrent_path (set to request.path) into the context when you haven't set it. ChirpUI navigation components like sidebar_link(..., match="prefix") then work without passing current_pathfrom every handler.

Fragment

Renders a named block from a template without rendering the full page:

from chirp import Fragment

@app.route("/results")
def results(request: Request):
    return Fragment("search.html", "results_list", results=do_search(request))

Same template, same data, narrower scope. This is Chirp's core move — one template serves both the full page and the htmx swap. See Fragments for the full story.

Page

Auto-detects whether to return a full page or a fragment based on the request — full template for a browser, block for an htmx swap:

from chirp import Page

@app.route("/search")
def search(request: Request):
    results = do_search(request.query.get("q", ""))
    return Page("search.html", "results_list", results=results)

Pageis the preferred way to negotiate full-page versus fragment. Reach for it instead of branching on a request flag by hand.

Suspense

Paints the shell instantly, then streams real content in as each slow data source resolves. The browser gets the full page with skeleton placeholders, then OOB swaps fill them in:

from chirp import Suspense

@app.route("/dashboard")
async def dashboard():
    return Suspense("dashboard.html",
        stats=get_stats(),       # awaitable -> deferred, shell shows skeleton
        orders=get_orders(),     # awaitable -> deferred, shell shows skeleton
        title="Sales Dashboard", # plain value -> available in the shell
    )

In the template, branch on {% if stats is deferred %} — not bare {% if stats %}, which reads an empty resolved tuple/listas "still loading":

{% if stats is deferred %}
  <div class="skeleton">Loading…</div>
{% else %}
  {{ render_stats(stats) }}
{% end %}

Chirp resolves the awaitables concurrently, then streams each affected block as an OOB swap. Blocks are discovered automatically by static analysis. Two optional parameters override that:

  • defer_blocks — an explicit tuple of block names to re-render, bypassing analysis. Use it when deferred values pass through macro arguments the analyzer can't trace.
  • defer_map — maps a block name to a different DOM id for the swap target, e.g. {"stats": "stats-panel"}.

See Streaming HTML for the full mechanism.

Stream

Progressive HTML rendering with no shell-first step. Pass awaitable context values unawaited — Chirp resolves them concurrently up front, then the page chunks out so the browser paints before the full HTML is ready:

from chirp import Stream

@app.route("/dashboard")
async def dashboard():
    return Stream("dashboard.html",
        header=site_header(),     # plain value
        stats=load_stats(),       # coroutine -> resolved concurrently
        activity=load_activity(), # coroutine -> resolved concurrently
    )

EventStream

Server-Sent Events — push updates to the browser over a persistent connection after the page has loaded:

from chirp import EventStream, Fragment

@app.route("/notifications")
async def notifications():
    async def stream():
        async for event in notification_bus.subscribe():
            yield Fragment("components/notification.html", "item", event=event)
    return EventStream(stream())

The generator yields values — strings, dicts, Fragments, or SSEEvents. See Server-Sent Events and SSE patterns for the wire format and recipes.

MutationResult

Progressive enhancement for any mutation (POST, PUT, PATCH, DELETE). htmx requests get rendered fragments; plain requests get a redirect:

from chirp import MutationResult, Fragment

# ContactForm is a form shape; Chirp extracts it from the request body
# by the typed parameter annotation. See Forms & validation.
@app.route("/contacts", methods=["POST"])
async def add_contact(form: ContactForm):
    _add_contact(form.name, form.email)
    contacts = _get_contacts()
    return MutationResult(
        "/contacts",
        Fragment("contacts.html", "table", contacts=contacts),
        Fragment("contacts.html", "count", target="count", count=len(contacts)),
        trigger="contactAdded",
    )
  • Plain request: 303 redirect to the URL (fragments ignored)
  • htmx + fragments: renders the fragments, addsHX-Trigger if triggeris set
  • htmx + no fragments: sends anHX-Redirectheader

It works for every mutation method, not just form POST — aDELETE handler returning a refreshed list works the same way. For a redirect in both modes, return MutationResult("/dashboard")with no fragments.

ValidationError

Returns a 422 with a re-rendered form fragment — the standard htmx form-validation loop:

from chirp import ValidationError

@app.route("/submit", methods=["POST"])
async def submit(request: Request):
    form = await request.form()
    errors = validate(form)
    if errors:
        return ValidationError("form.html", "form_errors", errors=errors)
    # ... process valid form

See Forms & validation for the full pattern.

OOB (out-of-band)

Updates several parts of the page in one response — a main fragment plus extra out-of-band swaps:

from chirp import OOB, Fragment

@app.route("/update")
def update():
    return OOB(
        Fragment("page.html", "main_content", data=data),
        Fragment("page.html", "sidebar", stats=stats),
        Fragment("page.html", "notification_count", count=count),
    )

Combined with htmx's hx-swap-oob, this swaps multiple regions from a single request.

Redirect and hx_redirect

Redirectis a normal HTTP redirect (302 by default):

from chirp import Redirect

@app.route("/old-page")
def old_page():
    return Redirect("/new-page")

hx_redirect handles a handler reachable by either plain browser navigation or htmx. It returns a Response carrying both Location (for browser redirects) and HX-Redirect(for htmx full-page navigation):

from chirp import hx_redirect

@app.route("/login", methods=["POST"])
async def login(request: Request):
    # ... authenticate ...
    return hx_redirect("/dashboard")

Strings, dicts, and Response

Plain strings return astext/html; dicts serialize to JSON:

@app.route("/hello")
def hello():
    return "Hello, World!"      # text/html, 200

@app.route("/api/status")
def status():
    return {"status": "ok"}     # application/json, 200

For full control, return a Response with a chainable .with_*()API:

from chirp import Response

@app.route("/api/create")
async def create():
    return Response(body=b'{"id": 42}', status=201).with_header(
        "Content-Type", "application/json"
    )

See Request & Response for the chainable API.

InlineTemplate (prototyping)

Template.inline() renders a template from a string instead of a file — a prototyping shortcut for scripts where you don't want a templates/directory yet:

from chirp import Template, InlineTemplate

@app.route("/")
def index() -> InlineTemplate:
    return Template.inline("<h1>{{ greeting }}</h1>", greeting="Hello, world!")

Advanced and internal