Recommended Project Structure for Air Applications: A Complete Guide

The recommended project structure for Air applications follows a minimal Python package layout with main.py as the entry point, routers/ for modular routes, templates/ and static/ for assets, and pyproject.toml for dependency management.

Air applications are ordinary Python packages designed for rapid prototyping while maintaining flexibility to scale into larger systems. According to the feldroy/air repository, the framework embraces a conventional, minimal layout that supports everything from single-file demos to production-grade architectures with multiple routers and complex form handling.

Core Principles of Air Project Layout

Conventional Python Packaging

Air applications leverage standard Python packaging conventions rather than inventing new metaphors. The project uses pyproject.toml for metadata and dependency declaration, with the air CLI entry point defined in that file as shown in pyproject.toml lines 82-83. This ensures compatibility with modern Python tooling like uv and pip.

Progressive Scalability

The documentation in docs/learn/quickstart.md lines 7-16 describes a fast-start approach using mkdir helloair && cd helloair && uv venv && source .venv/bin/activate && uv init && uv add air. As complexity grows, the "Bigger Applications" cookbook in docs/learn/cookbook/bigger-applications.md lines 11-52 demonstrates how to evolve from a single main.py into a modular structure with separate router packages.

Standard Directory Structure for Air Projects

The following layout represents the recommended project structure for Air applications, accommodating both quick prototypes and production deployments:


my-air-app/
├─ pyproject.toml          # Project metadata, Air as dependency

├─ main.py                 # Application entry point

├─ routers/                # Modular route groups

│   └─ dashboard.py        # AirRouter instances

├─ templates/              # Jinja2 or Air-Tag templates

│   └─ base.html
├─ static/                 # CSS, JS, images

│   ├─ styles.css
│   └─ scripts.js
├─ forms/                  # Pydantic-backed AirForm classes

│   └─ contact.py
└─ tests/                  # pytest suite

    └─ test_routes.py

main.py serves as the application factory, creating the air.Air() instance and mounting routers. The routers/ directory contains air.AirRouter subclasses that group related pages, which you attach via app.include_router(). templates/ holds Jinja2 templates (configured via air.JinjaRenderer as shown in docs/learn/quickstart.md lines 390-410), while static/ contains assets served via app.mount("/static", air.StaticFiles(...)) per the static files cookbook in docs/learn/cookbook/static.md lines 3-9.

Implementing the Entry Point

The main.py file represents the minimal viable application. As demonstrated in docs/learn/quickstart.md lines 34-45, this file instantiates the Air class and defines routes using the @app.page decorator:


# main.py

import air

app = air.Air()
jinja = air.JinjaRenderer(directory="templates")  # Optional Jinja support

@app.page
def index():
    return air.layouts.mvpcss(
        air.Title("Home"),
        air.H1("Hello, Air!"),
        air.P("Breathe it in."),
    )

Run this application using the built-in CLI: air run.

Modular Routing with AirRouter

For larger applications, split logic into separate modules using air.AirRouter. The router pattern shown in docs/learn/cookbook/bigger-applications.md allows you to define related pages in isolated files before mounting them on the main app:


# routers/dashboard.py

import air

router = air.AirRouter()

@router.page
def dashboard():
    return air.layouts.mvpcss(
        air.Title("Dashboard"),
        air.H1("Welcome to the dashboard"),
    )

Then include the router in your entry point:


# main.py

from routers.dashboard import router

app.include_router(router)  # Routes become /dashboard

Template and Static Asset Organization

Air supports both its native Air Tags and Jinja2 templating. When using Jinja, instantiate air.JinjaRenderer(directory="templates") and render templates by name:

@app.page
def index(request: air.Request):
    return jinja(
        request,
        name="base.html",
        title="Home",
        fragment=air.H1("Dynamic content"),
    )

Expose static assets by mounting the directory:

app.mount("/static", air.StaticFiles(directory="static"), name="static")

This makes files in static/ accessible via /static/styles.css or /static/scripts.js in your HTML.

Form Handling and Validation

Keep form logic separate from views by storing air.AirForm classes in a dedicated forms/ directory. As illustrated in the repository's examples, these forms use Pydantic models for validation:


# forms/contact.py

import air
from pydantic import BaseModel, Field

class ContactModel(BaseModel):
    name: str = Field(min_length=2)
    email: str = Field(pattern=r'^[^@]+@[^@]+\.[^@]+$')

class ContactForm(air.AirForm):
    model = ContactModel

Import these into your views to render forms and handle validation:

from forms.contact import ContactForm

@app.page
async def contact(request: air.Request):
    form = ContactForm()
    return air.layouts.mvpcss(
        air.Title("Contact Us"),
        form.render(),
    )

Configuration and Dependencies

The pyproject.toml file declares Air as a dependency and configures the CLI entry point. While the Air repository itself places source code under src/air/, user applications typically place code in the repository root or follow the src/ layout based on personal preference. The critical requirement is declaring air as a dependency to access the air run, air fmt, and other CLI commands.

Testing Structure

Mirror the upstream repository's approach by placing tests under tests/. The feldroy/air repository includes comprehensive routing tests in tests/test_routing.py, which you can use as a reference for writing unit tests against your own routes, forms, and background tasks.

Summary

  • Start minimal with a single main.py file containing an air.Air() instance, suitable for rapid prototyping.
  • Scale horizontally by moving routes into routers/ submodules using air.AirRouter and app.include_router().
  • Separate concerns by placing Jinja templates in templates/, static assets in static/, and form classes in forms/.
  • Use standard tooling with pyproject.toml for dependencies and the air CLI for running and formatting code.
  • Test systematically under tests/ following the patterns established in the upstream test suite.

Frequently Asked Questions

Can I start with a single file before adopting the full directory structure?

Yes. The docs/learn/quickstart.md explicitly recommends beginning with a single main.py file that creates an Air instance and defines routes directly. As your application grows, migrate routes to separate files in routers/ without changing the core application logic.

Where should I place Jinja templates in an Air project?

Place Jinja templates in a templates/ directory at the project root. Instantiate air.JinjaRenderer(directory="templates") in your main.py file, then reference templates by name (e.g., name="base.html") when returning responses from your page handlers.

How do I organize static files like CSS and JavaScript?

Store static assets in a static/ directory and mount it using app.mount("/static", air.StaticFiles(directory="static"), name="static"). This exposes the directory at the /static/ URL path, allowing your templates to reference files like /static/styles.css.

Does Air require a specific test runner or layout?

Air uses standard pytest for testing, with tests located in a tests/ directory at the project root. The framework does not impose special testing requirements; you can write standard unit tests against your route functions and form classes using the patterns shown in tests/test_routing.py in the upstream repository.

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 →