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:
= db.t.todos
todos if not todos.exists():
id=int, task=str, completed=bool, created=str, pk='id') todos.create(
After:
class Todo: id:int; task:str; completed:bool; created:str
= db.create(Todo) todos
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):
= todos.insert(task=task, completed=False, created=datetime.now().isoformat())
new_todo return todo_item(todos[new_todo])
@rt
def toggle(id: int):
= todos[id]
todo =not todo.completed, id=id)
todos.update(completedreturn 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:
=f"/toggle?id={todo.id}" hx_post
After:
=toggle.to(id=todo.id) hx_post
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
# No need for if __name__ guard serve()
Titled
already wraps content in a Container
, and serve()
handles the main check internally.
FastHTML Handles Iterables - No unpacking needed for generators
Before:
*[todo_item(todo) for todo in all_todos], id="todo-list") Section(
After:
map(todo_item, all_todos), id="todo-list") Section(
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
id)
todos.delete(# 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:
=f"/delete?id={todo.id}" hx_delete
After:
=delete.to(id=todo.id) hx_post
FastHTML routes handle only GET and POST by default. Using only these two verbs is more idiomatic and simpler.
Modern HTMX Event Syntax
Before:
="htmx:afterRequest: this.reset()" hx_on
After:
="this.reset()" hx_on__after_request
This works because:
hx-on="event: code"
is deprecated;hx-on-event="code"
is preferred- FastHTML converts
_
to-
(sohx_on__after_request
becomeshx-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 camelCaseafterRequest