App Shells

Persistent layout with SPA-style navigation and fragment regions

12 min read 2483 words

This guide is about chirp-ui's app_shell_layout.html — one of three shells you can extend. For the trifecta and the decision table, see Shells. Also read UI layers & shell regions for the glossary (app shell vs page chrome vs surface chrome) and stable OOB element ids, and Boosted Navigation for the swap contract, cross-shell redirects, and debug warnings.

What Is an App Shell?

An app shell is a persistent layout — topbar, sidebar, footer — that stays on screen while the main content area swaps between pages. Think Gmail, GitHub, or any dashboard. The shell never reloads; only#mainchanges.

Chirp + chirp-ui give you this pattern with zero client-side JavaScript frameworks. The server renders exactly the right HTML for each request, and htmx swaps it in.

Not a shell: Feature modules likechirp.docsship templates that look shell-like (sidebar, search, content area) but render inside whichever shell you choose. They don't establish the document root or the boost contract. See Shells > What is not a shell.

How It Works

Full page load     → server renders everything (shell + page)
Sidebar navigation → server renders the page block, htmx swaps #main
Fragment request   → server renders just the targeted block

The Navigation Model

app_shell_layout.html puts hx-boost="true", hx-target="#main", hx-swap="innerHTML", and hx-select="#page-content" directly on <main id="main">. Content is wrapped in<div id="page-content"> inside #main. All links inside #main inherit these attributes automatically — plain <a href="...">tags get SPA navigation with no extra markup. Because#main uses innerHTML(not outerHTML), it persists in the DOM and its view-transition-nameis never duplicated during swaps.

Sidebar links (outside#main) carry their own htmx attributes via sidebar_link(), which emits hx-boost, hx-target, and hx-select.

When a boosted link fires, theHX-Boostedheader tells Chirp to render thepage_block(the wider, self-contained root). The response is swapped into#main.

Forms and fragment targets with explicithx-target(e.g. hx-target="#contacts-list") override the inherited value naturally. Usefragment_island or hx-disinheritonly when a region needs to opt out of the inherited shell attributes entirely.

Active State

ChirpUI sidebar and navbar links support amatch=parameter for automatic path-based highlighting:

{{ sidebar_link("/", "Home", icon="◉", match="exact") }}
{{ sidebar_link("/contacts", "Contacts", icon="◎", match="prefix") }}

match="exact" activates on exact URL match; match="prefix"activates when the URL starts with the href. Chirp auto-injectscurrent_pathinto template context forTemplate(...) and Page(...) returns, so match=works without manually passingnav= or current_path=from every handler.

After htmx navigation,app_shell_layout.htmlruns a client-side sync that updates active classes andaria-current="page" based on location.pathname. This covers the gap wherehx-boost swaps #mainbut the sidebar DOM is not re-rendered.

The Rendering Rule

Handlers returnPageand Chirp auto-detects rendering scope:

def get(request: Request) -> Page:
    return Page(
        "contacts/page.html",
        "page_content",
        page_block_name="page_root",
        **context,
    )
Request type What renders Block used
Full page load Shell + layouts + page page_root(full template)
Sidebar navigation (HX-Boosted) Page block only page_root
Fragment request (form, search) Narrow block only page_content

The developer doesn't think aboutis_fragment or is_boosted. Return Pagewith both block names and Chirp does the right thing.

Building a Shell

1. Extendapp_shell_layout.html

{% extends "chirpui/app_shell_layout.html" %}
{% from "chirpui/sidebar.html" import sidebar, sidebar_link, sidebar_section %}

{% block brand %}My App{% end %}

{% block sidebar %}
{% call sidebar() %}
  {% call sidebar_section("Main") %}
    {{ sidebar_link("/", "Home", icon="home") }}
    {{ sidebar_link("/contacts", "Contacts", icon="grid") }}
  {% end %}
{% end %}
{% end %}

{% block content %}
  Page content here
{% end %}

app_shell_layout.html provides the topbar, sidebar slot, and <main id="main"> with built-inhx-boost, hx-target, hx-swap, and hx-select. Links inside #maininherit SPA navigation automatically.

2. Structure Your Page Template

{% block page_root %}
<div class="my-page">
  <h1>Contacts</h1>

  {% block page_content %}
  <div id="contacts-list">
    {% for c in contacts %}
      <div>{{ c.name }}</div>
    {% end %}
  </div>
  {% endblock %}
</div>
{% endblock %}
  • page_root— the wide block for sidebar navigation. Contains layout wrappers, headings, spacing.
  • page_content— the narrow block for fragment requests. Contains just the data-driven content.

3. Return Page from Your Handler

def get(request: Request) -> Page:
    contacts = get_contacts()
    return Page(
        "contacts/page.html",
        "page_content",
        page_block_name="page_root",
        contacts=contacts,
    )

When you need both full-page slots and OOB swaps (breadcrumbs, sidebar, title), use{% region %}instead of blocks. One definition serves both — no duplication:

{% region breadcrumbs_oob(breadcrumb_items=[{"label":"Home","href":"/"}]) %}
{{ breadcrumbs(breadcrumb_items) }}
{% 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 %}

See examples/chirpui/shell_oobfor the reference implementation. The block-based extend pattern above remains valid for apps that don't need OOB.

Route Contract and Sections

With the route directory contract, sections,_meta.py, and shell context assembly replace manual build_page_context patterns. Register sections with app.register_section() before mount_pages(). Use _meta.py to declare title, section, breadcrumb_label, and shell_mode. The framework assembles page_title, breadcrumb_items, tab_items, and current_pathautomatically. See the Route Directory Golden Path for recommended patterns.

Forms Inside the Shell

Forms with explicithx-targetoverride the inherited shell attributes naturally. No defensive wrappers needed:

<form hx-post="/contacts/create"
      hx-target="#contacts-list"
      hx-swap="outerHTML">
  <input name="name" required>
  <button type="submit">Add</button>
</form>

Use fragment_island or hx-disinheritonly when a region needs to fully opt out of the inherited boost/target/swap/select chain.

Validation Errors

ReturnValidationErrorto re-render a form block with 422 status:

async def post(request: Request):
    form = await request.form()
    result = validate(form, rules)
    if not result:
        return ValidationError(
            "contacts/page.html",
            "contact_form",
            retarget="#contact-form-card",
            errors=result.errors,
            form=form,
        )
    # ... success path

Shell Actions

Routes contribute actions to the topbar (buttons, links, menus) via _context.py files. The correct pattern is to return a context()dict with ashell_actions key — not a standalone shell_actions()function.

Declaration

# pages/contacts/_context.py
from chirp import ShellAction, ShellActions, ShellActionZone

def context() -> dict:
    return {
        "shell_actions": ShellActions(
            primary=ShellActionZone(
                items=(ShellAction(id="add", label="Add Contact", icon="add", href="/contacts/new"),),
            ),
        ),
    }

Three Zones

  • primary — Main buttons/links (e.g. "New project", "Deploy")
  • controls — Secondary actions (e.g. "Metrics", filters)
  • overflow — Dropdown menu (e.g. "More" with Archive, Export, Docs)

Form actions (kind="form")

Use kind="form" for POST actions that need CSRF, hidden fields, and optional HTMX attributes on the<form>. Chirp-ui renders the form in the shell target; OOB updates refresh it on navigation like other shell actions.

  • Put form actions in primary or controls only (not overflow).
  • Set form_action, label (submit button), and optional hidden_fields as tuple[tuple[str, str], ...].
  • include_csrf (default True) renders {{ csrf_field() }}inside the form.
  • HTMX: set hx_post, hx_target, hx_swap, hx_disinherit as needed.
  • submit_surface: "btn" | "shimmer" | "pulsing"(ChirpUI submit control).

For link/button actions that need extra attributes (e.g.hx-booston a shell link), set attrs on ShellAction (string passed through to btn).

ShellAction(
    id="add-to-party",
    kind="form",
    label="Add Bulbasaur to party",
    variant="primary",
    form_action="/team/add",
    hidden_fields=(("pokemon_id", "1"),),
    hx_post="/team/add",
    hx_target="#party-toast",
    hx_swap="innerHTML",
    hx_disinherit="hx-select",
    submit_surface="shimmer",
)
ShellActions(
    primary=ShellActionZone(items=(ShellAction(id="new", label="New", href="/new"),)),
    controls=ShellActionZone(items=(ShellAction(id="metrics", label="Metrics", href="#stats"),)),
    overflow=ShellActionZone(
        items=(
            ShellAction(id="archive", label="Archive", href="/archive"),
            ShellAction(id="export", label="Export", href="/export"),
        ),
    ),
)

Cascade Inheritance

Parent_context.pydefines section defaults; child routes inherit them. Child_context.py can add actions, override by id, or replace entire zones.

Override Patterns

Remove specific parent actions:

ShellActionZone(items=(...), remove=("parent-action-id",))

Replace an entire zone (e.g. form pages that need different actions):

ShellActions(
    primary=ShellActionZone(
        items=(ShellAction(id="save", label="Save", href="/save"),),
        mode="replace",
    ),
)

Use mode="replace"when a subroute (e.g. settings, wizard, install) should completely replace parent navigation actions. Cannot combine withremove=.

Layout Wiring

When usingchirpui/app_shell_layout.html, shell_actionsis passed via the layout chain from merged_context.pyresults. No extra wiring needed.

When using theapp_shell()macro (regions-based layouts), pass it explicitly:

{% call app_shell(brand="My App", shell_actions=shell_actions | default(none)) %}
  ...
{% end %}

OOB Mechanism

Chirp's render plan adds shell actions as an OOB fragment when serving boosted navigation requests or when the HTMX target hastriggers_shell_update=True (e.g. tab clicks targeting#page-root). The topbar updates automatically on each page change — no client-side logic required.

use_chirp_ui() registers main and page-root with triggers_shell_update=True, andpage-content-inner with triggers_shell_update=Falseso narrow swaps don't update the shell. Custom targets:app.register_fragment_target("id", fragment_block="...", triggers_shell_update=True).

Reference

Seeexamples/chirpui/pages_shell for a working cascade with remove=and mode="replace".

Debugging and Introspection

The most useful way to debug a shell app is to follow the contract chain:

  1. Which HTMX target fired? (#main, #page-root, or a narrow target)
  2. Which fragment block does Chirp map that target to?
  3. Does the leaf page template actually define that block?

Withuse_chirp_ui(app), the default mapping is:

Target Block Typical trigger
#main page_root Sidebar navigation
#page-root page_root_inner Section tabs
#page-content-inner page_content Narrow content mutations

If the wrong amount of HTML swaps, the target/block pair is usually the bug. If the right block is chosen but the shell does not update, check whether the target was registered withtriggers_shell_update=True.

For day-to-day debugging:

  • Runapp.check()in tests or startup to catch missing shell blocks early.
  • Whenconfig.debug=True, response headers include X-Chirp-Route-Kind, X-Chirp-Route-Meta, X-Chirp-Context-Chain, and X-Chirp-Shell-Contextfor route introspection.
  • Visit/__chirp/routes(debug only) for a visual route explorer with per-route drill-down.
  • The HTMX debug panel's activity log shows route metadata when you expand a request entry.
  • Preferrender_route_tabs(tab_items, current_path) over the legacy route_tabs(...)alias so template names do not collide with context variables.
  • Keep one Python source of truth for tab families, breadcrumb prefixes, and sidebar state instead of recomputing them across templates and handlers.
  • When a target is unregistered, Chirp's render-plan diagnostics list the known targets; use that output to spot typos quickly.

Since<main id="main"> carries hx-boost="true", all <a>tags inside page content get SPA navigation automatically — no special attributes needed.

<a href="/page-2">Next page</a>
<a href="/details">View details</a>

For links that need extra htmx attributes (e.g. hx-push-url), use the nav_linkmacro:

{% from "chirpui/nav_link.html" import nav_link %}
{{ nav_link("/page-2", "Next page", push_url=true) }}

To opt a link out of SPA navigation, add hx-boost="false".

Fragment Regions (Optional)

Thefragment_island / safe_regionmacros are ChirpUI/HTMX swap-safety primitives. They isolate local mutation regions from inheritedhx-*behavior usinghx-disinherit. Use them when you want semantic grouping or when a region needs its ownhx-target / hx-swapdefaults:

{% from "chirpui/fragment_island.html" import fragment_island %}

{% call fragment_island("contacts-page", hx_target="#contacts-page", hx_swap="outerHTML") %}
  {# forms inside here target #contacts-page by default #}
{% end %}

Important: fragment_island is not the same as Chirp's data-islandislands. fragment_islandis a swap-safety boundary (no client runtime). Chirp islands (data-island) are client-managed surfaces with mount/unmount lifecycle. See Islands Contract for the distinction.

Custom Shells

If you need a custom shell instead ofapp_shell_layout.html, replicate the built-in defaults on your<main>element:

<main id="main" class="my-shell__main" tabindex="-1"
      hx-boost="true" hx-target="#main" hx-swap="innerHTML" hx-select="#page-content">
  <div id="page-content">
    {% block content %}{% end %}
  </div>
</main>

Sidebar links (outside #main) need their own hx-target="#main"and hx-select="#page-content" since they don't inherit from the <main>element.

Seeexamples/chirpui/kanban_shell for a working custom shell and examples/chirpui/shell_oobfor regions-based OOB.

Gotchas for Interactive Shells

Interactive shells — boards, dashboards, real-time feeds — combine OOB swaps, SSE, and action-style routes. These patterns have sharp edges worth knowing.

OOB withhx-swap="none"— the Lost Main

Chirp'sOOB(main, *oob_fragments)renders the first argument as the main response (nohx-swap-oobwrapping). The rest get wrapped. When a button uses hx-swap="none"(delete, move, toggle), the main is discarded — htmx only processes the OOB elements.

If you put important content as the main, it vanishes silently:

# BAD — old_column is the main, discarded by hx-swap="none"
return OOB(
    _column_fragment(old_status, tasks),
    _column_fragment(new_status, tasks),
    _stats_fragment(tasks),
)

# GOOD — empty main, all real content is OOB
return OOB(
    Fragment("page.html", "empty"),
    _column_fragment(old_status, tasks),
    _column_fragment(new_status, tasks),
    _stats_fragment(tasks),
)

Rule of thumb: if the route uses hx-swap="none", make the OOB main an empty fragment and put everything else in the OOB positions.

SSE Event Naming — the Silent Mismatch

When you yield aFragment with a targetin an SSE generator, _format_eventuses the target as the SSE event name (e.g., "column-backlog"). But sse-swap="message"only listens for events literally named"fragment". Everything else is silently ignored.

Fix: Create template blocks withhx-swap-oobbaked into the HTML, and yieldFragment objects without target. The event name defaults to "fragment"and htmx processes the OOB attributes from the content:

{%- fragment column_block_oob -%}
{% call column(column_id, column_name, tasks | length, oob=true) %}
  ...
{% end %}
{%- endfragment -%}
# No target → event name is "fragment" → client receives it
yield Fragment("page.html", "column_block_oob",
               column_id="backlog", tasks=filtered, ...)

ContextVar Loss in Streamed Rendering

Middleware-provided helpers backed byContextVaronly exist while the request is inside the middleware pipeline. Callingget_user(), csrf_token(), or similar middleware-backed helpers during streamed or deferred rendering can raiseLookupError.

The request object itself is restored for stream iteration, so this warning is about middleware-scoped values such as auth/session/CSRF state, notget_request().

This applies to:

  • SSE async generators
  • Stream / TemplateStreambody rendering
  • Suspenseshell or deferred block rendering

Fix: Capture request-scoped values before returning the stream or entering the generator:

def events_route():
    user = get_user()  # captured in handler scope

    async def generate():
        # user is available via closure; get_user() would fail here
        yield _fragment(..., current_user=user)

    return EventStream(generate())

The same pattern applies to Suspenseand other streaming responses:

@app.route("/dashboard")
def dashboard():
    token = csrf_token()
    return Suspense("dashboard.html", csrf_token_value=token, stats=load_stats())

Then the template uses {{ csrf_token_value }}instead of calling {{ csrf_token() }}while the stream is rendering.

For the broader streaming model (Stream, TemplateStream, Suspense), see Streaming HTML.

Dual Template Blocks for HTTP vs SSE

HTTP OOB routes rely on Chirp's negotiation layer to wrap fragments with hx-swap-oob. SSE fragments are rendered by _format_event, which only adds OOB wrapping whentarget is set — but setting targetbreaks event naming (see above).

The result: you need separate template blocks for the same content. One for HTTP (no inline OOB, the framework adds it) and one for SSE (OOB baked into the HTML):

{%- fragment column_block -%}
{# HTTP — negotiate() adds hx-swap-oob externally #}
{% call column(col_id, col_name, count, oob=false) %}...{% end %}
{%- endfragment -%}

{%- fragment column_block_oob -%}
{# SSE — OOB is inline so _format_event doesn't need target #}
{% call column(col_id, col_name, count, oob=true) %}...{% end %}
{%- endfragment -%}

See examples/chirpui/kanban_shellfor a working example of all four patterns.