Shells

The three root layouts you can extend, when to pick each, and what is not a shell

3 min read 598 words

What a shell is

A shell is the root layout your templates extend. It owns the document root (<html>, <head>, <body>), loads htmx, and declares the htmx-boost contract — the target id, swap mode, andhx-selectfilter that govern how boosted navigation flows into the page.

Pick exactly one shell per app. Page templates, fragments, and feature modules render inside the shell's outlet.

The three shells

Chirp ships two; chirp-ui adds one more.

Shell Comes from When to pick it
chirp/layouts/boost.html core htmx-boost SPA-style nav, no opinionated chrome
chirp/layouts/shell.html core Fragment-only apps (LLM/RAG playgrounds, dashboards, form-heavy UIs) — nohx-select, fragments flow exactly where hx-targetsays
chirpui/app_shell_layout.html chirp-ui Sidebar/topbar app chrome with breadcrumbs, shell actions, and OOB regions pre-wired

The decisive question is whether your app needs persistent chrome — sidebar, topbar, breadcrumbs that survive boosted navigation:

  • Yesapp_shell_layout.html. See App Shells.
  • No, but you want SPA-style navboost.html. See Boosted Navigation.
  • No, fragment swaps onlyshell.html.

Thehx-selectdistinction

The biggest hidden difference between the three is what<main>sets for hx-select:

  • boost.htmlhx-select="#page-content". Filters every response. Correct for boosted nav; silently discards fragment responses that don't contain#page-content.
  • shell.html — no hx-select. Forms and fragment swaps land exactly wherehx-targetsays, with no filtering.
  • app_shell_layout.htmlhx-select="#page-content" on <main>plus per-linkhx-boost on sidebar links. Same boost contract as boost.html, plus persistent chrome.

If you build a fragment-heavy app onboost.html, form posts return 200 OK but the UI never updates. The HTMX debug overlay will show "Empty hx-select" on the triggering element. The fix is a one-line change to extendshell.html instead — and remove any defensivehx-disinherit="hx-select"shims that were working around the inherited selector.

chirp check catches the mismatch via the select_inheritancerule when a mutating element may silently discard its response.

What is not a shell

A common point of confusion: feature modules like chirp.docs ship templates with their own visual chrome (sidebar nav, search box, content area). They look shell-like, but they are not shells.

chirp.docs / future forum / your custom feature module
   └── ships page templates (doc_page.html, doc_list.html, …)
       └── render INSIDE the outlet of whatever shell you extend

Page templates from a feature module:

  • Do not establish<html>, <head>, <body>, or load htmx.
  • Do not declare the boost contract (target, swap, select).
  • Render as the content of a route handler — typically via Page("chirp_docs/doc_page.html", "doc_content", doc=doc).
  • Compose into the shell's{% block content %}slot (or get returned as a fragment).

If you mountchirp.docs in an app that extends app_shell_layout.html, the docs sidebar lives inside the chirp-ui main outlet — not as a peer of the chirp-ui sidebar. The shell stays in charge of the page frame.

Custom shells

To roll your own shell instead of extending one of the three, replicate the boost contract on<main>:

<main id="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_shellfor a worked example.