Upgrade to Pro — share decks privately, control downloads, hide ads and more …

FastHTML: HTML applications, the Python way

FastHTML: HTML applications, the Python way

Many Python developers prefer to work in their favourite language rather than grapple with the complexities of HTML and other web technologies; this can make web application development more time-consuming, less efficient and less enjoyable.
FastHTML takes a different approach by allowing you to use Python exclusively to create web applications, combining this with a minimalist philosophy inspired by FastAPI.
Thanks to the use of tried-and-tested technologies such as Starlette and HTMX, it focuses on the developer experience to create web applications with minimal effort.
I will guide you through this framework, exploring its possibilities and sharing my journey of discovery.

Avatar for Iacopo Spalletti

Iacopo Spalletti

May 28, 2026

More Decks by Iacopo Spalletti

Other Decks in Technology

Transcript

  1. FastHTML HTML applications the Python way Iacopo Spalletti @ Nephila

    PyCon Italia 2026 Iacopo Spalletti - @yakkys
  2. then it didn’t complex data model permission system MiniDataAPI →

    SQLAlchemy ? a story Iacopo Spalletti - @yakkys
  3. the problem your Python is elegant your frontend is someone

    else’s problem Iacopo Spalletti - @yakkys
  4. frontend Jinja / Django template DRF / FastAPI React /

    Vue npm vite … the stack Iacopo Spalletti - @yakkys
  5. the result two languages two mental models two ecosystems two

    dependency systems … one application. the stack Iacopo Spalletti - @yakkys
  6. the pattern backend returns JSON JavaScript assembles the UI A

    hard to maintain two-way contract the dominant approach Iacopo Spalletti - @yakkys
  7. the original model 1991 HTTP + HTML server returns HTML

    → browser renders it the web was right all along Iacopo Spalletti - @yakkys
  8. the insight nothing was broken More interactions everywhere Javascript is

    the only way to go No longer the case the web was right all along Iacopo Spalletti - @yakkys
  9. hypermedia API-based backend → JSON → JavaScript → UI hypermedia-based

    backend → HTML → browser Iacopo Spalletti - @yakkys
  10. HTMX the missing piece any element · any event any

    event · any HTTP method partial DOM updates 14KB · no build step · no framework Iacopo Spalletti - @yakkys
  11. FastHTML built on Starlette · HTMX inspired by FastAPI ·

    for hypermedia async-native < 1000 lines of library code Iacopo Spalletti - @yakkys
  12. the mental model Q: how do I manage client state?

    Q: what HTML the server should return? Iacopo Spalletti - @yakkys
  13. minimal app 18 serve(port=5001) 1 from fasthtml.common import ( 2

    H1, Button, Div, Form, 3 Link, Style, Textarea, 4 fast_app, serve, 5 ) 6 7 app, rt = fast_app( 8 hdrs=[ 9 Link(rel="stylesheet", href=PICO_CDN), 10 Style(OVERRIDES), 11 ] 12 ) 13 14 @rt("/") 15 def get(): 16 return _page(history) 17 Iacopo Spalletti - @yakkys
  14. components Python functions are your components minimal app 13 def

    _page(msgs: list) -> tuple: 14 return ( 15 Title("Claude Chat"), 16 Div(H1("Claude Chat"), _messages_area(msgs), _input_bar()), 17 Script(JS) 18 ) 1 def _render_message(msg: dict) -> Div: 2 role = "You" if msg["role"] == "user" else "Claude" 3 return Div( 4 Div(role, cls="msg-role"), 5 Div(msg["content"], cls="msg-bubble"), 6 cls=f"msg {msg['role']}" 7 ) 8 9 def _messages_area(msgs: list) -> Div: 10 inner = [_render_message(m) for m in msgs] 11 return Div(*inner, id="messages") 12 Iacopo Spalletti - @yakkys
  15. interactivity click → request → HTML fragment → DOM update

    minimal app 17 @rt("/clear", methods=["POST"]) 18 def post_clear(): 19 history.clear() 20 return _page(history) 1 def _input_bar() -> Div: 2 return Div( 3 Form( 4 Textarea(id="user-input", rows="1"), 5 Button("Send", type="submit"), 6 id="chat-form", 7 ), 8 Button( 9 "Clear conversation", 10 hx_post="/clear", 11 hx_target="body", 12 hx_swap="outerHTML", 13 ), 14 id="input-bar", 15 ) 16 Iacopo Spalletti - @yakkys
  16. what comes in the box sessions · auth Databases via

    MiniDataAPI PicoCSS WebSockets · SSE Iacopo Spalletti - @yakkys
  17. MiniDataAPI what comes in the box 1 app, rt, todos,

    Todo = fast_app( 2 'data/todos.db', 3 id=int, task=str, done=bool, pk='id' 4 ) Iacopo Spalletti - @yakkys
  18. querying MiniDataAPI 8 todo = todos.get(id) 9 return Article(H2(todo.task)) 1

    @rt("/") 2 def get(): 3 items = todos() 4 return Ul(*[Li(t.task) for t in items]) 5 6 @rt("/todos/{id}") 7 def get_detail(id: int): Iacopo Spalletti - @yakkys
  19. searching MiniDataAPI 7 todos(where="done=0", order_by='task', limit=5) # combined 1 todos(where="done=0")

    # filtered 2 3 todos(order_by='created_at') # sorted 4 5 todos(limit=10, offset=20) # paginated 6 Iacopo Spalletti - @yakkys
  20. safe filtering MiniDataAPI 8 @rt("/search") 9 def get(q: str): 10

    results = todos("task like ?", (f"%{q}%",)) 11 return Ul(*[Li(t.task) for t in results]) 1 # never interpolate user input directly 2 todos(where=f"task='{q}'") # ⚠ SQL injection 3 4 # use positional placeholders instead 5 todos("task=?", (q,)) 6 7 # in a route Iacopo Spalletti - @yakkys
  21. [] — access by primary key MiniDataAPI 11 try: 12

    todos[999] 13 except NotFoundError: 14 ... 1 # integer key 2 todo = todos[1] 3 4 # string key 5 user = users['Alma'] 6 7 # compound key 8 pub = publications['Alma', 2019] 9 10 # missing key raises, not returns None Iacopo Spalletti - @yakkys
  22. .xtra() MiniDataAPI 7 # writes enforce the constraint too 8

    todos.insert(Todo(task="learn FastHTML")) 9 # → row has user_id set automatically 1 # lock the table to the current user's records 2 todos.xtra(user_id=session['user_id']) 3 4 # all subsequent reads are automatically filtered 5 todos() # only this user's todos 6 Iacopo Spalletti - @yakkys
  23. in — membership testing MiniDataAPI 9 # respects .xtra() scope

    10 todos.xtra(user_id=42) 11 99 in todos # False even if todo 99 exists for another user 1 # simple key 2 'Alma' in users # True 3 99 in todos # False 4 5 # compound key 6 ['Alma', 2019] in publications # True 7 ('John', 1967) in publications # False 8 Iacopo Spalletti - @yakkys
  24. CRUD MiniDataAPI 9 @rt("/todos/{id}") 10 def delete(id: int): 11 todos.delete(id)

    1 @rt("/") 2 def post(todo: Todo): 3 return todos.insert(todo), mk_input(hx_swap_oob='true') 4 5 @rt("/todos/{id}") 6 def put(todo: Todo): 7 return todos.upsert(todo) 8 Iacopo Spalletti - @yakkys
  25. database backends MiniDataAPI 5 # fastsql — any SQLAlchemy database

    6 from fastsql import * 7 db = Database('postgresql://...') 1 # fastlite — SQLite (default) 2 from fastlite import * 3 db = database('app.db') 4 8 9 # the rest of your code stays identical Iacopo Spalletti - @yakkys
  26. build your own library components 1 # components/ui.py 2 from

    fasthtml.common import * 3 4 def Card(title: str, *content, cls: str = "") -> Article: 5 return Article( 6 Header(H3(title)), 7 *content, 8 cls=f"card {cls}".strip() 9 ) 10 11 def Badge(text: str, variant: str = "primary") -> Span: 12 return Span(text, cls=f"badge badge-{variant}") Iacopo Spalletti - @yakkys
  27. composition components 10 def TodoList(items: list[Todo]) -> Ul: 11 return

    Ul(*[TodoItem(t) for t in items], id="todo-list") 1 def TodoItem(todo: Todo) -> Li: 2 return Li( 3 Span(todo.task), 4 Badge("done" if todo.done else "pending", 5 variant="success" if todo.done else "secondary"), 6 Button("✓ ", hx_patch=f"/todos/{todo.id}", 7 hx_target="closest li", hx_swap="outerHTML"), 8 ) 9 Iacopo Spalletti - @yakkys
  28. background tasks 5 async def post(email: str): 7 return P("Export

    started — check your inbox"), task 1 def send_report(email: str): 2 mailer.send(email, subject="Report ready") 3 4 @rt("/export") 6 task = BackgroundTask(send_report, email=email) Iacopo Spalletti - @yakkys
  29. SSE streaming demo 14 15 @rt("/chat") 16 def get_chat(request: Request)

    -> StreamingResponse: 17 history.append({"role": "user", "content": request.query_params["message"]}) 18 return StreamingResponse( 19 _stream_claude(DEFAULT_MODEL, DEFAULT_SYSTEM, history), 20 media_type="text/event-stream" 21 ) 1 def _sse_frame(text: str) -> bytes: 2 frame = "".join(f"data: {line}\n" for line in text.split("\n")) 3 return (frame + "\n").encode() 4 5 def _stream_claude(model, system, messages) -> Iterator[bytes]: 6 client = anthropic.Anthropic() 7 with client.messages.stream( 8 model=model, max_tokens=MAX_TOKENS, 9 system=system, messages=messages, 10 ) as stream: 11 for text in stream.text_stream: 12 yield _sse_frame(text) 13 yield b"data: [DONE]\n\n" Iacopo Spalletti - @yakkys
  30. the use cases internal tools · dashboards · prototypes single

    developer · small team fluent with FastAPI where it shines Iacopo Spalletti - @yakkys
  31. missing pieces no admin no ORM · no migrations no

    asset fingerprinting · no cache busting the limits Iacopo Spalletti - @yakkys
  32. ecosystem small community · young project fewer plugins · fewer

    answers on Stack Overflow the limits Iacopo Spalletti - @yakkys
  33. But Easily fit in AI context Less hallucinations Easy to

    AI-code a full FastHTML app ecosystem Iacopo Spalletti - @yakkys
  34. our story, revisited simple model → FastHTML ✓ complex model

    + permissions → FastHTML ✗ know your data model before you choose your framework Iacopo Spalletti - @yakkys
  35. the readability question vs Is this a concern? Readability counts

    Although practicality beats purity 1 Div(P("hello")) 1 <div><p>hello</p></div> Iacopo Spalletti - @yakkys
  36. Iacopo Spalletti Founder and CTO @Nephila_digital Djangonaut and open source

    developer @[email protected] https://github.com/yakky https://www.linkedin.com/in/iacopospalletti/ https://speakerdeck.com/yakky Iacopo Spalletti - @yakkys