Back to posts

Triggering Multiple Routes from a Single Form - FastHTML & HTMX

March 23, 2025

By Rian Dolphin

I was building a dashboard in FastHTML where the page had several components, each backed by a different computation. My first attempt was the obvious one: a single route handler that computes everything and returns it all at once.

@app.get("/dashboard")
def dashboard():
    profile_data = compute_profile_data()  # Fast: 100ms
    activity_data = compute_activity_data()  # Medium: 500ms
    analytics_data = compute_analytics_data()  # Slow: 2000ms

    return Titled("Dashboard",
        profile_component(profile_data),
        activity_component(activity_data),
        analytics_component(analytics_data)
    )

The problem is obvious in hindsight. The page load time is the sum of all three computations because they run in series. One slow component blocks everything. The user stares at a blank page for 2.6 seconds when they could have been looking at the profile data after 100ms.

The fix: parallel HTMX requests

Instead of one route returning everything, you can use HTMX to fire multiple requests in parallel and update different parts of the page as each response arrives. The two key HTMX attributes are:

  • hx-trigger: what event triggers the request and from where
  • hx-include: which form inputs to include in the request

Full working example

import time

from fasthtml.common import *

app, _ = fast_app()


@app.get("/")
def home():
    return (
        Title("Multi-Route Form Example"),
        # Form with fields
        Form(
            id="main-form",
        )(
            Fieldset(
                Label("Your Name", Input(type="text", name="username", id="username")),
                Label("Message", Textarea(name="message", id="message")),
            ),
        ),
        # Button outside the form
        Button("Update All", id="trigger-button"),
        # Container for components
        Div(
            # Component 1 - Fast
            Div(
                hx_get="/greeting",
                hx_trigger="click from:#trigger-button",
                hx_include="#username",
                hx_swap="innerHTML",
                id="greeting-component",
            )(
                H3("Personal Greeting"),
                P("..."),
            ),
            # Component 2 - Medium
            Div(
                hx_post="/echo",
                hx_trigger="click from:#trigger-button",
                hx_include="#username, #message",
                hx_swap="innerHTML",
                id="echo-component",
            )(
                H3("Message Echo"),
                P("..."),
            ),
            # Component 3 - Slow
            Div(
                hx_get="/time",
                hx_trigger="click from:#trigger-button",
                hx_swap="innerHTML",
                id="time-component",
            )(
                H3("Current Time (with delay)"),
                P("..."),
            ),
        ),
    )


# Fast endpoint (~100ms)
@app.get("/greeting")
def greeting(username: str = None):
    return Div(H3("Personal Greeting"), P(f"Hello, {username or 'Anonymous'}!"))


# Medium endpoint (~500ms)
@app.post("/echo")
def echo(username: str = None, message: str = None):
    time.sleep(0.5)  # Simulate processing time
    return Div(
        H3("Message Echo"),
        P(f"From: {username or 'Anonymous'}"),
        P("Message: " + (message or "No message provided")),
    )


# Slow endpoint (~2000ms)
@app.get("/time")
def current_time():
    time.sleep(2)  # Simulate slow processing
    current_time = time.strftime("%H:%M:%S")
    return Div(
        H3("Current Time (with delay)"),
        P(f"Server time: {current_time}"),
        P("This component took 2 seconds to load"),
    )


serve()

You can paste this and run it directly if you have FastHTML installed. Open the network tab to see the three requests fire in parallel.

How it works

The page loads with placeholder "..." content. When the user clicks the button, three separate requests fire at once. Each component updates independently as its response arrives. The fast component appears almost immediately while the slow one takes its time. The UI progressively populates rather than making the user wait for the slowest component.

The setup is straightforward. The form has fields with IDs but no action or method. The button has an ID so components can listen for clicks on it. Each component div listens for clicks on that button via hx-trigger="click from:#trigger-button", pulls in only the form fields it needs via hx-include, and swaps its own content when the response arrives.

Each backend route only handles what it needs. /greeting only needs the username and returns quickly. /echo needs username and message with some processing time. /time doesn't need any form data at all.

The time.sleep() calls simulate real-world varying processing times, but in practice you'd have naturally slow operations like database queries, API calls, or expensive calculations. You can also add hx-indicator for loading spinners on each component, or hx-trigger="every:5s" for components that need regular polling.