A FastAPI admin panel with Jinja and HTMX, without an admin framework
A SaaS admin panel isn't really a database browser. It's a few screens that answer "who is this customer, what are they paying for, and can I help them?". That's small enough to build with ordinary FastAPI routes, Jinja templates and a few HTMX attributes, and it gets the same CSRF protection, sessions and tests as the rest of your app.
Contents
Why not an admin framework?
Libraries like SQLAdmin generate list and edit screens for your models, and for internal CRUD over many tables they're a good fit. A SaaS panel has different needs. The useful operations aren't "edit this row" but "deactivate this account and sign it out everywhere" or "give this partner the Pro plan until March". Those belong to your service layer, and a generic form that lets you type into auth_stamp or status is a way to break things.
So this panel has no generic editing at all. Every button is one route that calls one service function, about 200 lines of routes in total. If you later need bulk editing across many tables, add a framework next to it.
One dependency guards every route
# app/admin/routes.py
"""Admin pages. Everything here returns 404 to anyone who isn't an admin, so the panel
doesn't reveal that it exists."""
async def require_admin(user: Annotated[User, Depends(require_user)]) -> User:
if not user.is_admin:
raise HTTPException(status_code=404)
return user
AdminUser = Annotated[User, Depends(require_admin)]
router = APIRouter(prefix="/admin", dependencies=[Depends(verify_csrf), Depends(require_admin)])
Two choices here:
- The dependency is on the router, not on each route. A new admin route can't forget it, which is how admin endpoints usually end up public.
- Non-admins get 404, not 403. A 403 confirms the page exists. Signed-out visitors go to the login page through
require_user, and signed-in non-admins see the same answer as for any other wrong URL.
Routes that need to know who the admin is, in order to stop them demoting themselves, also declare admin: AdminUser. FastAPI caches dependencies within a request, so the check still runs only once.
Making the first admin
Who can make the first admin, if only admins can make admins? A small command you run on the server:
# app/accounts/make_admin.py
async def set_admin(email: str, is_admin: bool) -> bool:
async with SessionLocal() as session:
user = await get_user_by_email(session, email)
if user is None:
return False
user.is_admin = is_admin
await session.commit()
return True
docker compose exec web python -m app.accounts.make_admin you@example.com
docker compose exec web python -m app.accounts.make_admin you@example.com --remove
Whoever can run this already has shell access to your server, so it needs no further checks. Avoid the alternatives you'll see around: a hardcoded admin email in the code, or "the first user to sign up becomes admin", which is a race you can lose in production.
Search and pagination
The queries live in a service, like everywhere else. The search matches part of the email, or an exact user id, because a support message often quotes one or the other:
# app/admin/service.py
PAGE_SIZE = 25
@dataclass
class UserPage:
users: list[User]
total: int
page: int
pages: int
async def search_users(session: AsyncSession, query: str = "", page: int = 1) -> UserPage:
"""Newest users first; `query` matches part of the email, or the exact user id."""
conditions = []
if query := query.strip():
matches = [User.email.ilike(f"%{query}%")]
if query.isdigit():
matches.append(User.id == int(query))
conditions.append(or_(*matches))
total = await session.scalar(select(func.count()).select_from(User).where(*conditions)) or 0
pages = max(1, -(-total // PAGE_SIZE))
page = min(max(page, 1), pages)
result = await session.scalars(
select(User)
.where(*conditions)
.order_by(User.created_at.desc(), User.id.desc())
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
)
return UserPage(users=list(result), total=total, page=page, pages=pages)
-(-total // PAGE_SIZE)is ceiling division without floats: 26 users make 2 pages.- Clamping
pagemeans?page=999shows the last page instead of an empty table. User.idas a tie-breaker keeps the order stable when two users share a timestamp. Otherwise a row can show up on two pages, or on neither.ilikewith a leading%can't use a normal index, which is fine for an admin screen over thousands of users. With millions, add a trigram index.
The route is three lines of real work:
@router.get("/users")
async def users(request: Request, session: SessionDep, q: str = "", page: int = 1):
result = await service.search_users(session, q, page)
context = {"result": result, "q": q, "stats": await service.stats(session), "tab": "users"}
return templates.TemplateResponse(request, "admin/users.html", context)
Search as you type, with no partial template
Usually an HTMX update means a route that returns a fragment. Here there's a shortcut that needs no second template and no second route: request the whole page, and let HTMX take just the part it wants with hx-select:
<form method="get" action="/admin/users" role="search">
<label for="q" class="sr-only">Search by email or ID</label>
<input id="q" name="q" type="search"
hx-get="/admin/users" hx-trigger="input changed delay:300ms, search"
hx-target="#user-table" hx-select="#user-table" hx-swap="outerHTML" hx-push-url="true">
</form>
<div id="user-table">
<table>...</table>
</div>
Each attribute does one job:
hx-trigger="input changed delay:300ms, search"waits until typing pauses for 300 ms, so there's one query rather than one per keystroke. Thesearchevent covers pressing Enter or the clear button that some browsers show in a search field.hx-selectandhx-targetboth point at the table, so the input keeps its focus and cursor while the results change.hx-push-url="true"puts?q=gracein the address bar. A refresh keeps the search, and you can paste the link to a colleague.
Because it's still a normal form with a normal route, it works with JavaScript off, and the test only needs a plain GET. The cost is rendering a full page for each search, which doesn't matter on an admin screen with a handful of users on it. For a public page, write the partial.
Actions: POST, CSRF, and no locking yourself out
Anything that changes data is a POST from a small form, covered by the router's CSRF dependency. A plain link that deactivates a user would be triggerable from an image tag on someone else's page. Redirect with 303 afterwards, so a refresh doesn't repeat the action:
@router.post("/users/{user_id}/active")
async def toggle_active(request: Request, session: SessionDep, admin: AdminUser, user_id: int):
user = await _target(session, user_id)
if user.id == admin.id:
flash(request, "admin.flash.not_yourself", level="error")
else:
user.is_active = not user.is_active
await session.commit()
if not user.is_active:
await sign_out_everywhere(session, user) # also revokes API tokens
flash(request, "admin.flash.activated" if user.is_active else "admin.flash.deactivated")
return RedirectResponse(f"/admin/users/{user_id}", status_code=303)
Two details worth copying:
- Admins can't deactivate or demote themselves. The same guard on both actions means the panel always keeps at least one working admin, and no one locks themselves out by misclicking.
- Deactivating really ends access. Flipping a flag isn't enough if the user holds a valid session cookie and a JWT.
sign_out_everywhererotates the account's auth stamp, which invalidates both. Checkingis_activeon every request backs this up.
Giving someone a plan by hand
A beta tester, a partner, a goodwill month after an outage: sooner or later you'll want to give someone a plan without charging them. Creating fake Stripe subscriptions for that makes a mess of your revenue numbers. A small table of its own is cleaner:
# app/billing/models.py
class PlanGrant(Base):
"""A plan given by an admin without payment. It wins over subscriptions and
purchases until it expires (or forever when expires_at is empty)."""
__tablename__ = "plan_grants"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
plan: Mapped[str] = mapped_column(String(50))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
note: Mapped[str | None] = mapped_column(String(500))
granted_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
@property
def is_active(self) -> bool:
return self.expires_at is None or self.expires_at > datetime.now(UTC)
Expiry is checked when reading, so there's no scheduled job to run. The route validates what a form can get wrong:
user = await _target(session, user_id)
if plan not in get_settings().stripe_plans:
flash(request, "admin.flash.unknown_plan", level="error")
return _redirect(f"/admin/users/{user_id}")
expires_at = None
if expires_on:
try:
# The grant lasts through the chosen day.
expires_at = datetime.combine(date.fromisoformat(expires_on), time.max, tzinfo=UTC)
except ValueError:
flash(request, "admin.flash.bad_date", level="error")
return _redirect(f"/admin/users/{user_id}")
This works without further changes only because every plan check in the app goes through one function, which looks at the sources in order: grant, subscription, one-time purchase.
sources = (grant, subscription, payment)
plan = next((source.plan for source in sources if source), None)
Link to Stripe instead of rebuilding it
Refunds, plan changes, retrying a payment: Stripe's Dashboard already does all of that, with an audit trail. Don't rebuild it. Link straight to the right page, in the right mode:
def stripe_dashboard_url(path: str) -> str:
"""Link to an object in the Stripe Dashboard, in test or live mode to match the key."""
test = get_settings().stripe_secret_key.startswith(("sk_test_", "rk_test_"))
return f"https://dashboard.stripe.com/{'test/' if test else ''}{path}"
One line in a template (customers/cus_123) saves a lot of searching, and a test checks the link is right. Keep the panel's job to showing the state and getting you to the place that can change it.
Tests worth having
The most important admin test is the one proving other people can't get in. Loop over the URLs so that adding a new page is one more word:
async def test_admin_pages_are_hidden_from_other_users(client, make_user):
await make_user()
await login(client)
for url in ("/admin", "/admin/users", "/admin/users/1", "/admin/subscriptions"):
assert (await client.get(url)).status_code == 404, url
assert (await post_form(client, "/admin/users/1/admin")).status_code == 404
async def test_admin_cannot_lock_themselves_out(client, make_user, db):
admin = await admin_client(client, make_user)
await post_form(client, f"/admin/users/{admin.id}/active")
await post_form(client, f"/admin/users/{admin.id}/admin")
await db.refresh(admin)
assert admin.is_active and admin.is_admin
And test the effect that matters on the other side, with a second client acting as the user being deactivated:
async def test_deactivate_signs_the_user_out(client, make_user, db):
await admin_client(client, make_user)
ada = await make_user(email="ada@example.com")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other:
await login(other, "ada@example.com")
assert (await other.get("/account")).status_code == 200
await post_form(client, f"/admin/users/{ada.id}/active")
assert (await other.get("/account")).status_code == 303
assert (await login(other, "ada@example.com")).status_code == 400
Checklist
- Access check and CSRF as dependencies on the router; 404 for non-admins.
- The first admin made from the command line, nothing hardcoded.
- Stable sort order with a tie-breaker; clamped page numbers.
- Debounced search with
hx-selectandhx-push-url, on top of a form that works without JavaScript. - Changes only through POST with CSRF, followed by a 303 redirect.
- Admins can't lock themselves out; deactivation signs the user out everywhere.
- Manual plans in their own table, with one function deciding access.
- Links to Stripe rather than a rebuilt Stripe.
- A test that every admin URL answers 404 to everyone else.
This admin panel ships with getpylaunch, a FastAPI SaaS starter I sell, along with the billing and account pages it connects to.