Ditching Django Admin for FastHTML with HTMX

Building a Django Admin-like dashboard with automatic refresh using FastHTML and HTMX

Posted on 2025-02-21 by Simon Moisselin

pythonfasthtmlhtmxdashboard

Django admin is fantastic for quick starts - free UI generated from django models. At our online school, we use it for platform monitoring, course creation, and debugging.

But over time, cracks appeared as our models grew more complex:

  1. Navigation: Employees kept clicking at the wrong place: between User/UserProfile models, or between FileAttempt/Attempt models (yes, we have UserProfile to extend User with more fields, and FileAttempt to store individual files of an attempt - try explaining that to non-tech people)
  2. Number of clicks: Tracking activity required 3+ model hops. If you wanted to know the last 10 attempts of a user, you had to click 3 times: User -> UserProfile -> Attempt. Then click on each attempt to see the details.
  3. Inlines don't scale: If you want to show multiple data models in one view you can use inlines. But nested inlines are a pain to navigate - you don't know the direction of the nested inline or what will happen when you save
  4. Risk of bad behavior: You can click on other pages, delete or see data that you shouldn't be able to access in the first place

For 2,

"The transaction cost of context switching between admin sections became higher than the value they provided." As a result, no-one wanted to use the admin dashboard.

Django admin issues

Django admin is a great multi-purpose tool, but we needed specific UIs for our use cases like monitoring and debugging. Rather than using a general solution, let's build a focused dashboard for platform monitoring.

Building a Purpose-Built Monitoring Dashboard

Three core metrics needed atomic monitoring:

  1. Daily active users (learner engagement)
  2. Exercise attempts (content effectiveness)
  3. Expiring subscriptions (business health)

Here's the visual solution I built in 142 lines of Python:

Daily Users

Daily users

Daily Attempts

Daily submissions

Subscriptions

Formations

Design Philosophy

  1. Vertical slices: Each card shows complete context for one entity
  2. Direct manipulation: Edit Python code instead of admin checkboxes

The user card implementation looks like a pandas dataframe operation:

def render_user(user: User, attempts_user: List[Attempt]):
    # Calculate engagement metrics using native Python collections
    stats = {
        'success': sum(1 for a in attempts_user if a.is_success),
        'failed': len(attempts_user) - sum(1 for a in attempts_user if a.is_success),
        'exercises': len({a.exercice.id for a in attempts_user}),
        'languages': {a.exercice.language for a in attempts_user}
    }

    # Time delta calculation without ORM dependencies
    duration = max(a.created for a in attempts_user) - min(a.created for a in attempts_user)

    return Card(
        DivLAligned(
            DiceBearAvatar(user.email, h=24, w=24),
            Div(cls='space-y-2')(
                H3(user.email.split("@")[0], cls='truncate max-w-[300px]'),
                DivHStacked(tag_school(user), Tags(stats['languages']), cls='gap-2')
            )
        ),
        Div(cls='space-y-2 mb-4')(
            P(f"Success: {stats['success']}", cls='text-green-600 font-medium'),
            P(f"Failed: {stats['failed']}", cls='text-red-600 font-medium'),
            P(f"Exercises: {stats['exercises']}", cls='font-medium')
        ),
        footer=DivFullySpaced(
            P(f"{duration.seconds//3600}h{(duration.seconds//60)%60}min", cls='font-bold'),
            UkIconLink("mail", height=16, href=f"mailto:{user.email}")
        ),
        cls=CardT.hover + " " + ("bg-white" if tag_school(user) is None else "bg-blue-50")
    )

Since LLMs are great at generating html code, this code was quite quick to generate and iterate on. I really like having pydantic / dataclass types as arguments of a frontend rendering function.

Smart Refresh Strategy

HTMX's hx-trigger enabled 5-minute auto-refresh while avoiding thundering herd problems:

last_refresh = datetime.datetime.now()

@app.get("/api/refresh")
def refresh():
    global last_refresh
    if needs_refresh(last_refresh):  # Global timestamp prevents stampede
        fetch_fresh_data()  # Single atomic update
        last_refresh = datetime.now()
    return dashboard()

What is happening here:

  1. Coordinated updates: Global timestamp prevents concurrent refreshes
  2. Cached snapshots: Data persists between refresh intervals
  3. Full page updates: our HTMX swaps entire dashboard, but visually only the visible part is updated. we should probably do partial updates in the future.

Reusable Refresh Component to be used anywhere

This python function returning a paragraph P will make any page call the /api/refresh endpoint every 5 minutes.

def rerender_refresh():
    return P(
        UkIcon("refresh-cw", height=20),
        "Auto-refresh every 5 minutes",
        cls="text-sm text-gray-600 flex items-center gap-2",
        hx_get="/api/refresh",
        hx_target="body",
        hx_trigger="every 5m"
    )

For example, this function will display a grid of users, and will refresh the page every 5 minutes because of the rerender_refresh component being used.

def render_users(users, attempts):
    return Div(
        H1(f"{len(users)} utilisateurs, {len(attempts)} tentatives"),
        rerender_refresh(),  # Adds auto-refresh every 5min via HTMX
        Grid(
            *(render_user(user, [a for a in attempts if a.user.id == user.id])
              for user in users),
            gap=4, cols_xl=4, cols_lg=3, cols_md=2, cols_sm=1, cols_xs=1
        ),
        cls='space-y-4'
    )

Simple Authentication

For internal dashboards and dev tools, a simple cookie-based auth is much faster to build than integrating OAuth providers:

beforeware = Beforeware(
    user_auth_before,
    skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', r'.*\.js', '/login', ]
)

@rt("/login")
def post(password: str, session):
    if password == SHARED_PASSWORD:
        session['auth'] = 'authenticated'  # Simple session storage
        return RedirectResponse('/', status_code=303)
    return Container(
        DivCentered(
            Alert("Incorrect password", cls=AlertT.error),
            A("Try again", href="/login", cls=AT.primary)
        )
    )

A Beforeware is just a middleware, that runs before the request from the client is processed by the python server. It checks if the user is authenticated by checking the session cookie inside the user_auth_before function.

What's next for this?

I should probably add filterings, sorting, and searching for each of those type of cards. Also I need more info per cards (number of attempts for the user, etc).

Another quick feature would be to use LLMs to automatically generate a 1-liner description of the activity for each user. Or a summary of the day done by LLMs too.

Also maybe I can have my django objects inside my fasthtml server? or at leasthave a direct db access instead of making api calls to my backend?

Also wanted to better integrate a "mail" button inside the cards to quickly send an email to the user. Ideally sending a mail directly from the dashboard this should be super fast to start interaction with the users based on their activity. But will need to keep track of existing email threads, can be a nightmare.

My Takeaways

Now monitoring my platform is easier, no excuses not to do it.

Related to dev tools, it's great to have the freedom from not having to use a general purpose framework and use python. It enabled rapid development - I built a complete dashboard in just one day, with features like color-coded urgency indicators for subscriptions. The simplicity of using plain Python dictionaries for state, HTMX for refreshes, and basic session auth meant the entire codebase stayed under 400 lines across just two files.

I really like Python's built-in functions. It handled data transformations more elegantly than JavaScript's functional methods. Global variables for state management is fine for a short-term solution, that will got you the job done.