App Shells

Persistent layout with SPA-style navigation and fragment regions

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

An app shell is a layout that stays on screen — topbar, sidebar, footer — while the main content area swaps between pages. Think Gmail or GitHub: the chrome never reloads, only the content inside#mainchanges. It works with zero client-side JavaScript framework: the server renders the right HTML for each request and htmx swaps it in.

Reach for an app shell when you want SPA-style navigation around persistent chrome. This page is the how-to for chirp-ui'sapp_shell_layout.html. For the three shell options and when to pick each, see the three shells and when to pick each.

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 shell layout puts the boost and swap contract (hx-boost, hx-target="#main", hx-swap="innerHTML", hx-select="#page-content") on<main id="main">. Plain <a href="..."> links inside #maininherit those attributes, so they get SPA navigation with no extra markup. Because#main swapsinnerHTML (not outerHTML), the element persists in the DOM across swaps.

Build a shell

The happy path is three steps: extend the layout, give your page apage_root and apage_content block, and return [[docs/about/core-concepts/return-values|Page]] with both block names.

  1. 1

    Extend `app_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="house") }}
        {{ sidebar_link("/contacts", "Contacts", icon="users") }}
      {% end %}
    {% end %}
    {% end %}
    
    {% block content %}
      Page content here
    {% end %}
    

    app_shell_layout.html provides the topbar, sidebar slot, and <main id="main"> with the boost/target/swap/select contract already wired. Links inside#main inherit SPA navigation automatically.

  2. 2

    Give your page a `page_root` and a `page_content` block

    {% 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. 3

    Return `Page` with both block names

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

The rendering rule

ReturnPagewith both block names and Chirp picks the right scope for each request — the return type is the intent. You never branch onis_htmxor is_boostedyourself.

What renders Block used Shell + layouts + page page_root(full template) Page block only page_root Narrow block only page_content

Active state

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

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

match="exact" activates on an exact URL match; match="prefix"activates when the URL starts with the href.

OOB regions

When you need both full-page slots and out-of-band swaps (breadcrumbs, sidebar, title), use{% region %}instead of plain 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.

Forms inside the shell

Forms with an 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.

To re-render a form block on a validation failure, return [[docs/build-apps/forms-data/forms-validation|ValidationError]] with the form block name and a 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) by returning a context() dict with a shell_actions key from a _context.pyfile — not a standaloneshell_actions()function.

# 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"),),
            ),
        ),
    }

There are 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).

When you extendchirpui/app_shell_layout.html, shell_actionsis passed through the layout chain from the merged_context.pyresults — no extra wiring. When you use theapp_shell()macro (regions-based layouts), pass it explicitly:

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

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() beforemount_pages(), and 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 the recommended patterns.

Debugging a shell

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 target → block mapping is:

Block Typical trigger page_root Sidebar navigation page_root_inner Section tabs 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 doesn't update, check whether the target was registered withtriggers_shell_update=True.

For day-to-day debugging:

  • Runapp.check()in tests or at 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.
  • Preferrender_route_tabs(tab_items, current_path)over the legacy route_tabs(...)alias so template names don't 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.

Since<main id="main"> carries the boost contract, 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 a smooth SPA transition effect on a specific content link, use the nav_link macro (it puts hx-booston the link itself):

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

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

Thefragment_island / safe_regionmacros isolate a local mutation region from inheritedhx-* behavior using hx-disinherit. Use them 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 %}

Interactive shells

Boards, dashboards, and real-time feeds combine OOB swaps, SSE, and action-style routes. Those patterns have sharp edges around how the main response and SSE event names interact with htmx — but they are streaming concerns, not app-shell concerns, and they apply to any layout.