Also read: App Shells (how shells host boosted links) and
UI layers & shell regions (theswap_attrsglobal, layout
scope comments).
What Boosted Navigation Is
hx-boost="true" turns plain <a href="...">clicks into htmx swaps. The
browser never full-reloads; htmx fetches the destination URL, extracts the
fragment referenced byhx-target, and replaces the matching DOM node. The
shell (topbar, sidebar, footer) stays mounted — only the outlet re-renders.
Chirp recognizes boosted requests via theHX-Boostedheader and renders the
wide page block (typicallypage_root), not the narrow fragment block. Return
Page(...)and Chirp picks the correct block automatically:
def get(request: Request) -> Page:
return Page(
"contacts/page.html",
"page_content", # narrow — for in-page fragment swaps
page_block_name="page_root", # wide — for boosted navigation
contacts=load_contacts(),
)
| Request type | HX-Boosted |
Block rendered |
|---|---|---|
| Full page load | absent | full template (inherits from layout) |
| Boosted link click | true |
page_root |
| Narrow fragment (form post, search input) | absent;HX-Request: true |
page_content |
The Contract
A boosted GET has three rules. If any of them break, the framework redirects instead of rendering a broken fragment.
- One fragment 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 —#mainandmainare the same. - The destination is reachable from the current shell. If the link
crosses shell boundaries (e.g. marketing site → dashboard app), Chirp
emits
HX-Redirectso the browser performs a real navigation and the new shell can mount. - 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; see Debug warnings.
Invariant: a boosted GET never 500s on a malformed target. The framework either renders a valid fragment or emits a redirect.
Boundary Decision Tree
Which helper emits which attributes depends on whether the link stays inside the current shell, crosses into another, or leaves the app entirely.
| Link destination | Use | What you get |
|---|---|---|
| Same shell, in-outlet | route_link_attrs() (chirp-ui) or bare <a>inside a boosted region |
hx-boost="true"+ inherited target |
| Crosses shell / layout domain | swap_attrs(href) |
Framework-resolvedhx-target (broader scope) or an HX-Redirectpath |
| External / download / anchor | hx-boost="false" |
Full navigation; htmx leaves the element alone |
In-shell links — just use<a href>
Insideapp_shell_layout.html (or any boosted outlet), hx-boost="true"is
set on the outlet itself. Child<a>tags inherit it. No per-link markup
needed:
<a href="/contacts">Contacts</a>
<a href="/contacts/42">View</a>
chirp-ui's sidebar_link() emits route_link_attrs()for links outside
#main(the sidebar is not inside the boosted region), so they still
participate in swaps.
Cross-shell links — useswap_attrs
When the destination lives in a different layout domain — e.g. linking from
the marketing site root to the dashboard/app/*— hand the href to
swap_attrsand let the framework pick the right 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 (truly cross-shell), the
framework will redirect at request time — the link still works, the browser
just performs 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>
HX-Target Normalization
TheHX-Target header can arrive as #main or maindepending on how the
sending element was declared. Chirp strips the leading#once, at the
request layer:
request.htmx.target # → "#main" (raw header)
request.htmx.target_id # → "main" (normalized)
request.htmx_target_id # → "main" (convenience alias)
All framework code — fragment target resolution, render plan building,
cross-shell redirect logic — consumes the normalized form. Application code
should too; the raw.targetis only kept for logging and debugging.
Registries (FragmentTargetRegistry, OOBRegistry) normalize defensively on
the public API, so you can pass either form when registering or looking up.
Cross-Shell Redirects
A boosted click that crosses a shell boundary cannot render correctly into
the current outlet — the destination layout is not mounted. When this
happens, Chirp emitsHX-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
The framework detects this in three places:
- Inconsistent setup —
swap_scope_mapconfigured but registries areNone. Redirect rather than crash. - No shared ancestor —
common_navigation_prefix_lenbetween the current and destination layout chains is zero (both chains exist but share no outlet). Redirect. - Target mismatch — the destination route cannot satisfy the requested
HX-Target(existing logic; preserved).
Apps without any app-shell (swap_scope_mapempty) pass through untouched —
there is no shell boundary to cross.
Debug Warnings
WhenAppConfig(debug=True), the DebugFragmentValidatormiddleware
inspects buffered fragment responses and logs a WARNING when it finds
patterns that indicate a broken swap:
| Pattern | Meaning | Fix |
|---|---|---|
<!DOCTYPE>in fragment body |
A full page was rendered into an outlet | ReturnPage(...) or Fragment(...) instead of Template(...) |
id="<oob-target-id>"appearing > 1 time |
Shell region is duplicated in the fragment | Remove the duplicate block; OOB regions own their DOM id |
The validator only runs whenrender_intent="fragment" (or "unknown"on an
htmx request — same skip rule asHTMLInject). Streaming responses, non-HTML
content, and full-page intents pass through unchanged.
Silencing warnings
Two options, in order of preference:
- Fix the handler. The warning is almost always correct — a fragment really did leak full-page markup or a duplicate id.
- Opt out globally. If your app renders pre-serialized fragments with
intentional id repetition, set
AppConfig(debug_fragment_validator=False).
Strict mode (CI)
For CI, 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, which
propagates to the app's error handler and becomes 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 | Handler returnedTemplate(...) (full page) instead of Page(...)— debug validator should warn |
| Cross-shell click 500s | Registries inconsistent — checkapp.check()output; Sprint 2 added redirect, but an older version passed through |
| Page renders twice / duplicate ids | OOB region block duplicated in the fragment — debug validator warns on registered ids |
HX-Target: #main works but HX-Target: mainfails (or vice versa) |
Custom code path forgot to normalize; userequest.htmx_target_id |
Related
- Shells — the three shells you can extend, the
hx-selectdistinction, and what is not a shell - App Shells — building the shell that hosts boosted links
- UI layers & shell regions —
swap_attrs, layout scope comments,{# domain: #} - OOB Registry — registering shell regions and the fail-loud policy for missing blocks
- Filesystem routing —
_layout.htmloutlet/target/domain declarations