How to Create Custom Page Layouts Using air.layouts: Step-by-Step Guide
You create custom page layouts in air.layouts by writing a Python function that accepts child tags, separates head and body content using filter_head_tags and filter_body_tags, and returns either a complete Html document or a Children fragment for HTMX responses.
The feldroy/air framework provides a lightweight, Pythonic approach to HTML generation through its air.layouts module. While the library ships with pre-built layouts like mvpcss and picocss, understanding how to create custom page layouts using air.layouts allows you to define your own HTML scaffolding, integrate custom CSS frameworks, and handle both full page renders and HTMX partials with the same function.
How air.layouts Processes Documents
The air.layouts module in src/air/layouts.py handles HTML document construction through a predictable pipeline that separates concerns between head and body content.
Tag Classification
Two utility functions handle automatic tag sorting:
filter_head_tags– Extracts tags belonging in<head>(such asTitle,Meta, andLink) based on theHEAD_TAG_TYPESdefinition insrc/air/tags/models/types.pyfilter_body_tags– Returns everything else that belongs in<body>(such asDiv,Main,Header, and text content)
These utilities allow you to pass mixed content to your layout without manually sorting where each tag belongs.
Layout Function Structure
Built-in functions like mvpcss and picocss demonstrate the standard pattern:
- Accept arbitrary children and an
is_htmxboolean flag - Split children into head and body categories
- If
is_htmxisTrue, return aChildrenfragment (body content only) - Otherwise, assemble a full
Htmltree withHeadandBodynodes - Inject framework-specific assets (CSS/JS links) before user-provided content
The private helper _header (lines 27-36 in src/air/layouts.py) additionally scans for air.Header tags to render them specially within the body.
Anatomy of a Custom Layout Function
To create custom page layouts using air.layouts, implement a function that follows this five-step structure:
- Define the signature: Accept
*children: Anyandis_htmx: bool = False, returningHtml | Children - Filter tags: Call
filter_body_tags(children)andfilter_head_tags(children)to separate content - Handle HTMX: If
is_htmxisTrue, returnChildren(*body_tags, *head_tags)immediately - Build the document: Construct a
tags.Htmlnode containingtags.Head(your assets + head tags) andtags.Body(content) - Return the tree: Air's templating engine renders the
Tagobject to HTML string
Import the necessary tag classes from air.tags: Html, Head, Body, Link, Script, Main, Header, and others.
Complete Code Examples
Minimal Hello World Layout
This example shows the essential boilerplate for a custom layout without framework-specific CSS:
import air
from air import layouts, tags
def hello_layout(*children: air.Tag, is_htmx: bool = False) -> air.Html | air.Children:
# Step 1: Separate head and body tags automatically
body_tags = layouts.filter_body_tags(children)
head_tags = layouts.filter_head_tags(children)
# Step 2: Return fragment for HTMX requests
if is_htmx:
return air.Children(*body_tags, *head_tags)
# Step 3: Build full HTML document
custom_css = tags.Link(rel="stylesheet", href="/static/my.css")
return tags.Html(
tags.Head(custom_css, *head_tags),
tags.Body(*body_tags),
)
# Usage in a page handler
@app.page
async def index(request: air.Request) -> air.Html | air.Children:
return hello_layout(
air.Title("Hello"),
air.H1("Welcome to Air!"),
is_htmx=request.htmx.is_hx_request,
)
The is_htmx parameter allows the same layout function to serve both full page loads (returning complete Html) and HTMX-boosted fragments (returning Children).
Layout with Navigation Bar and Footer
This layout injects persistent navigation and footer elements while preserving the _header helper for explicit Header tags:
import air
from air import layouts, tags
def site_layout(*children: air.Tag, is_htmx: bool = False) -> air.Html | air.Children:
body_tags = layouts.filter_body_tags(children)
head_tags = layouts.filter_head_tags(children)
if is_htmx:
return air.Children(*body_tags, *head_tags)
# Navigation component
nav = tags.Nav(
tags.Ul(
tags.Li(tags.A("Home", href="/")),
tags.Li(tags.A("About", href="/about")),
),
class_="main-nav"
)
# Footer component
footer = tags.Footer(
tags.P("© 2026 My Application"),
class_="site-footer"
)
# Tailwind CSS via CDN
tailwind = tags.Link(
rel="stylesheet",
href="https://cdn.jsdelivr.net/npm/tailwindcss@3.4.0/dist/tailwind.min.css"
)
# Extract any explicit Header tags using the private helper
header_content = layouts._header(body_tags)
# Filter out Header from body_tags to avoid duplication
main_content = [t for t in body_tags if not isinstance(t, tags.Header)]
return tags.Html(
tags.Head(tailwind, *head_tags),
tags.Body(
header_content,
nav,
tags.Main(*main_content),
footer
),
)
Handling HTMX Partial Responses
When processing HTMX requests, layouts skip the HTML wrapper and return only the content:
@app.page
async def dashboard(request: air.Request) -> air.Html | air.Children:
return site_layout(
air.Title("Dashboard"),
air.H1("Statistics"),
air.P("Real-time metrics display here."),
air.Button(
"Refresh Data",
hx_get="/dashboard/data",
hx_target="#metrics"
),
is_htmx=request.htmx.is_hx_request,
)
When request.htmx.is_hx_request evaluates to True, the function returns a Children collection containing only Main and other body elements, allowing HTMX to swap content without parsing a full HTML document.
Key Source Files and Utilities
Understanding these files helps when extending the layout system:
src/air/layouts.py– Containsfilter_body_tags,filter_head_tags,_header, and the reference implementationsmvpcssandpicocss(lines 9-101 and 122-190)src/air/tags/__init__.py– Exports all HTML tag classes (Html,Head,Body,Link,Script,Header, etc.)src/air/tags/models/types.py– DefinesHEAD_TAG_TYPEStuple andAttributeTypeused for tag classification and type hintssrc/air/templating.py– Handles the final rendering ofTagtrees to HTML stringssrc/air/applications.py– Provides theAirclass and@app.pagedecorator for registering handlers that return your custom layouts
Summary
- Create custom page layouts using air.layouts by writing Python functions that return
HtmlorChildrenobjects constructed fromair.tagsclasses. - Use
filter_head_tagsandfilter_body_tagsfromsrc/air/layouts.pyto automatically sort mixed content into the correct document sections. - Support both full page renders and HTMX fragments by checking an
is_htmxflag and returning either a completeHtmltree or aChildrencollection. - Reuse the
_headerhelper to extract explicitHeadertags for special rendering within the body. - Import tag classes from
air.tagsto build your document structure without writing raw HTML strings.
Frequently Asked Questions
How do I add custom CSS frameworks to my air.layouts template?
Import the Link and Script classes from air.tags and instantiate them with your CDN or local paths. Pass these to tags.Head() alongside the head_tags extracted from your children. For example, create tags.Link(rel="stylesheet", href="https://cdn.example.com/style.css") and include it in the Head constructor before the spread *head_tags.
Can I use the same layout function for regular pages and HTMX responses?
Yes. Pass is_htmx: bool = False as a parameter to your layout function. When True, return air.Children(*body_tags, *head_tags) directly without wrapping in Html. When False, build the full Html document. In your page handler, pass is_htmx=request.htmx.is_hx_request to automatically detect the request type.
What is the difference between filter_head_tags and filter_body_tags?
filter_head_tags returns only tags that belong in the HTML <head> section (such as Title, Meta, and Link instances), while filter_body_tags returns all other content. These functions examine the HEAD_TAG_TYPES tuple defined in src/air/tags/models/types.py to determine classification, allowing you to pass mixed content to your layout without manual sorting.
How do I preserve Header tags when building custom layouts?
Use the private helper layouts._header(body_tags) to extract any explicit Header tags from your children list. This function scans the body tags and returns the first Header instance it finds (or None). Remove the Header from your main content list before rendering to avoid duplication, placing the extracted header in your desired location within the Body construction.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →