· Patryk Komow · 8 min read

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.

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:

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.

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)

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:

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:

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

This admin panel ships with getpylaunch, a FastAPI SaaS starter I sell, along with the billing and account pages it connects to.