Every project needs an admin interface. API key management, user administration, configuration dashboards — the internal tools that keep a platform running.
The instinct is to reach for React, Vue, or Svelte. They're powerful, well-documented, and everyone knows them. But for internal tools — especially ones with a small user base and straightforward requirements — they're often overkill.
When we built the admin portal for PulseRelay, we chose a different path: server-rendered HTML with Jinja2 templates, enhanced with HTMX for interactivity. No build step. No node_modules. No JavaScript framework.
This post explains why we made that choice and how the implementation works.
Why Not React/Vue/Svelte?
For a public-facing app with complex client-side state, rich animations, and offline capabilities, a JavaScript framework makes sense. But our admin portal:
- Has maybe 2-3 users
- Requires no offline support
- Has simple, form-based interactions
- Lives behind a VPN (security over polish)
For this use case, a JS framework adds:
- Build complexity — Webpack/Vite config, npm dependencies, build pipeline
- Bundle size — Shipping 100KB+ of JavaScript for form submissions
- Two languages — Python backend AND JavaScript frontend, with data serialization between them
- Deployment friction — Building frontend assets, cache busting, asset serving
What do we get in return? Faster perceived interactions on form submissions. That's about it.
The Alternative: HTMX
HTMX lets you build dynamic interfaces using HTML attributes. Instead of writing JavaScript to fetch data and update the DOM, you declare what should happen:
<!-- When this form submits, POST to /keys, replace #key-list with the response -->
<form hx-post="/keys" hx-target="#key-list" hx-swap="innerHTML">
<input name="name" placeholder="Key name">
<select name="role">
<option value="publisher">Publisher</option>
<option value="subscriber">Subscriber</option>
</select>
<button type="submit">Create Key</button>
</form>
<div id="key-list">
<!-- Keys will be inserted here -->
</div>
The server returns HTML fragments. HTMX swaps them into the page. That's the entire model.
What HTMX Provides
hx-get,hx-post,hx-put,hx-delete— AJAX requests triggered by any elementhx-target— Which element to update with the responsehx-swap— How to insert the response (innerHTML, outerHTML, beforeend, etc.)hx-trigger— When to fire (click, submit, keyup, every 5s, etc.)hx-confirm— Browser confirmation dialog before requesthx-indicator— Show a loading spinner during requests
It's ~14KB minified. Include it via CDN, and you're done.
Our Admin Portal Structure
admin/
├── routes.py # FastAPI routes returning HTML
├── templates/
│ ├── base.html # Layout with HTMX, nav, flash messages
│ ├── login.html # Login form
│ ├── keys.html # Main dashboard
│ └── partials/
│ ├── key_row.html # Single key table row
│ ├── key_list.html # Full key table
│ └── key_created.html # Success message with new keyThe Base Template
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Admin{% endblock %} - PulseRelay</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
/* Minimal CSS - internal tool doesn't need to be pretty */
.htmx-request { opacity: 0.5; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
</style>
</head>
<body>
<nav>
<a href="/keys">API Keys</a>
<a href="/logout" hx-post="/logout" hx-confirm="Log out?">Logout</a>
</nav>
{% with messages = get_flashed_messages() %}
{% for message in messages %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>
Key points:
- HTMX loaded from CDN (one line)
.htmx-requestclass added automatically during requests (we use it to dim the UI)- Flash messages for server-side feedback
The Key Management Page
{% extends "base.html" %}
{% block content %}
<h1>API Keys</h1>
<!-- Create key form -->
<form hx-post="/keys"
hx-target="#key-list"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="name" placeholder="Key name" required>
<select name="role">
<option value="publisher">Publisher</option>
<option value="subscriber">Subscriber</option>
<option value="admin">Admin</option>
</select>
<input name="channels" placeholder="Channels (comma-separated, or leave empty for all)">
<button type="submit">
Create Key
<span class="htmx-indicator">...</span>
</button>
</form>
<!-- Key list -->
<table>
<thead>
<tr>
<th>Name</th>
<th>Public ID</th>
<th>Role</th>
<th>Channels</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="key-list" hx-get="/keys/list" hx-trigger="load">
<!-- Loaded via HTMX on page load -->
</tbody>
</table>
<!-- Modal for showing newly created key -->
<div id="key-modal"></div>
{% endblock %}
Notice:
hx-on::after-requestresets the form after successful submissionhx-trigger="load"fetches the key list when the page loads- No JavaScript written — just HTML attributes
Server Routes
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="admin/templates")
@router.get("/keys", response_class=HTMLResponse)
async def keys_page(request: Request):
"""Main keys management page."""
return templates.TemplateResponse("keys.html", {"request": request})
@router.get("/keys/list", response_class=HTMLResponse)
async def keys_list(request: Request):
"""Partial: key list table body."""
keys = await key_repo.get_all()
return templates.TemplateResponse(
"partials/key_list.html",
{"request": request, "keys": keys}
)
@router.post("/keys", response_class=HTMLResponse)
async def create_key(
request: Request,
name: str = Form(...),
role: str = Form(...),
channels: str = Form(""),
):
"""Create a new API key, return updated list + modal with key."""
channel_list = [c.strip() for c in channels.split(",") if c.strip()]
# Create the key (returns the full key, shown only once)
key_data = await auth_service.mint_key(name, role, channel_list)
# Get updated key list
keys = await key_repo.get_all()
# Return both the updated list AND a modal showing the new key
# Using HTMX's out-of-band swap feature
return templates.TemplateResponse(
"partials/key_created.html",
{
"request": request,
"keys": keys,
"new_key": key_data.api_key,
"key_name": name,
}
)
@router.delete("/keys/{key_id}", response_class=HTMLResponse)
async def revoke_key(request: Request, key_id: str):
"""Revoke a key, return updated list."""
await auth_service.revoke_key(key_id)
keys = await key_repo.get_all()
return templates.TemplateResponse(
"partials/key_list.html",
{"request": request, "keys": keys}
)
The server always returns HTML. No JSON serialization, no API contracts to maintain between frontend and backend.
The Key Created Modal (Out-of-Band Swap)
HTMX can update multiple elements from a single response using out-of-band swaps:
<!-- partials/key_created.html -->
<!-- This replaces the key list (normal swap target) -->
{% for key in keys %}
<tr>
<td>{{ key.name }}</td>
<td><code>{{ key.public_id }}</code></td>
<td>{{ key.role }}</td>
<td>{{ key.channels | join(", ") or "All" }}</td>
<td>
<button hx-delete="/keys/{{ key.id }}"
hx-target="#key-list"
hx-confirm="Revoke this key?">
Revoke
</button>
</td>
</tr>
{% endfor %}
<!-- This updates the modal (out-of-band) -->
<div id="key-modal" hx-swap-oob="innerHTML">
<div class="modal">
<h2>Key Created: {{ key_name }}</h2>
<p><strong>Copy this key now — it won't be shown again!</strong></p>
<code class="key-display">{{ new_key }}</code>
<button onclick="navigator.clipboard.writeText('{{ new_key }}')">
Copy to Clipboard
</button>
<button onclick="document.getElementById('key-modal').innerHTML=''">
Done
</button>
</div>
</div>
The hx-swap-oob="innerHTML" attribute tells HTMX: "Also update the element with id key-modal with this content, even though it's not the target."
One request, two UI updates. Still no JavaScript framework.
Patterns We Used
1. Progressive Enhancement
The portal works without JavaScript (just full page reloads). HTMX enhances it to avoid reloads. If HTMX fails to load, users can still manage keys.
2. Partials for Reusability
We render the same partial (key_list.html) whether it's:
- Initial page load (inside full page)
- Create key response (replacing table body)
- Revoke key response (replacing table body)
One template, multiple uses.
3. Server-Side Validation
All validation happens on the server. If something's wrong, we return an error message in the HTML response. No client-side validation logic to maintain.
@router.post("/keys", response_class=HTMLResponse)
async def create_key(request: Request, name: str = Form(...), ...):
if not name:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": "Name is required"},
status_code=400
)
# ... create key
4. CSRF Protection
We use a hidden CSRF token in forms:
<form hx-post="/keys">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- ... -->
</form>
Validated on every POST/PUT/DELETE. Standard web security, no special handling needed.
5. Session Management
Sessions are httponly cookies with signed tokens (using itsdangerous). Nothing special for HTMX — it sends cookies like any browser request.
What We Didn't Need
- State management library — Server is the source of truth
- Client-side routing — Full page loads for major navigation, HTMX for in-page updates
- API serialization — No JSON, just HTML
- Build tooling — No webpack, no npm, no node_modules
- Type definitions — No TypeScript interfaces mirroring Python models
- Testing infrastructure — Server tests cover the templates
When to Use This Approach
✓ Good fit:
- Internal tools / admin portals
- Small user base
- Form-heavy CRUD interfaces
- Teams that know Python better than JavaScript
- Projects where simplicity beats polish
✗ Not ideal:
- Public-facing apps needing polish
- Complex real-time interactions (collaborative editing, etc.)
- Offline-first requirements
- Heavy client-side computation
Results
Our admin portal:
- Loads in ~50KB total (HTML + HTMX + minimal CSS)
- Has zero npm dependencies
- Deploys as part of the Python application (no separate frontend build)
- Works identically in all browsers
- Is maintained by backend developers without frontend expertise
For an internal tool, that's a win.
This post is part of a series on building PulseRelay, a real-time data platform for live event timing systems. See also: Real-Time WebSocket Streaming with Redis Pub/Sub and Secure API Key Management with HMAC-SHA256.