Bitdoze Logo

FastHTML for Beginners: Build a UI for Your Python App

Learn FastHTML for beginners and build your first UI for a Python app in 5 minutes using pure Python and HTMX. No JavaScript or build tools required.

DragosDragos44 min read
FastHTML for Beginners: Build a UI for Your Python App

FastHTML is a Python web framework created by Jeremy Howard and Answer.AI that lets you build complete web applications using only Python. No JavaScript, no build tools, no template languages. If you’ve ever wanted to build a UI for your Python app but didn’t want to learn React, Vue, or even HTML/CSS from scratch, this FastHTML for beginners tutorial will get you there in minutes.

FastHTML works by mapping Python functions directly to HTML elements. You write P("Hello") instead of <p>Hello</p>. It ships with HTMX 2.x for interactivity and Pico CSS for default styling, both included out of the box with no extra setup. The result: you go from a Python script to a working web app with interactive UI faster than with any other Python web framework.

  • Install FastHTML with pip or uv
  • Understand the function-to-HTML syntax
  • Build your first web page with a working server
  • Create common UI components (forms, tables, navbars)
  • Add interactivity with HTMX, no JavaScript needed
  • Build a complete todo application
  • Troubleshoot common errors
  • Run your FastHTML app in production on a VPS

If you’re exploring Python UI frameworks in general, also check out our NiceGUI beginner tutorial, another option for building Python-powered interfaces.

Why FastHTML?

Traditional web development forces you to juggle HTML, CSS, JavaScript, and frameworks like React or Vue just to put a button on a screen. FastHTML cuts through that by keeping everything in Python. Here’s what makes it worth a look:

  • Single language: backend and frontend in Python, no context switching
  • HTMX-first: interactivity without writing JavaScript. HTMX 2.x is bundled by default
  • Pico CSS included: your pages look decent out of the box with fast_app(), no Tailwind CDN needed
  • No build tools: no npm, no webpack, no node_modules. It’s just Python files
  • Python-native syntax: familiar Python functions instead of template languages
  • Built on Starlette/ASGI: production-ready foundation, drops into any ASGI host

Framework defaults

FastHTML’s fast_app() includes Pico CSS and live-reload by default. HTMX 2.x (>=2.0.4) is bundled, no separate import or CDN link needed. You get a styled, interactive page with zero configuration.

FastHTML sits in a different lane than Django or Flask. Those are great backend frameworks, but building a UI with them means either writing HTML templates or pairing with a JavaScript frontend. FastHTML gives you the whole thing in Python. You can compare FastHTML to Django, Flask, and FastAPI in our Python web frameworks guide, or check out Streamlit vs. NiceGUI for other Python UI approaches.

FastHTML Series

Below are the articles in this series to help you build with FastHTML:

Installing FastHTML

Prerequisites

  • Python 3.10 or newer (3.11/3.12 recommended). FastHTML does not work with Python 3.9 or below.
  • Terminal access on any OS (macOS, Linux, Windows)

If you need to install or upgrade Python on macOS, see our guide on how to install Python on macOS. For other OSes, check python.org.

ModuleNotFoundError?

If you see ModuleNotFoundError: No module named 'fasthtml', it almost always means either your Python version is below 3.10 or you forgot to activate your virtual environment. Check with python3 --version and make sure which python3 points to your venv.

Understanding FastHTML Syntax

FastHTML’s standout feature is its ability to let you write web pages using Python functions that map directly to HTML tags. Instead of juggling separate HTML files, you define your page structure in Python:

from fasthtml.common import *

# Creating a paragraph element
paragraph = P("Hello, World!")

P is a FastHTML function that generates <p>Hello, World!</p> when rendered. FastHTML provides similar functions for all standard HTML elements:

HTML FastHTML Example
<p> P() P("Text")
<h1> H1() H1("Heading")
<div> Div() Div(P("Child element"))
<a> A() A("Link text", href="https://example.com")
<input> Input() Input(type="text", name="username")

How FastHTML functions work:

Each function takes:

  • Positional arguments for child elements or content
  • Keyword arguments for HTML attributes

For example, P("Hello", cls="greeting") produces <p class="greeting">Hello</p>.

FastHTML maps two Python-reserved keywords to avoid syntax errors:

  • classcls (since class is a Python keyword)
  • forfr (since for is a Python keyword)

The wildcard import from fasthtml.common import * is by design and explicitly endorsed by the FastHTML team. It pulls in all the standard components, helpers, and the serve() function you need.

Building Your First Web Page

Let’s create a simple web page. Create a file called main.py:

from fasthtml.common import *

app, rt = fast_app()

@rt("/")
def get():
    return Titled("FastHTML Basics",
        P("Below is a list of features:"),
        Ul(
            Li("Easy to learn"),
            Li("Python-based"),
            Li("Dynamic and responsive")
        )
    )

serve()

Let’s break this down:

  1. from fasthtml.common import * imports all FastHTML components and helpers
  2. app, rt = fast_app() creates the application and returns a route decorator. fast_app() applies useful defaults: Pico CSS styling, live-reload, and HTMX 2.x, all bundled automatically
  3. @rt("/") maps the function to the root URL. The function name get tells FastHTML to handle GET requests
  4. Titled() is a helper that generates both a <title> and an <h1> tag in one call
  5. serve() starts the FastHTML server (it checks __name__ internally, so no if __name__ == "__main__": guard needed)

Long form still works

You’ll sometimes see app = FastHTML() with @app.get("/") in older examples. That still works, but fast_app() is the current recommended idiom. It’s what the official docs and all recent examples use.

Verify it works

Run the app from your terminal:

python main.py

You should see output like:

INFO:     Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
INFO:     Started reloader process ... using WatchFiles
INFO:     Started server process ...

Open your browser to http://localhost:5001. You should see a page with a heading and a bullet list.

You can also do a quick CLI smoke test:

curl -s localhost:5001 | head

If you see HTML output, the app is serving correctly.

It's running

The browser shows your page with Pico CSS styling applied automatically. No Tailwind CDN, no CSS file, fast_app() handles it. The live-reloader also picks up code changes without restarting manually.

What’s happening behind the scenes:

  1. FastHTML receives the request for /
  2. It calls the get() function
  3. The function returns a page built with FastHTML components
  4. FastHTML converts these components to HTML and sends it to the browser

Customizing headers (CSS/JS)

Pico CSS is the default with fast_app(). If you prefer Tailwind or need custom CSS/JS, pass them via the hdrs parameter:

app, rt = fast_app(
    pico=False,
    hdrs=(Script(src="https://cdn.tailwindcss.com"),)
)

Set pico=False to disable the default Pico CSS, then add whatever you need in hdrs.

Basic UI Components

Let’s explore the fundamental components for building interfaces with FastHTML.

Text elements

Text elements are the foundation of any interface:

@rt("/text-elements")
def get():
    return Titled("Text Elements",
        H2("Subheading"),
        H3("Section heading"),
        P("This is a regular paragraph with some ",
          Strong("bold text"), " and some ",
          Em("italicized text"), " mixed in."),
        P("You can also use ", Code("code snippets"), " inline.")
    )

Strong renders <strong>, Em renders <em>, and Code renders <code>. FastHTML follows the HTML element naming convention. If you know HTML, you already know FastHTML.

Containers and layout

Div is the workhorse for structuring pages:

@rt("/layout")
def get():
    return Titled("Layout Demo",
        # Sidebar and main content
        Div(
            Div(
                H2("Sidebar"),
                Ul(Li("Home"), Li("About"), Li("Services")),
            ),
            Div(
                H2("Main Content"),
                P("This is the main content area."),
            ),
        )
    )

With Pico CSS as the default, containers get sensible spacing and typography without extra classes. If you need custom layouts (grids, flexbox), add your own CSS via hdrs or use inline style attributes.

Links use A() and buttons use Button():

@rt("/navigation")
def get():
    return Titled("Navigation Demo",
        Nav(
            A("Home", href="/"),
            A("Features", href="/features"),
            A("Docs", href="/docs"),
            Button("Login"),
        ),
        P("Welcome to FastHTML")
    )

Nav creates a <nav> element. With Pico CSS, navbars and buttons get basic styling automatically.

Forms and inputs

Forms let users send data to your app. Here’s a contact form:

@rt("/contact")
def get():
    return Titled("Contact Us",
        Form(
            Label("Name", Input(type="text", name="name", placeholder="Your name")),
            Label("Email", Input(type="email", name="email", placeholder="Your email")),
            Label("Message", Textarea(name="message", placeholder="Your message", rows=5)),
            Button("Send Message", type="submit"),
            action="/submit-contact",
            method="post"
        )
    )

Notice the Label pattern — the input is nested inside the label, which is the docs-preferred approach. If you need an explicit for attribute, use fr (not For):

Label("Name:", fr="name")
Input(type="text", id="name", name="name")

FastHTML can inject form field values directly into your handler via typed parameters:

@rt("/submit-contact")
def post(name: str, email: str, message: str):
    return P(f"Thanks {name}, we got your message.")

When the form submits, FastHTML extracts name, email, and message from the form data and passes them as function arguments — no manual parsing needed.

Missing handler = 404

The form submits to /submit-contact. Make sure you define a matching POST handler (@rt("/submit-contact") def post(...)) or the form will return a 404 error.

Lists and tables

Organizing data with lists and tables:

@rt("/data-table")
def get():
    return Titled("User Data",
        Table(
            Thead(
                Tr(Th("ID"), Th("Name"), Th("Email"), Th("Role"))
            ),
            Tbody(
                Tr(Td("1"), Td("John Doe"), Td("john@example.com"), Td("Admin")),
                Tr(Td("2"), Td("Jane Smith"), Td("jane@example.com"), Td("User")),
                Tr(Td("3"), Td("Robert Johnson"), Td("robert@example.com"), Td("Editor")),
            )
        )
    )

Pico CSS automatically styles tables with borders, padding, and alternating row colors — no extra classes needed.

Adding Interactivity with HTMX

FastHTML integrates HTMX — a library that lets you create dynamic UIs without writing JavaScript. FastHTML bundles HTMX 2.x (≥2.0.4) by default, so there’s nothing extra to install.

HTMX 2.x bundled

FastHTML includes HTMX 2.x out of the box. No CDN link or separate import needed. The hx_* attributes (underscores become hyphens in HTML) are all available as keyword arguments.

Here’s a counter example that updates without a full page refresh:

from fasthtml.common import *

app, rt = fast_app()

counter = 0

@rt("/")
def get():
    return Titled("HTMX Counter",
        Div(
            P(f"Current count: {counter}", id="counter"),
            Button("Increment",
                  hx_post="/increment",
                  hx_target="#counter"),
            Button("Decrement",
                  hx_post="/decrement",
                  hx_target="#counter"),
        )
    )

@rt("/increment")
def post():
    global counter
    counter += 1
    return P(f"Current count: {counter}", id="counter")

@rt("/decrement")
def post():
    global counter
    counter -= 1
    return P(f"Current count: {counter}", id="counter")

serve()

Run it, click Increment, and watch the counter update — no full page reload. That’s HTMX in action.

HTMX attributes reference

FastHTML provides HTMX attributes as Python keyword arguments:

Attribute What it does Example
hx_post Sends a POST request when clicked hx_post="/increment"
hx_get Sends a GET request when clicked hx_get="/items"
hx_target Which element to update (CSS selector) hx_target="#counter"
hx_swap How to swap the response (innerHTML, outerHTML, beforeend) hx_swap="outerHTML"
hx_trigger When to trigger (click, change, etc.) hx_trigger="change"

Underscores in attribute names become hyphens in the rendered HTML: hx_posthx-post.

Building a Todo Application

Let’s combine everything into a working todo app with add, toggle, and delete:

from fasthtml.common import *
from dataclasses import dataclass

app, rt = fast_app()

todos = []
todo_id_counter = 0

@dataclass
class Todo:
    id: int
    title: str
    completed: bool = False

@rt("/")
def get():
    return Titled("Todo App",
        Form(
            Div(
                Input(type="text", name="title", placeholder="Add a new todo"),
                Button("Add", type="submit"),
            ),
            hx_post="/add-todo",
            hx_target="#todo-list",
            hx_swap="beforeend"
        ),
        Div(id="todo-list"),
    )

@rt("/add-todo")
def post(title: str):
    global todo_id_counter
    if not title.strip():
        return ""
    todo_id_counter += 1
    new_todo = Todo(id=todo_id_counter, title=title)
    todos.append(new_todo)
    return create_todo_item(new_todo)

@rt("/toggle-todo/{id}")
def post(id: int):
    for todo in todos:
        if todo.id == id:
            todo.completed = not todo.completed
            return create_todo_item(todo)
    return ""

@rt("/delete-todo/{id}")
def delete(id: int):
    global todos
    todos = [t for t in todos if t.id != id]
    return ""

def create_todo_item(todo: Todo):
    return Div(
        Div(
            Input(type="checkbox",
                  checked=todo.completed,
                  hx_post=f"/toggle-todo/{todo.id}",
                  hx_target=f"#todo-{todo.id}",
                  hx_swap="outerHTML"),
            Span(todo.title,
                 style="text-decoration: line-through; opacity: 0.6" if todo.completed else ""),
        ),
        Button("×",
               hx_delete=f"/delete-todo/{todo.id}",
               hx_target=f"#todo-{todo.id}",
               hx_swap="outerHTML"),
        id=f"todo-{todo.id}",
    )

serve()

How the todo app works

  1. Data structure: a Python dataclass defines the todo item shape
  2. Add: the form POSTs to /add-todo — FastHTML extracts title from the form data automatically. HTMX appends the new item to the list
  3. Toggle: clicking the checkbox POSTs to /toggle-todo/{id} — the server flips completed and returns the updated HTML. HTMX swaps it in place
  4. Delete: the × button sends a DELETE request to /delete-todo/{id} — the server removes the item and returns empty HTML, which HTMX uses to remove the element from the page

From in-memory to persistent storage

The todos = [] list resets every time the server restarts. That’s fine for learning, but not for anything real. FastHTML ships with Fastlite for SQLite persistence:

from fastlite import *

db = database("todos.db")
todos = db.create(Todo, pk="id", transform=True)

Since version 0.14.1, fastlite must be imported explicitly (it’s no longer re-exported from fasthtml.common). This gives you a SQLite-backed store that survives restarts.

For the full tutorial, see how to add a SQLite database to FastHTML.

Dev-only state

Global mutable state like counter and todos breaks if you run more than one worker process. Use session or a database for anything beyond prototyping.

What’s New in FastHTML Since 2025

FastHTML has gained significant features since this article was first published. Here’s what’s worth knowing:

  • MonsterUI — a shadcn-like component library built on top of FastHTML by Answer.AI. The recommended next step after learning the basics.
  • Fastlite — SQLite persistence via fastlite (see add a SQLite database to FastHTML)
  • Sessions — add a session parameter to any handler for per-user state
  • Beforeware — auth guards via fast_app(before=Beforeware(...))
  • OAuth built-ins — Google, GitHub, Apple, Auth0 support (see FastHTML Authentication)
  • Toastssetup_toasts(app) + add_toast(session, msg, "success") for flash messages
  • APIRouter — split routes across multiple files for larger apps (see FastHTML Multiple Pages)
  • WebSockets & SSEexts='ws' for WebSocket support, EventStream for server-sent events
  • fill_form() — bind dataclass instances to form fields automatically
  • llms.txt / llms-ctx.txt — LLM-friendly docs at fastht.ml for AI coding assistants
  • FastHTML Gallerygallery.fastht.ml for examples and inspiration

The FastHTML Advantage

FastHTML’s approach has some clear wins:

  1. Python-powered UI: write backend and frontend in one language
  2. Declarative syntax: compose functions instead of writing HTML templates
  3. Integrated interactivity: HTMX is bundled — no JavaScript framework needed
  4. No context switching: stay in Python from data layer to UI
  5. Minimal dependencies: no Node.js, no npm, no build step
  6. Fast iteration: functional UIs in minutes

FastHTML is well-suited for:

  • Internal tools and dashboards
  • Prototypes and MVPs
  • Data visualization applications
  • Admin interfaces
  • Any app where development speed matters more than complex client-side interactions

If you’re comparing Python UI approaches, take a look at Streamlit vs. NiceGUI — two other frameworks in this space.

Troubleshooting Common Issues

ModuleNotFoundError: No module named 'fasthtml'

Cause: Almost always one of two things — your Python version is below 3.10, or you forgot to activate your virtual environment.

Fix:

python3 --version
# Should show 3.10 or higher

which python3
# Should point to your venv, e.g. /path/to/fhenv/bin/python3

If Python is too old, upgrade. If which python3 doesn’t point to your venv, run source fhenv/bin/activate again.

Port 5001 already in use

Cause: Another process is using port 5001.

Fix: Change the port by passing it to serve():

serve(port=8000)

Since version 0.12.42, serve() passes keyword arguments directly to uvicorn.run, so any uvicorn option works.

Live-reload infinite loop

Cause: Known incompatibility with uvicorn ≥0.39.

Fix: Upgrade FastHTML — this was fixed in version 0.12.39:

pip install --upgrade python-fasthtml
Form returns 404 after submit

Cause: Your form’s action URL doesn’t match any handler, or the HTTP method is wrong.

Fix: Make sure you have a handler matching both the URL and the method. A form with action="/submit-contact" and method="post" needs:

@rt("/submit-contact")
def post(name: str, email: str, message: str):
    return P(f"Thanks {name}, we got your message.")

Check that the function name matches the HTTP method (get, post, delete, etc.).

Running FastHTML in Production

FastHTML’s serve() is a development convenience wrapper around uvicorn. For production, there are a few things to know.

From serve() to a production ASGI server

Run with uvicorn directly for production:

uvicorn main:app --host 0.0.0.0 --port 8000

Or use gunicorn with uvicorn workers for multi-process setups:

gunicorn main:app --worker-class uvicorn.workers.UvicornWorker --workers 2 --bind 0.0.0.0:8000

Never ship debug=True

debug=True exposes detailed error pages in the browser. Fine for development, a security risk in production. Remove it or set it to False before deploying.

Deploy behind a reverse proxy

Run your FastHTML app behind Caddy, Nginx, or Traefik for TLS termination, static file serving, and rate limiting. A basic Caddyfile:

yourdomain.com {
    reverse_proxy localhost:8000
}

Caddy handles HTTPS certificates automatically. The zero-config TLS setup is hard to beat for solo operators.

FastHTML is plain Starlette/ASGI — it works with any ASGI-compatible host. State lives in-process or in a SQLite file, so there’s zero cloud infrastructure cost by default. You just need a Linux VPS.

For hosting, I use Hetzner VPS from €4/mo for most of my projects — reliable, cheap, EU-based. Hostinger VPS from $5.99/mo is another budget option if you want a different provider.

To containerize your app, see our guide on how to run any Python app in Docker. If you prefer a PaaS workflow, check deploying a Python project with Dokploy for a self-hosted deployment setup.

Conclusion

You’ve gone from zero to a working FastHTML app with interactivity, forms, and a todo list — all in pure Python. Here’s what we covered:

  • Installing FastHTML with pip or uv
  • The function-to-HTML syntax that makes FastHTML tick
  • Building pages with fast_app() and @rt()
  • Creating UI components: forms, tables, navigation
  • Adding HTMX interactivity without JavaScript
  • Building a complete todo app with add, toggle, and delete
  • Troubleshooting common errors
  • Running in production behind a reverse proxy

From here, the natural next steps are:

And if you want to see how FastHTML stacks up against other frameworks, compare Python web frameworks for a broader view.