# How to Create Custom Page Layouts Using air.layouts: Step-by-Step Guide

> Learn to create custom page layouts in air.layouts with this step by step guide. Build flexible Python functions for your web app.

- Repository: [Feldroy/air](https://github.com/feldroy/air)
- Tags: how-to-guide
- Published: 2026-03-01

---

**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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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:

```python
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:

```python
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:

```python
@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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/src/air/tags/__init__.py)** – Exports all HTML tag classes (`Html`, `Head`, `Body`, `Link`, `Script`, `Header`, etc.)
- **[`src/air/tags/models/types.py`](https://github.com/feldroy/air/blob/main/src/air/tags/models/types.py)** – Defines `HEAD_TAG_TYPES` tuple and `AttributeType` used for tag classification and type hints
- **[`src/air/templating.py`](https://github.com/feldroy/air/blob/main/src/air/templating.py)** – Handles the final rendering of `Tag` trees to HTML strings
- **[`src/air/applications.py`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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.