FastHTML Best Practices

FastHTML applications are different to applications using FastAPI/react, Django, etc. Don’t assume that FastHTML best practices are the same as those for other frameworks. Best practices embody the fast.ai philosophy: remove ceremony, leverage smart defaults, and write code that’s both concise and clear. The following are some particular opportunities that both humans and language models sometimes miss:

Database Table Creation - Use dataclasses and idempotent patterns

Before:

todos = db.t.todos
if not todos.exists():
todos.create(id=int, task=str, completed=bool, created=str, pk='id')

After:

class Todo: id:int; task:str; completed:bool; created:str
todos = db.create(Todo)

FastLite’s create() is idempotent - it creates the table if needed and returns the table object either way. Using a dataclass-style definition is cleaner and more Pythonic. The id field is automatically the primary key.

Route Naming Conventions - Let function names define routes

Before:

@rt("/")
def get(): return Titled("Todo List", ...)

@rt("/add")
def post(task: str): ...

After:

@rt
def index(): return Titled("Todo List", ...) # Special name for "/"
@rt
def add(task: str): ... # Function name becomes route

Use @rt without arguments and let the function name define the route. The special name index maps to /.

Query Parameters over Path Parameters - Cleaner URL patterns

Before:

@rt("/toggle/{todo_id}")
def post(todo_id: int): ...
# URL: /toggle/123

After:

@rt
def toggle(id: int): ...
# URL: /toggle?id=123

Query parameters are more idiomatic in FastHTML and avoid duplicating param names in the path.

Leverage Return Values - Chain operations in one line

Before:

@rt
def add(task: str):
  new_todo = todos.insert(task=task, completed=False, created=datetime.now().isoformat())
  return todo_item(todos[new_todo])

@rt
def toggle(id: int):
  todo = todos[id]
  todos.update(completed=not todo.completed, id=id)
  return todo_item(todos[id])

After:

@rt
def add(task: str):
  return todo_item(todos.insert(task=task, completed=False, created=datetime.now().isoformat()))

@rt
def toggle(id: int):
  return todo_item(todos.update(completed=not todos[id].completed, id=id))

Both insert() and update() return the affected object, enabling functional chaining.

Use .to() for URL Generation - Type-safe route references

Before:

hx_post=f"/toggle?id={todo.id}"

After:

hx_post=toggle.to(id=todo.id)

The .to() method generates URLs with type safety and is refactoring-friendly.

Built-in CSS Frameworks - PicoCSS comes free with fast_app()

Before:

style = Style("""
.todo-container { max-width: 600px; margin: 0 auto; padding: 20px; }
/* ... many more lines ... */
""")

After:

# Just use semantic HTML - Pico styles it automatically
Container(...), Article(...), Card(...), Group(...)

fast_app() includes PicoCSS by default. Use semantic HTML elements that Pico styles automatically. Use MonsterUI (like shadcn, but for FastHTML) for more complex UI needs.

Smart Defaults - Titled creates Container, serve() handles main

Before:

return Titled("Todo List", Container(...))

if __name__ == "__main__":
  serve()

After:

return Titled("Todo List", ...)  # Container is automatic

serve()  # No need for if __name__ guard

Titled already wraps content in a Container, and serve() handles the main check internally.

FastHTML Handles Iterables - No unpacking needed for generators

Before:

Section(*[todo_item(todo) for todo in all_todos], id="todo-list")

After:

Section(map(todo_item, all_todos), id="todo-list")

FastHTML components accept iterables directly - no need to unpack with *.

Functional Patterns - Use map() over list comprehensions

List comprehensions are great, but map() is often cleaner for simple transformations, especially when combined with FastHTML’s iterable handling.

Minimal Code - Remove comments and unnecessary returns

Before:

@rt
def delete(id: int):
  # Delete from database
  todos.delete(id)
  # Return empty response
  return ""

After:

@rt
def delete(id: int): todos.delete(id)
  • Skip comments when code is self-documenting
  • Don’t return empty strings - None is returned by default
  • Use a single line for a single idea.

Use POST for All Mutations

Before:

hx_delete=f"/delete?id={todo.id}"

After:

hx_post=delete.to(id=todo.id)

FastHTML routes handle only GET and POST by default. Using only these two verbs is more idiomatic and simpler.

Modern HTMX Event Syntax

Before:

hx_on="htmx:afterRequest: this.reset()"

After:

hx_on__after_request="this.reset()"

This works because:

  • hx-on="event: code" is deprecated; hx-on-event="code" is preferred
  • FastHTML converts _ to - (so hx_on__after_request becomes hx-on--after-request)
  • :: in HTMX can be used as a shortcut for :htmx:.
  • HTMX natively accepts - instead of : (so -htmx- works like :htmx:)
  • HTMX accepts e.g after-request as an alternative to camelCase afterRequest