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 as Title, Meta, and Link) based on the HEAD_TAG_TYPES definition in src/air/tags/models/types.py
  • filter_body_tags – Returns everything else that belongs in <body> (such as Div, 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:

  1. Accept arbitrary children and an is_htmx boolean flag
  2. Split children into head and body categories
  3. If is_htmx is True, return a Children fragment (body content only)
  4. Otherwise, assemble a full Html tree with Head and Body nodes
  5. 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:

  1. Define the signature: Accept *children: Any and is_htmx: bool = False, returning Html | Children
  2. Filter tags: Call filter_body_tags(children) and filter_head_tags(children) to separate content
  3. Handle HTMX: If is_htmx is True, return Children(*body_tags, *head_tags) immediately
  4. Build the document: Construct a tags.Html node containing tags.Head (your assets + head tags) and tags.Body (content)
  5. Return the tree: Air's templating engine renders the Tag object 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).

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 – Contains filter_body_tags, filter_head_tags, _header, and the reference implementations mvpcss and picocss (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 – Defines HEAD_TAG_TYPES tuple and AttributeType used for tag classification and type hints
  • src/air/templating.py – Handles the final rendering of Tag trees to HTML strings
  • src/air/applications.py – Provides the Air class and @app.page decorator for registering handlers that return your custom layouts

Summary

  • Create custom page layouts using air.layouts by writing Python functions that return Html or Children objects constructed from air.tags classes.
  • Use filter_head_tags and filter_body_tags from src/air/layouts.py to automatically sort mixed content into the correct document sections.
  • Support both full page renders and HTMX fragments by checking an is_htmx flag and returning either a complete Html tree or a Children collection.
  • Reuse the _header helper to extract explicit Header tags for special rendering within the body.
  • Import tag classes from air.tags to 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →