---
title: "FastHTML Multi-Page Website: Complete Structure Tutorial"
description: "Build a FastHTML multi-page website with reusable components, shared layouts, and APIRouter. Includes FastHTML 0.14 404 handling, forms, and project structure."
date: 2026-08-13
categories: ["web-development"]
tags: ["fasthtml","python","web-development"]
---

import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";
import Notice from "../../components/widgets/Notice.astro";
import ListCheck from "../../components/widgets/ListCheck.astro";
import Accordion from "../../components/widgets/Accordion.astro";
import Tabs from "../../components/widgets/Tabs.astro";
import Tab from "../../components/widgets/Tab.astro";
import Button from "../../components/widgets/Button.astro";

In the first article, [FastHTML: Getting Started](/fasthtml-start/), we covered the basics: installing FastHTML, building a simple page, and adding Tailwind CSS styling. This article picks up where that left off and builds a full **FastHTML multi-page website** with shared layouts, reusable components, proper 404 handling, and form processing (updated for FastHTML 0.14).

By the end you'll have a working Home, About, and Contact site using modern idioms like `fast_app()`, `Titled()`, and dataclass-based form binding. If you want to scale the project later, we also cover `APIRouter` for multi-file routing.

A video walkthrough is embedded below if you prefer to follow along visually.

## Why multiple pages?

A multi-page website lets you organize content logically. While single-page applications have their place, most websites benefit from distinct pages for different purposes:

- **Organization**: Separate pages keep your content structured. "Home" for an overview, "About" for your story, "Contact" for communication details.
- **Navigation**: Users expect to click links to explore different sections. It makes the site intuitive.
- **Scalability**: Adding new pages is a matter of creating a new file and a new route.
- **SEO benefits**: Search engines index individual pages, improving discoverability.
- **User experience**: Users can bookmark specific pages and use browser navigation (back/forward) naturally.

FastHTML makes multi-page sites straightforward with its routing system and reusable Python components. If you're evaluating Python web frameworks in general, check our comparison of the [best Python web frameworks](/best-python-web-frameworks/).

<YouTubeEmbed
  url="https://www.youtube.com/embed/Zc8APrgknug"
  label="FastHTML Multiple Pages Walkthrough"
/>

## FastHTML series

Below are the articles in this series to help you get started:

- [FastHTML Get Started](/fasthtml-start/)
- [FastHTML Multiple Pages](/fasthtml-multiple-pages/) (this article)
- [FastHTML Complex AI Tools](/fasthtml-complex-ai-tools/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](/fasthtml-pydenticai-tools/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](/fasthtml-sqlite-db/)
- [FastHTML Authentication](/fasthtml-user-auth/)

## Prerequisites

Before you start, make sure you have the following:

<ListCheck>
<ul>
<li>Python 3.10 or newer installed</li>
<li>`pip install python-fasthtml` (this tutorial targets v0.14.x)</li>
<li>A code editor (VS Code, Cursor, or similar)</li>
<li>Basic Python knowledge (functions, imports, decorators)</li>
<li>Terminal or command-line access</li>
</ul>
</ListCheck>

<Notice type="warning" title="Pin your FastHTML version">
FastHTML is pre-1.0 and ships breaking changes between minor versions. Always pin the exact version in `requirements.txt` (e.g., `python-fasthtml==0.14.11`) and re-test after upgrading. Read the CHANGELOG before bumping.
</Notice>

## Creating a multi-page website with FastHTML

We'll build a 3-page site (Home, About, Contact) step by step, using modern FastHTML idioms: `fast_app()` for app initialization, `Title()` tuples for page shells, and dataclass-based form binding with Post/Redirect/Get. The structure is modular: a `main.py` entry point, a `components.py` for shared UI, and a `pages/` directory for per-page content.

### Step 1: Project structure

A clean directory layout keeps the project maintainable as it grows:

```
mywebsite/
├── main.py              # Application entry point and routing
├── components.py        # Reusable header, footer, and layout
├── requirements.txt     # Pin dependencies
└── pages/               # Per-page content modules
    ├── __init__.py      # Makes pages a Python package
    ├── home.py          # Home page content
    ├── about.py         # About page content
    └── contact.py       # Contact page with form
```

Create a `requirements.txt` to pin the version:

```
python-fasthtml==0.14.11
```

Each file has a clear role:

- **`main.py`** handles routing (URL → handler) and starts the server.
- **`components.py`** holds reusable UI pieces (header, footer, page layout) that appear on every page.
- **`pages/`** contains one module per page. Each returns FT components for that page's content.
- **`pages/__init__.py`** is an empty file that makes the directory a proper Python package.

The advantages: separation of concerns, easy to find and update specific pieces, and straightforward to add new files to `pages/` as the site grows.

### Step 2: Reusable components (header, footer, layout)

Create `components.py` with three functions: `header()`, `footer()`, and `page_layout()`. The update from older tutorials: we use `Title()` tuples instead of manual `Html(Head(...))` wrapping. FastHTML auto-wraps the tuple in a full HTML document including any `hdrs` you passed to `fast_app()`.

**File: `mywebsite/components.py`**

```python
from fasthtml.common import *
from datetime import datetime

def header(current_page="/"):
    """Navigation bar shared across all pages.

    Args:
        current_page: Current page path, used to highlight the active link.
    """
    nav_items = [
        ("Home", "/"),
        ("About", "/about"),
        ("Contact", "/contact"),
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path
        link_class = "text-white hover:text-gray-300 px-3 py-2"
        if is_current:
            link_class += " font-bold underline"
        nav_links.append(Li(A(title, href=path, cls=link_class)))

    return Header(
        Div(
            A("MyWebsite", href="/", cls="text-xl font-bold text-white"),
            Nav(Ul(*nav_links, cls="flex space-x-2"), cls="ml-auto"),
            cls="container mx-auto flex items-center justify-between px-4 py-3",
        ),
        cls="bg-blue-600 shadow-md",
    )


def footer():
    """Footer shared across all pages."""
    current_year = datetime.now().year

    return Footer(
        Div(
            Div(
                P(f"\u00a9 {current_year} MyWebsite. All rights reserved.",
                  cls="text-gray-500"),
                cls="mb-4",
            ),
            Div(
                A("Privacy Policy", href="#",
                  cls="text-blue-500 hover:underline mr-4"),
                A("Terms of Service", href="#",
                  cls="text-blue-500 hover:underline"),
                cls="text-sm",
            ),
            cls="container mx-auto px-4 py-6 text-center",
        ),
        cls="bg-gray-100 mt-8",
    )


def page_layout(title, content, current_page="/"):
    """Wraps page content with header, footer, and meta tags.

    Returns a tuple that FastHTML auto-wraps into a full HTML document.
    The Tailwind CDN script and viewport meta are injected here for
    simplicity in this tutorial.
    """
    return (
        Title(title),
        Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
        Meta(name="description", content=f"{title} - MyWebsite built with FastHTML"),
        Script(src="https://cdn.tailwindcss.com"),
        header(current_page),
        Main(
            Div(content, cls="container mx-auto px-4 py-8"),
            cls="min-h-screen",
        ),
        footer(),
    )
```

What changed from older tutorials:

1. **`datetime.now().year`** instead of hardcoding `2025`.
2. **`page_layout` returns a tuple**: `(Title(...), Meta(...), header(...), Main(...), footer())`. FastHTML sees this tuple and wraps it in `Html(Head(...), Body(...))` automatically. No need for manual `Html(Head(Title(...)))`.
3. **Tailwind CDN script stays in `page_layout`** for simplicity. In production you'd move it to `fast_app(hdrs=...)` instead (see Production notes below).

<Notice type="warning" title="Tailwind Play CDN is for development only">
Tailwind's own docs state the Play CDN "is designed for development purposes only, and is not intended for production." The browser console will also warn you. For production, build a static CSS file with `npx @tailwindcss/cli -o public/styles.css` and serve it via a `<link>` tag, or use FastHTML's default Pico CSS for zero-build styling.
</Notice>

### Step 3: Individual page content

Create the page modules inside `pages/`. Start with an empty `pages/__init__.py` (just `touch pages/__init__.py`), then add each page.

**File: `mywebsite/pages/home.py`**

```python
from fasthtml.common import *

def home():
    """Home page content."""
    return Div(
        # Hero section
        Div(
            H1("Welcome to MyWebsite",
               cls="text-4xl font-bold text-gray-800 mb-4"),
            P("Build web applications with FastHTML and Python.",
              cls="text-xl text-gray-600 mb-6"),
            Div(
                A("Get Started", href="/about",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-4"),
                A("Learn More", href="/contact",
                  cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
                cls="flex",
            ),
            cls="py-12 text-center",
        ),
        # Features section
        Div(
            H2("Key Features", cls="text-3xl font-bold text-center mb-8"),
            Div(
                Div(
                    H3("Easy to Learn", cls="text-xl font-semibold mb-2"),
                    P("Built on Python, making web development accessible to everyone.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
                Div(
                    H3("Highly Productive", cls="text-xl font-semibold mb-2"),
                    P("Create web applications faster with fewer lines of code.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
                Div(
                    H3("Scalable", cls="text-xl font-semibold mb-2"),
                    P("Easily expand your application as your needs grow.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
                cls="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12",
            ),
            cls="py-8",
        ),
    )
```

**File: `mywebsite/pages/about.py`**

```python
from fasthtml.common import *

def about():
    """About page content."""
    return Div(
        H1("About Us", cls="text-3xl font-bold text-gray-800 mb-6 text-center"),
        Div(
            Div(
                H2("Our Story", cls="text-2xl font-semibold mb-4"),
                P("MyWebsite was created to help developers build "
                  "web applications using Python. Our mission is to make "
                  "web development accessible, enjoyable, and productive.",
                  cls="text-gray-600 mb-4"),
                P("We believe that Python developers should be able to create "
                  "stunning web applications without having to learn multiple "
                  "languages and frameworks.",
                  cls="text-gray-600 mb-4"),
                cls="mb-8",
            ),
            Div(
                H2("Our Team", cls="text-2xl font-semibold mb-4"),
                Div(
                    Div(
                        H3("Jane Doe", cls="text-xl font-semibold"),
                        P("Founder & CEO", cls="text-gray-500 italic mb-2"),
                        P("Python enthusiast with 15 years of experience in web development.",
                          cls="text-gray-600"),
                        cls="bg-white p-4 rounded shadow-md",
                    ),
                    Div(
                        H3("John Smith", cls="text-xl font-semibold"),
                        P("CTO", cls="text-gray-500 italic mb-2"),
                        P("Full-stack developer with a passion for clean, maintainable code.",
                          cls="text-gray-600"),
                        cls="bg-white p-4 rounded shadow-md",
                    ),
                    cls="grid grid-cols-1 md:grid-cols-2 gap-6",
                ),
            ),
        ),
    )
```

**File: `mywebsite/pages/contact.py`**

```python
from fasthtml.common import *

def contact():
    """Contact page content with a form."""
    return Div(
        H1("Contact Us", cls="text-3xl font-bold text-gray-800 mb-6 text-center"),
        Div(
            # Contact info
            Div(
                H2("Get in Touch", cls="text-2xl font-semibold mb-4"),
                P("We'd love to hear from you! Use the form or the "
                  "information below to reach out.",
                  cls="text-gray-600 mb-4"),
                Div(
                    P(Strong("Email: "), "info@mywebsite.com", cls="mb-2"),
                    P(Strong("Phone: "), "+1 (555) 123-4567", cls="mb-2"),
                    P(Strong("Address: "), "123 Web Street, Internet City, 10101",
                      cls="mb-2"),
                    cls="mb-6",
                ),
                cls="mb-8 md:pr-8",
            ),
            # Contact form
            Div(
                H2("Send a Message", cls="text-2xl font-semibold mb-4"),
                Form(
                    Div(
                        Label("Name", For="name", cls="block text-gray-700 mb-1"),
                        Input(type="text", id="name", name="name",
                              placeholder="Your name",
                              cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"),
                        cls="mb-4",
                    ),
                    Div(
                        Label("Email", For="email", cls="block text-gray-700 mb-1"),
                        Input(type="email", id="email", name="email",
                              placeholder="Your email",
                              cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"),
                        cls="mb-4",
                    ),
                    Div(
                        Label("Message", For="message", cls="block text-gray-700 mb-1"),
                        Textarea(id="message", name="message",
                                 placeholder="Your message",
                                 rows=5,
                                 cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"),
                        cls="mb-6",
                    ),
                    Button("Send Message", type="submit",
                           cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                    action="/submit-contact",
                    method="post",
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
            ),
            cls="md:flex",
        ),
    )
```

If you've built HTML forms before, this is straightforward Python. Each function returns FT components that render to standard HTML. The form `action` points to `/submit-contact`. We'll handle that POST in the next step.

For adding contact forms to other types of sites, see [adding a contact form to static websites](/add-contact-form-static-websites/).

### Step 4: Routing in the main application

Here's where the modern FastHTML idioms matter. The updated `main.py` uses `fast_app()` instead of `FastHTML()`, `@rt` instead of `@app.get`, a real 404 handler, and no `if __name__` guard.

**File: `mywebsite/main.py`**

```python
from fasthtml.common import *
from dataclasses import dataclass

from pages.home import home as home_page
from pages.about import about as about_page
from pages.contact import contact as contact_page
from components import page_layout

# --- 404 handler (defined before fast_app so we can pass it in) ---

def not_found(req, exc):
    error_content = Div(
        H1("404 - Page Not Found", cls="text-3xl font-bold text-gray-800 mb-4"),
        P("Sorry, that page doesn't exist.",
          cls="text-xl text-gray-600 mb-6"),
        A("Return Home", href="/",
          cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
        cls="text-center py-12",
    )
    return page_layout("404 Not Found - MyWebsite", error_content, current_page="/")

# Initialize the app.
# pico=False disables the default Pico CSS since we're using Tailwind.
# Tailwind CDN is injected via page_layout() for this tutorial.
# exception_handlers registers our 404 handler for real HTTP 404 status codes.
app, rt = fast_app(
    pico=False,
    exception_handlers={404: not_found},
)

# --- Routes ---

@rt("/")
def get():
    return page_layout("Home - MyWebsite", home_page(), current_page="/")

@rt("/about")
def get():
    return page_layout("About Us - MyWebsite", about_page(), current_page="/about")

@rt("/contact")
def get():
    return page_layout("Contact Us - MyWebsite", contact_page(), current_page="/contact")

# --- Form handling with dataclass binding + PRG ---

@dataclass
class Contact:
    name: str
    email: str
    message: str

@app.post("/submit-contact")
def submit_contact(contact: Contact):
    # In a real app, save to DB or send email here.
    # contact.name, contact.email, contact.message are typed and validated.
    return RedirectResponse("/thanks", status_code=303)

@rt("/thanks")
def get():
    thanks_content = Div(
        H1("Thank You!", cls="text-3xl font-bold text-gray-800 mb-4"),
        P("We've received your message and will respond soon.",
          cls="text-xl text-gray-600 mb-6"),
        A("Return Home", href="/",
          cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
        cls="text-center py-12",
    )
    return page_layout("Thank You - MyWebsite", thanks_content, current_page="/contact")

serve()
```

<Notice type="error" title="The old catch-all 404 returns HTTP 200">
The previous tutorial used `@app.get("/{path:path}")` for 404 handling. That returns **HTTP 200 OK** with a page that *says* "404". Crawlers and monitoring tools see a successful response, which is wrong for SEO and alerting. Use `exception_handlers={404: handler}` passed to `fast_app()` to return a genuine 404 status code.
</Notice>

<Notice type="info" title="serve() doesn't need a __main__ guard">
The official docs are explicit: "Never write `if __name__ == '__main__'` since `serve` checks it internally." The guard is harmless but unnecessary and flagged as non-idiomatic. Just call `serve()` at module level.
</Notice>

Key changes explained:

1. **`app, rt = fast_app(pico=False)`**: `fast_app()` is the modern initialization. It returns `(app, rt)` where `rt` is shorthand for `app.route`. By default it includes Pico CSS, htmx 2.x, and surreal.js headers. We pass `pico=False` because we're using Tailwind instead.

2. **`@rt("/")` decorator**: instead of `@app.get("/")`. The function name `get` maps to the HTTP method. Both styles work; `@rt` is the idiomatic shorthand.

3. **`exception_handlers={404: not_found}`**: registered at app creation. The handler receives `(req, exc)` and must return a response. FastHTML wraps the FT component in a full HTML doc automatically, and the response gets a real HTTP 404 status.

4. **`serve()` without a guard**: runs uvicorn on `0.0.0.0:5001` with live-reload by default.

### Step 5: Contact form with dataclass binding and PRG pattern

The contact form in Step 4 uses two patterns worth understanding:

**Dataclass binding**: instead of loose function parameters (`name: str, email: str, message: str`), we define a `@dataclass class Contact`. FastHTML resolves form fields to the dataclass constructor automatically. This gives you type validation and cleaner code.

**Post/Redirect/Get (PRG)**: the POST handler returns `RedirectResponse("/thanks", status_code=303)` instead of rendering the thank-you page directly. If a user refreshes after a direct POST render, the browser re-submits the form. A 303 redirect to a GET endpoint makes refresh safe.

<Accordion label="Why use Post/Redirect/Get (PRG)?" group="faq" expanded="true">
After a successful form POST, the browser holds the POST request in its history. If the user hits refresh, the browser asks "Re-send form data?" or silently re-submits. With PRG, the POST returns a 303 redirect to a GET endpoint. The browser's address bar shows the GET URL, and refreshing just reloads that page. No duplicate submissions.

This is a standard web pattern. The FastHTML community discussed it in [GitHub issue #389](https://github.com/AnswerDotAI/fasthtml/issues/389), and `RedirectResponse` is the documented way to do it.
</Accordion>

### Step 6 — Running and testing your website

Now let's run it:

```bash
cd mywebsite
pip install -r requirements.txt
python main.py
```

You should see uvicorn start on `http://0.0.0.0:5001`. Visit:

- `http://localhost:5001/` — Home page
- `http://localhost:5001/about` — About page
- `http://localhost:5001/contact` — Contact page with form

Click the nav links to move between pages. The current page should be highlighted in the header. Submit the contact form — you should be redirected to `/thanks`. Refresh that page — no duplicate form submission.

## Organizing routes with APIRouter

The pattern above (import page functions, define routes in `main.py`) works fine for small sites. When your project grows past 3-4 pages, FastHTML offers `APIRouter` — introduced in v0.8.0 — to let each module own its routes.

<Tabs>
<Tab name="Simple import pattern">
Routes are defined in `main.py` by importing page functions:

```python
# main.py
from pages.home import home as home_page
from components import page_layout

app, rt = fast_app(pico=False)

@rt("/")
def get():
    return page_layout("Home", home_page(), current_page="/")
```

This is the pattern from Steps 1-6. Works well for small projects.
</Tab>
<Tab name="APIRouter (modern pattern)">
Each page module creates its own `APIRouter` and registers itself with the app:

```python
# pages/home.py
from fasthtml.common import *
from components import page_layout

ar = APIRouter()

@ar("/")
def get():
    return page_layout("Home", home_content(), current_page="/")

def home_content():
    return Div(
        H1("Welcome to MyWebsite", cls="text-4xl font-bold text-gray-800 mb-4"),
        P("Build web applications with FastHTML and Python.",
          cls="text-xl text-gray-600 mb-6"),
        cls="py-12 text-center",
    )
```

```python
# main.py
from fasthtml.common import *
from components import not_found
from pages.home import ar as home_routes

app, rt = fast_app(
    pico=False,
    exception_handlers={404: not_found},
)

home_routes.to_app(app)

serve()
```

Each page module is self-contained — routes and content live together. The `main.py` just wires them to the app.
</Tab>
</Tabs>

**Prefix support**: `APIRouter("/blog")` adds a URL prefix to all routes in that module. Useful for namespacing, e.g., all blog routes live under `/blog/*` without repeating the prefix in every decorator.

For a real-world example of `APIRouter` in a multi-page project, see [building a multi-page AI tools website with FastHTML](/fasthtml-complex-ai-tools/).

## Verify and troubleshoot

After building the site, verify it works:

<ListCheck>
<ul>
<li>Visit `/`, `/about`, `/contact` — content renders correctly</li>
<li>`curl -i http://127.0.0.1:5001/nope` — returns `HTTP/1.1 404 Not Found` (not 200)</li>
<li>Submit the contact form — browser redirects to `/thanks` (check the URL bar)</li>
<li>Refresh the thank-you page — no duplicate form submission</li>
<li>Check browser console — no JS errors from Tailwind CDN</li>
</ul>
</ListCheck>

### Common errors and fixes

| Symptom | Likely cause | Fix |
|---|---|---|
| `ModuleNotFoundError: No module named 'pages'` | Missing `pages/__init__.py` or wrong working directory | Create the empty `__init__.py`; run from project root |
| 404 page returns HTTP 200 | Old catch-all `@app.get("/{path:path}")` still in code | Replace with `exception_handlers={404: handler}` |
| Port 5001 already in use | Another process on the port | Kill it (`lsof -i :5001`) or set `PORT=8000` env var |
| `AttributeError: 'NoneType'` on form submit | Missing `name` attribute on `<input>` | Ensure inputs have `name="name"`, `name="email"`, etc. |
| Tailwind styles not loading | Play CDN blocked or offline | Check browser network tab; use a local CSS build for reliability |

### Debug mode

Pass `debug=True` to `fast_app()` to get detailed error pages in the browser:

```python
app, rt = fast_app(pico=False, debug=True)
```

Never use this in production — it exposes stack traces to users.

### Testing with TestClient

You can test routes without a browser using Starlette's `TestClient`:

```python
from starlette.testclient import TestClient

client = TestClient(app)
print(client.get("/").status_code)       # 200
print(client.get("/nope").status_code)   # 404
print(client.post("/submit-contact", data={
    "name": "Test", "email": "a@b.c", "message": "hi"
}).status_code)                          # 303
```

This is useful for CI or quick smoke tests after changes.

## Production and deployment notes

The tutorial above is optimized for local development. Here's what changes when you deploy beyond localhost.

### Serving in production

`serve()` is a dev-friendly wrapper. It binds `0.0.0.0:5001` with live-reload on by default. For production, use uvicorn directly behind a reverse proxy for TLS termination and multiple workers:

```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```

Put Caddy, Nginx, or Traefik in front for automatic HTTPS. [Deploying a Python project with Dokploy](/dokploy-python-railpack-uv/) is one way to automate this on a VPS.

### CSS strategy

The Tailwind Play CDN compiles CSS in the browser on every page load — fine for development, slow and unreliable in production. Options:

1. **Build a static CSS file**: `npx @tailwindcss/cli -o public/styles.css` and serve it via `fast_app(hdrs=(Link(rel="stylesheet", href="/styles.css"),))`.
2. **Use Pico CSS**: FastHTML's default. Zero build step, classless, looks decent out of the box. Drop `pico=False` and remove the Tailwind script.

### Static files

`fast_app(static_path='public')` serves files from a `public/` directory automatically. Useful for images, CSS, and JS assets.

### Version pinning

FastHTML is 0.x with frequent breaking changes. v0.14.1 removed several imports from `fasthtml.common`. Always pin `python-fasthtml` in `requirements.txt` and re-test before upgrading.

### Sessions and secrets

`fast_app(secret_key="your-secret")` enables signed session cookies via Starlette's session middleware. Never hardcode secrets — use environment variables.

### Deploying to a VPS

For affordable hosting, a [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) starts at around €4/month and runs a FastHTML app without issues. You can [run any Python app in Docker](/docker-run-python/) or set up a full deployment pipeline by [deploying a Python project with Dokploy](/dokploy-python-railpack-uv/).

## Extending your multi-page website

The foundation is in place. Here's how to grow it:

### Adding more pages

Create a new file in `pages/`, add a route (or `APIRouter`), and add a nav link in `components.py`. That's it.

### Adding dynamic content

Fetch data from APIs, read from a database, or generate content based on user input. See [adding a SQLite database to your FastHTML app](/fasthtml-sqlite-db/) and [building an AI-powered web app with FastHTML and PydanticAI](/fasthtml-pydenticai-tools/) for concrete examples.

### Implementing user authentication

For protected pages: login/register routes, session management, and redirects for unauthenticated users. Follow [adding user authentication to your FastHTML app](/fasthtml-user-auth/).

### Enhancing the UI

Add HTMX interactions for partial page updates, Tailwind animations, and client-side form validation. [Building a multi-page AI tools website with FastHTML](/fasthtml-complex-ai-tools/) shows HTMX in action with a real project.

## Conclusion

You've built a FastHTML multi-page website with:

- **Modular project structure**: `main.py`, `components.py`, and per-page modules
- **Reusable shared layout**: header, footer, and page shell in one place
- **Modern FastHTML idioms**: `fast_app()`, `@rt`, `Title()` tuples
- **Proper 404 handling**: `exception_handlers={404: handler}` returning real HTTP 404
- **Form processing**: dataclass binding with Post/Redirect/Get pattern
- **A path to scale**: `APIRouter` for multi-file routing when the project grows

The code in this tutorial targets FastHTML 0.14.x. Pin your version, read the CHANGELOG before upgrading, and check the Production notes before deploying.

<Button text="Next: FastHTML Complex AI Tools \u2192" link="/fasthtml-complex-ai-tools/" variant="solid" color="blue" size="md" icon="arrow-right" />

## FAQ

<Accordion label="What is the difference between FastHTML() and fast_app()?" group="faq">
`fast_app()` is the modern shorthand. It returns `(app, rt)` where `rt` is `app.route`. It also injects default headers (Pico CSS, htmx 2.x, surreal.js) and session middleware. `FastHTML()` still works but requires manual setup of headers and middleware. Use `fast_app()` unless you need full control over every default.
</Accordion>

<Accordion label="Why does my 404 page return HTTP 200?" group="faq">
If you're using `@app.get("/{path:path}")` as a catch-all, the route handler returns HTTP 200 by default — the page *says* "404" but the status code is wrong. Replace it with `exception_handlers={404: handler}` passed to `fast_app()`. The handler receives `(req, exc)` and must return a response; FastHTML sets the 404 status automatically.
</Accordion>

<Accordion label="Should I use Tailwind or Pico CSS with FastHTML?" group="faq">
FastHTML defaults to Pico CSS — a classless CSS framework that styles semantic HTML with zero configuration. It's fine for internal tools and simple sites. Tailwind gives more control but requires either the Play CDN (dev only) or a build step for production. Use `pico=False` plus `hdrs=(Script(...),)` to switch to Tailwind. For production Tailwind, build a static CSS file with the Tailwind CLI.
</Accordion>

<Accordion label="How do I deploy a FastHTML app to production?" group="faq">
Run `uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4` behind a reverse proxy (Caddy, Nginx, or Traefik) for TLS termination. Pin `python-fasthtml` in your requirements file. Build static CSS if using Tailwind. Use `fast_app(secret_key=...)` with an environment variable for session signing. A [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) at ~€4/month handles this easily.
</Accordion>

<Accordion label="What is APIRouter and when should I use it?" group="faq">
`APIRouter` (available since FastHTML v0.8.0) lets each page module own its routes. Instead of importing page functions into `main.py` and defining all routes there, each module creates an `APIRouter`, decorates its own handlers, and calls `ar.to_app(app)` to register with the main app. Use it when your project grows beyond 3-4 pages. It supports `prefix=` for URL namespacing (e.g., `APIRouter("/blog")`).
</Accordion>