The Key Innovation
Most Python frameworks treat templates as "render a full page, return a string." Chirp can render a named block from a template independently, without rendering the rest of the page.
This is what makes htmx integration seamless. The browser requests a fragment, the server returns just the block it needs.
Fragment
Fragmentrenders a specific block from a template:
from chirp import Fragment
@app.route("/search")
def search(request: Request):
results = do_search(request.query.get("q", ""))
if request.is_fragment:
return Fragment("search.html", "results_list", results=results)
return Template("search.html", results=results)
Arguments:
Template path
Path to the template file (relative totemplate_dir).
Block name
The named block to render. Must exist in the template.
Keyword arguments
Become the rendering context passed to the template.
The template:
{% extends "base.html" %}
{% block content %}
<input type="search" hx-get="/search" hx-target="#results" name="q">
{% block results_list %}
<div id="results">
{% for item in results %}
<div class="result">{{ item.title }}</div>
{% endfor %}
</div>
{% endblock %}
{% endblock %}
Full page request renders everything (base layout + content + results). Fragment request renders only results_list -- the <div id="results">and its contents.
request.is_fragment
TheRequestobject detects htmx requests automatically:
request.is_fragment # True if HX-Request header present
request.htmx_target # Value of HX-Target header (e.g., "#results")
request.htmx_trigger # Value of HX-Trigger header
request.is_history_restore # True if htmx history restore
Page
Pageis syntactic sugar that auto-detects whether to return a full page or a fragment:
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)
If request.is_fragment is True, it renders the block. Otherwise, it renders the full template. This eliminates the if/elsepattern.
When a route needs two fragment scopes, passpage_block_name:
return Page(
"search.html",
"results_list",
page_block_name="page_root",
results=results,
)
results_liststays the narrow fragment target for explicit swapspage_rootbecomes the fragment-safe root for boosted navigation
OOB (Out-of-Band Swaps)
Sometimes a single action needs to update multiple parts of the page.OOBsends a primary fragment plus additional out-of-band fragments in one response:
from chirp import OOB, Fragment
@app.route("/cart/add", methods=["POST"])
async def add_to_cart(request: Request):
item = await add_item(request)
return OOB(
Fragment("cart.html", "cart_items", items=get_cart()),
Fragment("layout.html", "cart_count", count=cart_count()),
Fragment("layout.html", "total_price", total=cart_total()),
)
The first fragment is the main response. Additional fragments are appended with hx-swap-oob="true", so htmx swaps them into the correct locations on the page.
ValidationError
A specialized fragment for form validation errors. Returns a 422 status:
from chirp import ValidationError
@app.route("/register", methods=["POST"])
async def register(request: Request):
form = await request.form()
errors = validate_registration(form)
if errors:
return ValidationError("register.html", "form_errors", errors=errors)
# ... create user
This renders the form_errors block with a 422 status code, which htmx can handle with hx-target-422or a custom error handler.
Block Availability
render_block()resolves inherited blocks. You can render a parent-defined page root from a child template, and child overrides still win inside that parent block.
{# child.html #}
{% extends "base.html" %}
{% block search_results %}
<div id="results">...</div>
{% endblock %}
{# base.html #}
{% block page_root %}
<section class="page-shell">
{% block search_results %}{% endblock %}
</section>
{% endblock %}
In this shape:
render_block("search_results")returns only the inner results fragmentrender_block("page_root")returns the full page shell with the child'ssearch_resultsblock injected
How Chirp Finds Blocks
Chirp uses Kida'stemplate_metadata()to introspect templates at build time.
Block names, regions, and dependencies come from the AST — Chirp never hard-codes
which blocks exist. That enables:
- Validation —
fragment_blockandpage_blockare checked before render - OOB discovery — Blocks named
*_oobare discovered automatically for app shells - Layout contracts —
depends_onandcache_scopefrom each block drive when OOB regions are rendered
See Kida Integration for the full flow.
Regions for Shell OOB
{% region %}is the preferred pattern for app shell updates (breadcrumbs, sidebar, title). One definition serves both full-page slots and OOB swaps — no duplication.
Minimal layout example:
{% region breadcrumbs_oob(breadcrumb_items=[{"label":"Home","href":"/"}]) %}
{{ breadcrumbs(breadcrumb_items) }}
{% end %}
{% region title_oob(page_title="My App") %}
<title id="chirpui-document-title" hx-swap-oob="true">{{ page_title }}</title>
{% end %}
{% region sidebar_oob(current_path="/") %}
{{ sidebar(current_path=current_path) }}
{% end %}
{% call app_shell(brand="My App") %}
{% slot topbar %}
{{ breadcrumbs_oob(breadcrumb_items=breadcrumb_items | default([{"label":"Home","href":"/"}])) }}
{% end %}
{% slot sidebar %}
{{ sidebar_oob(current_path=current_path | default("/")) }}
{% end %}
{% block content %}{% end %}
{% end %}
ChirpUI's breadcrumbs_oob, sidebar_oob, and title_oob map to chirpui-topbar-breadcrumbs, chirpui-sidebar-nav, and chirpui-document-title automatically. See Kida Integration for the full flow and examples/chirpui/shell_oobfor a complete reference.
Block-Heavy Layouts
Templates with many blocks (extends, nested blocks, fragments) benefit from a clear structure. Use the extension block pattern so child templates can add content without replacing parent layout.
Boost Layout Pattern
Extendchirp/layouts/boost.htmlfor htmx-boost + SSE apps. The layout defines stable blocks:
| Block | Purpose |
|---|---|
title |
Page title |
head |
Extra head content (styles, meta) |
head_style |
Inline CSS (e.g. view-transition overrides) |
body_before |
Content before#main |
content |
Main content (inside#main, swapped on navigation) |
sse_scope |
SSE connection (outside#mainso it persists) |
body_after |
Scripts (event delegation, theme toggle) |
content is swapped on navigation; sse_scope and body_after stay in place. Put event delegation and other scripts in body_afterso they run once and work for dynamically swapped content.
Extension Blocks
Since Kida doesn't supportsuper(), use explicit extension blocks in your base template:
{% block head %}
<link rel="stylesheet" href="/css/base.css">
{% block extra_head %}{% end %}
{% end %}
{% block body_after %}
<script src="/js/main.js"></script>
{% block extra_scripts %}{% end %}
{% end %}
Child templates override extra_head or extra_scriptsto add content without replacing the base.
Nesting for Fragments
Define fragment blocks inside the block that gets swapped. For example, ifpage_root is the page-level swap target, put results_list inside it so the fragment target (#results) exists in the DOM.
Next Steps
- Return Values -- All return types
- Server-Sent Events -- Push fragments in real-time
- htmx Patterns -- Common fragment patterns