Overview
Boosted navigation turns an ordinary<a href="...">click into an htmx swap.
The page never full-reloads: htmx fetches the destination URL, pulls out the
matching block, and replaces one DOM node. The shell — topbar, sidebar, footer —
stays mounted, and only the content outlet re-renders.
You opt in with htmx'shx-boost="true". Chirp's job is to render the right
block on the way back and to fail safely when a link can't swap into the current
shell.
When to reach for it
Reach for boosted navigation when links inside a
persistent app shell should feel
like an SPA without you writinghx-*attributes on every link. Return a
[[docs/about/core-concepts/return-values|Page]] and Chirp picks the wide block
for a boosted click and the narrow block for an in-page fragment swap
automatically.
| Request type | HX-Boosted |
Block rendered |
|---|---|---|
| Full page load | absent | full template (composed into the layout) |
| Boosted link click | true |
the widepage_block_name (e.g. page_root) |
| Narrow fragment (form post, search input) | absent;HX-Request: true |
the narrowblock_name (e.g. page_content) |
Htmx 4 also sendsHX-Request-Type. A partialrequest whose target matches a
layout outlet starts rendering below that matched layout, so the response owns
the target's page block without duplicating its surrounding shell. Htmx 2 keeps
the existing full-layout response plushx-selectbehavior. Both versions use
the samePage(...)return value and named blocks.
Omitted outlet layouts andhx-select
The defaultoutlet_mode: composekeeps the layout that owns an inherited
hx-select, which is the safest shell shape. If an advanced shell uses
{# outlet_mode: replace #}or registers its target with
omit_outer_layouts=True, the wide page fragment must contain the selected
element itself. For example, a shell withhx-select="#page-content"needs an
id="page-content" wrapper inside the rendered page_block_name.
app.check() reports a layout_outleterror when it can statically see a
literal#idselector that the page template does not define. Htmx 2 boosted
responses also receiveHX-Reselect: *in this omitted-layout case, preventing
the inherited selector from turning a valid fragment into an empty swap. This
header is a runtime corruption backstop, not a substitute for fixing the
contract error. Htmx 4 explicitpartialrequests and narrow fragment requests
keep their existing selection behavior.
Minimal working example
ReturnPage(...)with two block names. The first is the narrow block for
in-page fragment swaps;page_block_nameis the wider, fragment-safe root used
for a boosted navigation. Chirp reads theHX-Boostedheader and chooses.
def get(request: Request) -> Page:
query = normalize_query(request.query.get("q"))
group = normalize_query(request.query.get("group"))
return Page(
"contacts/page.html",
"page_content",
page_block_name="page_root",
**page_context(query, group),
)
Source: examples/chirpui/contacts_shell/pages/contacts/page.py.
That single handler serves three callers: a full page load, a boosted link click, and a narrow in-page swap. See the contacts shell example for the full app.
The contract
A boostedGETis checked against three rules. If any rule can't be met, Chirp
emits anHX-Redirectso the browser performs a real navigation — it never
renders a broken fragment.
- 1
One block matches `HX-Target`
The destination route's layout chain must define a block mapped to the requested target id. Chirp normalizes the header once at the request layer, so
#mainandmainresolve to the same id. - 2
The destination is reachable from the current shell
If the link crosses a shell boundary — say, marketing site to dashboard app — the destination layout isn't mounted, so Chirp redirects instead of swapping.
- 3
The response body is a fragment, not a full page
A
<!DOCTYPE>in a fragment response means the handler bypassedPage(...)or returned the wrongTemplate(...). In debug mode Chirp warns you — see Debug warnings.
Choosing the right link helper
Which attributes a link needs depends on whether it stays inside the current shell, crosses into another, or leaves the app.
| Link destination | Use | What you get |
|---|---|---|
| Same shell, in-outlet | route_link_attrs() (chirp-ui) or a bare <a>inside a boosted region |
hx-boost="true"plus the inherited target |
| Crosses shell / layout domain | swap_attrs(href) |
A framework-resolvedhx-target, or an HX-Redirectat request time |
| External / download / anchor | hx-boost="false" |
Full navigation; htmx leaves the element alone |
In-shell links — just use<a href>
Inside an app-shell layout (or any boosted outlet),hx-boost="true"is set on
the outlet itself. Child<a>tags inherit it, so no per-link markup is needed:
<a href="/contacts">Contacts</a>
<a href="/contacts/42">View</a>
chirp-ui's sidebar_link() emits route_link_attrs()for links outside the
content outlet (the sidebar is not inside the boosted region), so those links
still participate in swaps.
Cross-shell links — useswap_attrs
When the destination lives in a different layout domain — for example, linking
from the marketing site root into the dashboard at/app/*— hand the href to
swap_attrsand let the framework pick the target:
<a href="/app/inbox" {{ swap_attrs("/app/inbox") | html_attrs }}>Open inbox</a>
swap_attrswalks the current and destination layout chains, finds the nearest
shared ancestor outlet, and returns{"hx-target": "#<outlet>", "hx-boost": "true"}. If no shared ancestor exists, the request-time check redirects: the
link still works, the browser just does a real navigation.
Opt out withhx-boost="false"
Use this for elements htmx must not touch:
<a href="/export.csv" hx-boost="false" download>Download CSV</a>
<a href="https://example.com" hx-boost="false">External site</a>
<a href="#section" hx-boost="false">Jump to section</a>
Cross-shell redirects
New in 0.5A boosted click that crosses a shell boundary cannot render into the current
outlet, because the destination layout is not mounted. Chirp emits
HX-Redirect: <destination>and htmx performs a real navigation:
Scenario: user clicks /app/inbox from /marketing/about
Current shell: marketing (site-content outlet)
Destination: app dashboard (main outlet)
Result: HX-Redirect → browser fetches /app/inbox as a full page
Apps without any app shell pass through untouched — there is no shell boundary
to cross. You don't configure this branch; the one thing you act on is reading
the normalized target viarequest.htmx_target_idin any custom code path.
Advanced: HX-Target normalization and how redirects are decided
TheHX-Target header arrives as htmx 4 tag#id, htmx 2 #id, or legacy bare
id. Chirp normalizes all three at the request layer while preserving the raw
header:
request.htmx.target # → "main#app-content" (raw htmx 4 header)
request.htmx.target_tag # → "main"
request.htmx.target_id # → "app-content"
request.htmx_target_id # → "app-content" (convenience alias)
All framework code — fragment target resolution, render-plan building,
cross-shell redirect logic — consumes the normalized form, and so should your
code. The raw.targetis kept for logging. Selector-like and malformed values
normalize toNone, so they cannot accidentally match a layout or registry.
The redirect is decided in three places:
- Inconsistent setup — a shell is configured but the route or fragment-target registry is missing. Redirect rather than render against partial metadata.
- No shared ancestor — the current and destination layout chains both have layouts but share no navigation outlet. Redirect.
- Target mismatch — the computed swap target does not match the client's
HX-Target. Redirect.
Debug warnings
WhenAppConfig(debug=True), the DebugFragmentValidatormiddleware inspects
buffered fragment responses and logs a warning when it finds patterns that mean
a swap is broken. It runs only when the render intent isfragment(or
unknownon an htmx request); streaming responses, non-HTML content, and
full-page intents pass through. It is on by default (debug_fragment_validator
defaults toTrue).
The validator flags two patterns:
| Pattern | Meaning | Fix |
|---|---|---|
<!DOCTYPE>in fragment body |
A full page rendered into an outlet | ReturnPage(...) or Fragment(...), not Template(...) |
| A registered shell-region id appearing more than once | A shell region is duplicated in the fragment | Remove the duplicate block; OOB regions own their DOM id (see OOB fail-loud policy) |
To silence a warning, fix the handler first — the warning is almost always
correct. If your app intentionally renders pre-serialized fragments with repeated
ids, opt out globally withAppConfig(debug_fragment_validator=False).
Advanced: fail the CI build on fragment leakage
To make leakage fail a test run instead of just logging, construct the middleware
directly withstrict=True:
from chirp.middleware.debug_fragment_validator import DebugFragmentValidator
# CI-only: fail the build on fragment leakage
app.add_middleware(DebugFragmentValidator(oob_registry, strict=True))
Strict mode raises FragmentValidationErrorinstead of logging. The error
propagates to the app's error handler and surfaces as a 500 in tests — visible
rather than quiet.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Link does a full reload instead of swapping | hx-boost="false" is set on an ancestor, or the <a>is outside the boosted outlet |
| Fragment appears but the shell jumps | The handler returnedTemplate(...) (full page) instead of Page(...); the debug validator warns |
| Boosted click leaves the outlet blank | A replaced or omitted outlet layout ownshx-select, but the wide page fragment lacks that selected id; run app.check() and fix the layout_outleterror |
| Cross-shell click does a full navigation | Expected — the destination is in another shell, so Chirp emitsHX-Redirect |
| Page renders twice / duplicate ids | A shell-region block is duplicated in the fragment; the validator warns on registered ids |
HX-Target: #main works but HX-Target: mainfails (or the reverse) |
A custom code path forgot to normalize; userequest.htmx_target_id |