Bitdoze Logo

FastHTML Multi-Page Website: Complete Structure Tutorial

Build a FastHTML multi-page website with reusable components, shared layouts, and APIRouter. Includes FastHTML 0.14 404 handling, forms, and project structure.

DragosDragos50 min read
FastHTML Multi-Page Website: Complete Structure Tutorial

In the first article, FastHTML: Getting Started, 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.

FastHTML series

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

Prerequisites

Before you start, make sure you have the following:

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

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.

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.

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

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).

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.

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

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

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

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.

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

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()

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.

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.

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.

Why use Post/Redirect/Get (PRG)?

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, and RedirectResponse is the documented way to do it.

Step 6 — Running and testing your website

Now let’s run it:

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.

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.

Verify and troubleshoot

After building the site, verify it works:

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

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:

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:

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:

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 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 starts at around €4/month and runs a FastHTML app without issues. You can run any Python app in Docker or set up a full deployment pipeline by deploying a Python project with Dokploy.

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 and building an AI-powered web app with FastHTML and PydanticAI 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.

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 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.

Next: FastHTML Complex AI Tools \u2192

FAQ

What is the difference between FastHTML() and fast_app()?

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.

Why does my 404 page return HTTP 200?

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.

Should I use Tailwind or Pico CSS with FastHTML?

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.

How do I deploy a FastHTML app to production?

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 at ~€4/month handles this easily.

What is APIRouter and when should I use it?

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")).