Build a Hypermedia-Driven Python App with UserHarbor, FastHTML, and HTMX

Build a Hypermedia-Driven Python App with UserHarbor, FastHTML, and HTMX

●3 ●10 ●29
calendar_today ago • schedule18 min read
— Originally published at userharbor.github.io

Registration, verification, sessions, and password recovery can dominate a small application. I built UserHarbor to handle account operations through a small, framework-independent Python API.

Let's combine it with FastHTML and HTMX in a Hypermedia-Driven Application (HDA). The server owns application state and returns HTML; links and forms describe actions, and responses update cards or navigate between pages.

We'll build registration, email verification, login/logout, password recovery, a paginated directory, profiles, password changes, and deletion. Basecoat provides UI components.

The FastHTML Starter template includes the finished app; this walkthrough uses a smaller dashboard shell.

Why UserHarbor fits this stack

Each layer has a clear responsibility:

  • UserHarbor validates accounts, hashes passwords and tokens, manages sessions, and performs account operations.
  • FastHTML parses requests and renders Python components as HTML.
  • HTMX submits forms and applies HTML responses in the browser.
  • Your application chooses the layout, routes, cookie policy, and access rules.

UserHarbor uses UserStore for storage and EmailSender for delivery; we'll supply SQLAlchemy and SMTP adapters. Its API also works with other frameworks or a CLI. Role and permission checks are available when your application needs them.

Routes call harbor.register(...), harbor.login(...), or harbor.get_current_user(...), then choose an HTML response. No separate client app or JSON API is needed.

Set up the project

Use Python 3.14 or later, matching the starter, and uv:

uv init --python 3.14 fasthtml-app
cd fasthtml-app
uv add python-fasthtml "userharbor[sqlalchemy,smtp]" \
  fasthtml-basecoatui "fasthtml-tailwindcss[dev]" fasthtml-lucide
mkdir -p app/core app/components/auth app/layouts app/pages app/routes \
  app/email_templates app/static
touch app/__init__.py app/core/__init__.py app/components/__init__.py \
  app/components/auth/__init__.py app/layouts/__init__.py \
  app/pages/__init__.py app/routes/__init__.py

The wrappers supply components, CSS, and icons. Separate configuration, components, layouts, pages, and routes as below.

Save blocks under their indicated paths; finish all files before running.

Give UserHarbor storage and email delivery

Create a root .env with a random secret and SMTP credentials; keep it out of version control. APP_URL sets the email links' base URL.

.env

SECRET_KEY=replace-with-a-long-random-secret
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_EMAIL=*Emails are not allowed*
APP_URL=http://localhost:8000

app/core/settings.py

import os

SECRET_KEY = os.environ["SECRET_KEY"]
SMTP_HOST = os.environ["SMTP_HOST"]
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
SMTP_FROM_EMAIL = os.environ["SMTP_FROM_EMAIL"]
APP_URL = os.getenv("APP_URL", "http://localhost:8000")

Configure one shared instance. SQLite uses the working directory (app/users.db with our run command); create_all() initializes tables.

app/core/harbor.py

from pathlib import Path

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from userharbor import UserHarbor
from userharbor_smtp import SMTPEmailSender
from userharbor_sqlalchemy import SQLAlchemyUserStore

from .settings import (
    APP_URL,
    SECRET_KEY,
    SMTP_FROM_EMAIL,
    SMTP_HOST,
    SMTP_PASSWORD,
    SMTP_PORT,
    SMTP_USERNAME,
)

engine = create_engine("sqlite:///users.db")
SessionLocal = sessionmaker(bind=engine)

store = SQLAlchemyUserStore(SessionLocal)
store.metadata.create_all(engine)

email_sender = SMTPEmailSender(
    host=SMTP_HOST,
    port=SMTP_PORT,
    username=SMTP_USERNAME,
    password=SMTP_PASSWORD,
    from_email=SMTP_FROM_EMAIL,
    template_dir=Path(__file__).parent.parent / "email_templates",
    template_context={"app_url": APP_URL},
)

harbor = UserHarbor(secret_key=SECRET_KEY, store=store, email_sender=email_sender)

Override verification and recovery templates; other notifications use the SMTP defaults.

app/email_templates/verification.html

<p>Hello {{ username }},</p>
<p>
  <a href="{{ app_url.rstrip('/') }}/auth/verify_email?token={{ token | urlencode }}">Activate your account</a>
</p>

app/email_templates/password_reset.html

<p>Hello {{ username }},</p>
<p>
  <a href="{{ app_url.rstrip('/') }}/auth/reset_password?token={{ token | urlencode }}">Reset your password</a>
</p>

Routes return HTML, responses, or HTML with headers. UserLike exposes public fields, excluding the password hash.

app/core/types.py

from fasthtml.common import FT, HttpHeader, Response
from userharbor.interfaces import UserLike

type HTMLResult = FT | Response | tuple[FT | HttpHeader, ...]
User = UserLike

Build an HTML shell that HTMX can update

BaseLayout loads HTMX, Basecoat, and the nova CSS theme. Tailwind watches classes during development. Boosted links retain URLs and history while normally replacing the body contents.

Forms inherit the spinner; feedback goes to #alerts.

app/layouts/base.py

from fasthtml.common import FT, Body, Div, Head, Html, Meta, Script, Title
from fasthtml_basecoat import BasecoatInput, BasecoatJS, Spinner
from fasthtml_tailwindcss import TailwindHead


def BaseLayout(*c, title: str, **kwargs) -> FT:
    return Html(
        Head(
            Title(title),
            Meta(charset="utf-8"),
            Meta(name="viewport", content="width=device-width, initial-scale=1"),
            Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.js"),
            BasecoatJS(),
            TailwindHead(input=BasecoatInput("nova"), watch=True),
        ),
        Body(
            Spinner(
                id="global-spinner",
                cls="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none size-10 htmx-indicator",
            ),
            Div(id="alerts", cls="fixed top-4 inset-x-0 z-50 flex flex-col items-center gap-4"),
            *c,
            hx_boost="true",
            hx_indicator="#global-spinner",
        ),
        **kwargs,
    )

Add the theme switcher and dashboard header.

app/layouts/auth.py

from fasthtml.common import FT, Div
from fasthtml_basecoat import ThemeSwitcher

from .base import BaseLayout


def AuthLayout(*c, title: str, **kwargs) -> FT:
    return BaseLayout(
        Div(ThemeSwitcher(), cls="fixed top-4 right-4 z-50 flex items-center gap-4"),
        *c,
        title=title,
        **kwargs,
    )

app/layouts/dashboard.py

from urllib.parse import quote

from core.types import User
from fasthtml.common import FT, A, Div, Header, Main, Span
from fasthtml_basecoat import Button, ThemeSwitcher

from .base import BaseLayout


def DashboardLayout(*c, title: str, user: User) -> FT:
    return BaseLayout(
        Header(
            A("Users", href="/"),
            Span(title),
            A(user.username, href=f"/users/{quote(user.username, safe='')}"),
            Button(
                "Logout",
                type="button",
                variant="ghost",
                hx_post="/auth/logout",
                hx_swap="none",
                hx_disabled_elt="this",
            ),
            ThemeSwitcher(variant="ghost"),
            cls="flex flex-wrap items-center gap-4 border-b px-4 py-4",
        ),
        Main(Div(*c, cls="mx-auto max-w-6xl space-y-6 px-4 py-8"), id="dashboard-content"),
        title=title,
    )

Feedback is HTML too

hx_swap_oob="beforeend:#alerts" appends feedback even with hx_swap="none", preserving form input. UserHarbor raises domain errors; our component displays them.

app/components/alert.py

from typing import Literal

from fasthtml.common import FT, H2, Div, Footer, Section
from fasthtml_basecoat import Button
from fasthtml_lucide import Icon


def Alert(heading: str, message: str, variant: Literal["info", "error"]) -> FT:
    return Div(
        Div(
            Icon("info"),
            H2(heading),
            Section(message),
            Footer(
                Button(
                    Icon("circle-x"),
                    aria_label="Close",
                    data_tooltip="Close",
                    data_side="bottom",
                    data_variant="ghost",
                    onclick="this.closest('.alert').remove()",
                )
            ),
            cls="alert w-full max-w-md",
            data_variant="destructive" if variant == "error" else None,
            hx_on__load="setTimeout(() => { this.remove() }, 10000)",
        ),
        hx_swap_oob="beforeend:#alerts",
    )

Build the account forms

Input names match FastHTML route parameters. hx_post submits; hx_swap="none" preserves the form. hx_disabled_elt disables only that form's controls during requests.

The recovery link selects a hidden Basecoat tab.

app/components/auth/login.py

from fasthtml.common import FT, H2, A, Div, Form, Header, P, Section
from fasthtml_basecoat import Button, Card, Input, Label


def LoginCard(**attrs) -> FT:
    return Card(
        Header(H2("Login to your account"), P("Enter your credentials to access your account.")),
        Section(_LoginForm()),
        **attrs,
    )


def _LoginForm() -> FT:
    return Form(
        Div(
            Label("Username", fr="username"),
            Input(type="text", id="username", name="username", required=True),
            cls="grid gap-2",
        ),
        Div(
            Div(
                Label("Password", fr="password"),
                A(
                    "Forgot your password?",
                    onclick="event.preventDefault(); this.closest('.tabs').select(document.getElementById('auth-forgot-password-tab'));",
                    cls="ms-auto inline-block text-sm underline-offset-4 hover:underline",
                ),
                cls="flex items-center",
            ),
            Input(type="password", id="password", name="password", required=True),
            cls="grid gap-2",
        ),
        Button("Login", cls="w-full"),
        cls="grid gap-6",
        id="login-form",
        hx_post="login",
        hx_disabled_elt="#login-form input, #login-form button",
        hx_swap="none",
    )

Registration success replaces its card using HX-Retarget and HX-Reswap. Preserve the panel ID and tab attributes.

app/components/auth/register.py

from core.types import HTMLResult
from fasthtml import HttpHeader
from fasthtml.common import FT, H2, Div, Form, Header, P, Section
from fasthtml_basecoat import Button, Card, Input, Label


def RegisterCard(**attrs) -> FT:
    return Card(
        Header(H2("Create an account"), P("Enter your details to create your account.")),
        Section(_RegisterForm()),
        **attrs,
    )


def SuccessfulRegisterCard() -> HTMLResult:
    return (
        Card(
            Header(
                H2("Registration Successful"), P("Please check your email to verify your account.")
            ),
            cls="w-full max-w-sm text-center",
            id="auth-register-card",
            role="tabpanel",
            aria_labelledby="auth-register-tab",
        ),
        HttpHeader("HX-Retarget", "#auth-register-card"),
        HttpHeader("HX-Reswap", "outerHTML"),
    )


def _RegisterForm() -> FT:
    return Form(
        Div(
            Label("Username", fr="register-username"),
            Input(
                type="text",
                id="register-username",
                name="username",
                autocomplete="username",
                required=True,
            ),
            cls="grid gap-2",
        ),
        Div(
            Label("Email", fr="register-email"),
            Input(
                type="email", id="register-email", name="email", autocomplete="email", required=True
            ),
            cls="grid gap-2",
        ),
        Div(
            Label("Password", fr="register-password"),
            Input(
                type="password",
                id="register-password",
                name="password",
                autocomplete="new-password",
                required=True,
            ),
            cls="grid gap-2",
        ),
        Button("Create account", cls="w-full"),
        cls="grid gap-6",
        id="register-form",
        hx_post="register",
        hx_disabled_elt="#register-form input, #register-form button",
        hx_swap="none",
    )

Recovery returns a neutral confirmation for both known and unknown addresses.

app/components/auth/forgot_password.py

from core.types import HTMLResult
from fasthtml.common import FT, H2, Div, Form, Header, HttpHeader, P, Section
from fasthtml_basecoat import Button, Card, Input, Label


def ForgotPasswordCard(**attrs) -> FT:
    return Card(
        Header(
            H2("Forgot your password?"),
            P("Enter your email address to receive password reset instructions."),
        ),
        Section(_ForgotPasswordForm()),
        **attrs,
    )


def SuccessfulForgotPasswordCard() -> HTMLResult:
    return (
        Card(
            Header(
                H2("Check your email"),
                P(
                    "If an account exists for this email, you will receive password reset instructions."
                ),
            ),
            cls="text-center",
            id="auth-forgot-password-card",
            role="tabpanel",
            aria_labelledby="auth-forgot-password-tab",
        ),
        HttpHeader("HX-Retarget", "#auth-forgot-password-card"),
        HttpHeader("HX-Reswap", "outerHTML"),
    )


def _ForgotPasswordForm() -> FT:
    return Form(
        Div(
            Label("Email", fr="forgot-password-email"),
            Input(
                type="email",
                id="forgot-password-email",
                name="email",
                autocomplete="email",
                required=True,
            ),
            cls="grid gap-2",
        ),
        Button("Send reset instructions", cls="w-full"),
        cls="grid gap-6",
        id="forgot-password-form",
        hx_post="forgot_password",
        hx_disabled_elt="#forgot-password-form input, #forgot-password-form button",
        hx_swap="none",
    )

Reset carries the token in a hidden input and marks new passwords for password managers. The server compares both values.

app/components/auth/reset_password.py

from core.types import HTMLResult
from fasthtml.common import FT, H2, A, Div, Form, Header, HttpHeader, P, Section
from fasthtml_basecoat import Button, Card, Input, Label


def ResetPasswordCard(token: str, **attrs) -> FT:
    return Card(
        Header(H2("Reset your password"), P("Enter and confirm your new password.")),
        Section(_ResetPasswordForm(token)),
        **attrs,
    )


def SuccessfulResetPasswordCard() -> HTMLResult:
    return (
        Card(
            Header(
                H2("Password Updated"), P("Your password has been updated. You can now log in.")
            ),
            Section(A("Back to login", href="/auth/", cls="text-sm underline underline-offset-4")),
            cls="w-full max-w-sm text-center",
            id="reset-password-card",
        ),
        HttpHeader("HX-Retarget", "#reset-password-card"),
        HttpHeader("HX-Reswap", "outerHTML"),
    )


def _ResetPasswordForm(token: str) -> FT:
    return Form(
        Input(type="hidden", name="token", value=token),
        Div(
            Label("New password", fr="new-password"),
            Input(
                type="password",
                id="new-password",
                name="new_password",
                autocomplete="new-password",
                required=True,
            ),
            cls="grid gap-2",
        ),
        Div(
            Label("Confirm password", fr="confirm-password"),
            Input(
                type="password",
                id="confirm-password",
                name="confirm_password",
                autocomplete="new-password",
                required=True,
            ),
            cls="grid gap-2",
        ),
        Button("Reset password", cls="w-full"),
        cls="grid gap-6",
        id="reset-password-form",
        hx_post="/auth/update_password",
        hx_disabled_elt="#reset-password-form input, #reset-password-form button",
        hx_swap="none",
    )

Compose pages from cards

Compose the cards into tabs with matching aria_controls and panel IDs. Email links open separate reset and verification pages.

app/pages/auth.py

from components.auth.forgot_password import ForgotPasswordCard
from components.auth.login import LoginCard
from components.auth.register import RegisterCard
from fasthtml.common import FT, Button, Div, Nav
from fasthtml_basecoat import Tabs
from layouts.auth import AuthLayout


def AuthPage() -> FT:
    return AuthLayout(
        Div(
            Tabs(
                Nav(
                    Button(
                        "Login",
                        role="tab",
                        id="auth-login-tab",
                        aria_controls="auth-login-card",
                        aria_selected="true",
                        tabindex="0",
                    ),
                    Button(
                        "Sign Up",
                        role="tab",
                        id="auth-register-tab",
                        aria_controls="auth-register-card",
                        aria_selected="false",
                        tabindex="-1",
                    ),
                    Button(
                        "Reset password",
                        role="tab",
                        id="auth-forgot-password-tab",
                        aria_controls="auth-forgot-password-card",
                        aria_selected="false",
                        tabindex="-1",
                        hidden=True,
                    ),
                    role="tablist",
                    aria_label="Account access",
                    cls="w-full",
                ),
                LoginCard(role="tabpanel", id="auth-login-card", aria_labelledby="auth-login-tab"),
                RegisterCard(
                    role="tabpanel",
                    id="auth-register-card",
                    aria_labelledby="auth-register-tab",
                    hidden=True,
                ),
                ForgotPasswordCard(
                    role="tabpanel",
                    id="auth-forgot-password-card",
                    aria_labelledby="auth-forgot-password-tab",
                    hidden=True,
                ),
                id="auth-tabs",
                cls="w-full max-w-sm",
            ),
            cls="min-h-dvh flex items-center justify-center p-4",
        ),
        title="Account access",
    )

app/pages/reset_password.py

from components.auth.reset_password import ResetPasswordCard
from fasthtml.common import FT, Div
from layouts.auth import AuthLayout


def ResetPasswordPage(token: str) -> FT:
    return AuthLayout(
        Div(
            ResetPasswordCard(token, id="reset-password-card", cls="w-full max-w-sm"),
            cls="min-h-dvh flex items-center justify-center p-4",
        ),
        title="Reset your password",
    )

app/pages/verify_email.py

from fasthtml.common import FT, H2, A, Div, Header, P, Section
from fasthtml_basecoat import Card
from layouts.auth import AuthLayout


def VerifyEmailPage(error: str | None = None) -> FT:
    title = "Verification Failed" if error is not None else "Account Activated"
    message = error if error is not None else "Your account has been activated."
    return AuthLayout(
        Div(
            Card(
                Header(H2(title), P(message)),
                Section(
                    A("Back to login", href="/auth/", cls="text-sm underline underline-offset-4")
                ),
                cls="w-full max-w-sm text-center",
            ),
            cls="min-h-dvh flex items-center justify-center p-4",
        ),
        title=title,
    )

Connect forms to UserHarbor

Mount this router at /auth, making /login available as /auth/login. Routes handle HTTP; UserHarbor handles accounts.

Registration validates input, hashes passwords, stores a token hash, and sends email. Verification activates the account. Login puts the raw session token in an HttpOnly cookie; storage keeps its hash.

Return HX-Redirect with HTTP 200: HTMX ignores these headers on 3xx responses. Use secure=True with production HTTPS. Logout invalidates the session and clears the cookie.

Recovery is neutral for unknown addresses. Reset checks the token and password, then invalidates all sessions. Token pages disable caching and referrer forwarding.

app/routes/auth.py

from components.alert import Alert
from components.auth.forgot_password import SuccessfulForgotPasswordCard
from components.auth.register import SuccessfulRegisterCard
from components.auth.reset_password import SuccessfulResetPasswordCard
from core.harbor import harbor
from core.types import HTMLResult
from fasthtml.common import FT, HttpHeader, Response, fast_app
from pages.auth import AuthPage
from pages.reset_password import ResetPasswordPage
from pages.verify_email import VerifyEmailPage
from userharbor.exceptions import UserHarborError

auth_app, rt = fast_app()


@rt(methods=["GET"])
def index() -> FT:
    return AuthPage()


@rt(methods=["POST"])
def register(username: str, email: str, password: str) -> HTMLResult:
    try:
        harbor.register(username, email, password)
    except UserHarborError as e:
        return Alert("Registration Failed", str(e), variant="error")
    return SuccessfulRegisterCard()


@rt(methods=["GET"])
def verify_email(token: str) -> HTMLResult:
    try:
        harbor.verify_email(token)
    except UserHarborError as e:
        page = VerifyEmailPage(error=str(e))
    else:
        page = VerifyEmailPage()
    return (
        page,
        HttpHeader("Referrer-Policy", "no-referrer"),
        HttpHeader("Cache-Control", "no-store"),
    )


@rt(methods=["POST"])
def login(username: str, password: str) -> HTMLResult:
    try:
        session_token = harbor.login(username, password)
    except UserHarborError as e:
        return Alert("Login Failed", str(e), variant="error")
    response = Response(headers={"HX-Redirect": "/"})
    response.set_cookie(
        "session_token",
        session_token,
        path="/",
        httponly=True,
        secure=False,  # Set to True in production with HTTPS
        samesite="lax",
    )
    return response


@rt(methods=["POST"])
def logout(request) -> HTMLResult:
    try:
        harbor.logout(request.cookies.get("session_token", ""))
    except UserHarborError:
        pass
    response = Response(headers={"HX-Redirect": "/auth/"})
    response.delete_cookie("session_token", path="/")
    return response


@rt(methods=["POST"])
def forgot_password(email: str) -> HTMLResult:
    try:
        harbor.send_password_reset(email)
    except UserHarborError as e:
        return Alert("Password Reset Failed", str(e), variant="error")
    return SuccessfulForgotPasswordCard()


@rt(methods=["GET"])
def reset_password(token: str) -> HTMLResult:
    return (
        ResetPasswordPage(token),
        HttpHeader("Referrer-Policy", "no-referrer"),
        HttpHeader("Cache-Control", "no-store"),
    )


@rt(methods=["POST"])
def update_password(token: str, new_password: str, confirm_password: str) -> HTMLResult:
    if new_password != confirm_password:
        return Alert("Password Reset Failed", "Passwords do not match.", variant="error")
    try:
        harbor.reset_password(new_password=new_password, reset_token=token)
    except UserHarborError as e:
        return Alert("Password Reset Failed", str(e), variant="error")
    return SuccessfulResetPasswordCard()

Add a user directory with real URLs

Render UserList.items; its total drives pagination. FastHTML escapes text, and profile URLs encode usernames.

app/components/user_table.py

from urllib.parse import quote

from fasthtml.common import FT, A, Tbody, Td, Th, Thead, Tr
from fasthtml_basecoat import Table
from userharbor.interfaces import UserList


def UserTable(users: UserList) -> FT:
    return Table(
        Thead(Tr(Th("Username"), Th("Email"), Th("Verified"))),
        Tbody(
            *[
                Tr(
                    Td(
                        A(
                            u.username,
                            href=f"/users/{quote(u.username, safe='')}",
                            cls="hover:underline",
                        )
                    ),
                    Td(u.email),
                    Td("Yes" if u.verified else "No"),
                )
                for u in users.items
            ]
        ),
    )

Links such as ?page=2 inherit boosted navigation; current=True sets aria-current="page".

app/components/pagination.py

from fasthtml.common import FT
from fasthtml_basecoat import Pagination as BasePagination
from fasthtml_basecoat import PaginationLink


def Pagination(page: int, total_pages: int) -> FT | None:
    return (
        BasePagination(
            PaginationLink("Previous", href=f"?page={page - 1}") if page > 1 else None,
            *(
                PaginationLink(str(number), href=f"?page={number}", current=number == page)
                for number in range(1, total_pages + 1)
            ),
            PaginationLink("Next", href=f"?page={page + 1}") if page < total_pages else None,
        )
        if total_pages > 1
        else None
    )

app/pages/users.py

from components.pagination import Pagination
from components.user_table import UserTable
from core.types import User
from fasthtml.common import FT
from layouts.dashboard import DashboardLayout
from userharbor.interfaces import UserList


def UsersPage(users: UserList, user: User, page: int, total_pages: int) -> FT:
    return DashboardLayout(
        UserTable(users), Pagination(page, total_pages), title="Users", user=user
    )

Parse page, list with limit/offset, and clamp out-of-range pages. Empty results keep page one. The profile component follows next.

Username lookup ignores case, preserves stored casing, and returns None for missing users (HTTP 404 here). See User Manager for search and sorting.

app/routes/users.py

from core.harbor import harbor
from core.types import HTMLResult
from fasthtml.common import HTTPException, fast_app
from pages.user_details import UserDetailsPage
from pages.users import UsersPage

users_app, rt = fast_app()


@rt(methods=["GET"])
def index(request, page: int = 1) -> HTMLResult:
    page_size = 10
    page = max(1, page)
    users = harbor.users.list(limit=page_size, offset=(page - 1) * page_size)
    total_pages = max(1, (users.total + page_size - 1) // page_size)
    if page > total_pages:
        page = total_pages
        users = harbor.users.list(limit=page_size, offset=(page - 1) * page_size)
    return UsersPage(users, request.scope.get("user"), page, total_pages)


@rt("/users/{username}", methods=["GET"])
def details(request, username: str) -> HTMLResult:
    profile = harbor.users.get_by_username(username)
    if profile is None:
        raise HTTPException(status_code=404, detail="User not found")
    return UserDetailsPage(profile, request.scope["user"])

All signed-in users can read this demo's directory, including emails. User Manager does not enforce page permissions; choose your application's access rules.

Keep account actions tied to the signed-in user

Password changes require the old password and matching new values. Deletion requires the current password and confirmation, checked again on the server.

Keep the checkbox's label separately wrapped: Basecoat disables pointer events on labels immediately followed by disabled controls.

app/components/user_details.py

from fasthtml.common import FT, H2, Div, Form, Header, P, Section, Tbody, Td, Tr
from fasthtml_basecoat import Button, Card, Checkbox, Input, Label, Table
from userharbor.interfaces import UserLike


def UserDetailsCard(user: UserLike) -> FT:
    return Card(
        Header(H2("User details")),
        Section(
            Table(
                Tbody(
                    Tr(Td("Username", cls="font-semibold"), Td(user.username)),
                    Tr(Td("Email", cls="font-semibold"), Td(user.email)),
                    Tr(Td("Verified", cls="font-semibold"), Td("Yes" if user.verified else "No")),
                )
            )
        ),
    )


def ChangePasswordCard() -> FT:
    return Card(
        Header(
            H2("Change password"), P("You will need to log in again after changing your password.")
        ),
        Section(
            Form(
                Div(
                    Label("Current password", fr="old-password"),
                    Input(
                        type="password",
                        id="old-password",
                        name="old_password",
                        autocomplete="current-password",
                        required=True,
                    ),
                    cls="grid gap-2",
                ),
                Div(
                    Label("New password", fr="new-password"),
                    Input(
                        type="password",
                        id="new-password",
                        name="new_password",
                        autocomplete="new-password",
                        required=True,
                    ),
                    cls="grid gap-2",
                ),
                Div(
                    Label("Confirm password", fr="confirm-password"),
                    Input(
                        type="password",
                        id="confirm-password",
                        name="confirm_password",
                        autocomplete="new-password",
                        required=True,
                    ),
                    cls="grid gap-2",
                ),
                Button("Change password", type="submit"),
                id="change-password-form",
                hx_post="/account/change_password",
                hx_disabled_elt="#change-password-form input, #change-password-form button",
                hx_swap="none",
                cls="grid gap-6",
            )
        ),
    )


def DeleteAccountCard() -> FT:
    return Card(
        Header(H2("Delete account"), P("Deleting your account is permanent.")),
        Section(
            Form(
                Div(
                    Label("Current password", fr="delete-password"),
                    Input(
                        type="password",
                        id="delete-password",
                        name="password",
                        autocomplete="current-password",
                        required=True,
                    ),
                    cls="grid gap-2",
                ),
                Div(
                    Label(
                        Checkbox(
                            name="confirmed",
                            value="true",
                            required=True,
                            onchange="this.form.querySelector('button[type=submit]').disabled = !this.checked",
                        ),
                        "I understand that my account will be permanently deleted.",
                        cls="flex items-center gap-2",
                    )
                ),
                Button("Delete account", type="submit", variant="destructive", disabled=True),
                id="delete-account-form",
                hx_post="/account/delete",
                hx_disabled_elt="#delete-account-form input, #delete-account-form button",
                hx_swap="none",
                cls="grid gap-6",
            )
        ),
    )

Show account forms only to the profile owner. Viewing another profile never changes the session's identity.

app/pages/user_details.py

from components.user_details import ChangePasswordCard, DeleteAccountCard, UserDetailsCard
from core.types import User
from fasthtml.common import FT
from layouts.dashboard import DashboardLayout
from userharbor.interfaces import UserLike


def UserDetailsPage(profile: UserLike, user: User) -> FT:
    is_owner = profile.username == user.username
    return DashboardLayout(
        UserDetailsCard(profile),
        (ChangePasswordCard(), DeleteAccountCard()) if is_owner else None,
        title=profile.username,
        user=user,
    )

The /account handlers identify the account through the session cookie. UserHarbor checks that session and the password; hiding forms alone provides no authorization.

Changing a password invalidates all sessions. Both successful actions clear the cookie and redirect; errors return alerts.

app/routes/account.py

from components.alert import Alert
from core.harbor import harbor
from core.types import HTMLResult
from fasthtml.common import Response, fast_app
from userharbor.exceptions import UserHarborError

account_app, rt = fast_app()


@rt(methods=["POST"])
def change_password(
    request, old_password: str, new_password: str, confirm_password: str
) -> HTMLResult:
    if new_password != confirm_password:
        return Alert("Password Change Failed", "Passwords do not match.", variant="error")
    try:
        harbor.change_password(
            old_password=old_password,
            new_password=new_password,
            session_token=request.cookies.get("session_token", ""),
        )
    except UserHarborError as e:
        return Alert("Password Change Failed", str(e), variant="error")
    response = Response(headers={"HX-Redirect": "/auth/"})
    response.delete_cookie("session_token", path="/")
    return response


@rt(methods=["POST"])
def delete(request, password: str, confirmed: bool = False) -> HTMLResult:
    if not confirmed:
        return Alert("Account Deletion Failed", "Confirm account deletion first.", variant="error")
    try:
        harbor.delete_account(
            password=password, session_token=request.cookies.get("session_token", "")
        )
    except UserHarborError as e:
        return Alert("Account Deletion Failed", str(e), variant="error")
    response = Response(headers={"HX-Redirect": "/auth/"})
    response.delete_cookie("session_token", path="/")
    return response

Protect every mounted application

Root middleware resolves request.scope["user"] before dispatch, protecting every mounted child.

Auth routes and assets stay public; signed-in users skip login. Verification, recovery, and logout remain available. Normal redirects use 303; HTMX gets HTTP 200 with HX-Redirect, avoiding a login page swapped into a form.

app/main.py

from contextlib import suppress

from core.harbor import harbor
from fasthtml.common import FastHTML, Response, StaticImmutable
from routes.account import account_app
from routes.auth import auth_app
from routes.users import users_app
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
from starlette.routing import Mount
from userharbor.exceptions import UserHarborError


def redirect(request, url: str):
    if request.headers.get("HX-Request") == "true":
        return Response(headers={"HX-Redirect": url})
    return RedirectResponse(url, status_code=303)


async def check_session(request, call_next):
    path = request.url.path
    user = None
    if token := request.cookies.get("session_token"):
        with suppress(UserHarborError):
            user = harbor.get_current_user(token)
    request.scope["user"] = user

    public = path == "/auth" or path.startswith(("/auth/", "/static/"))
    if user and path in ("/auth", "/auth/", "/auth/login", "/auth/register"):
        return redirect(request, "/")
    if not user and not public:
        response = redirect(request, "/auth/")
        response.delete_cookie("session_token", path="/")
        return response
    return await call_next(request)


app = FastHTML(
    middleware=[Middleware(BaseHTTPMiddleware, dispatch=check_session)],
    routes=[
        Mount("/static", StaticImmutable(directory="static"), name="static"),
        Mount("/auth", auth_app, name="auth"),
        Mount("/account", account_app, name="account"),
        Mount("/", users_app, name="index"),
    ],
)

Run it and follow the complete flow

From the project root:

cd app
uv run --env-file ../.env uvicorn main:app --reload

Open http://localhost:8000. Running from app/ resolves the imports and puts users.db beside main.py.

  1. Register, verify your email, and log in.
  2. Compare profiles: only yours shows account forms.
  3. Create accounts; navigate to ?page=2 and back.
  4. Change your password, then log in with the new one.
  5. Log out and reset your password through email.
  6. Enter your password and confirm deletion to remove your account.

Requests show the spinner and disable the submitting controls; errors preserve forms. Logout disables only itself.

What makes this an HDA?

HTML carries content and actions. HTMX applies the next representation; UserHarbor manages accounts independently.

HTMX attribute or header Role in this application
hx-boost Enhance internal links and update browser history.
hx-post Submit form values to a Python handler.
hx-swap="none" Keep the form until an explicit update.
hx-swap-oob Append HTML feedback to the shared alerts container.
HX-Retarget, HX-Reswap Replace a card after success.
HX-Redirect Navigate after login, logout, or account changes.
hx-disabled-elt Disable the submitting form's controls or button.
hx-indicator Show the shared loading indicator.

Inline handlers only close alerts, switch tabs, and enable confirmation. Account state stays on the server. HTMX documents boosting, disabled controls, and redirects.

Take it into your own application

The starter adds responsive navigation. Explore UserHarbor's documentation for adapters and configuration.

For deployment, configure HTTPS, secure cookies, CSRF protection, secrets, directory access, migrations, SMTP delivery, and rate limits. Replace the development CSS watcher and initial table creation with your production workflow.

This is why I made UserHarbor framework-independent: reuse account management while keeping control of the UI. FastHTML and HTMX let that UI stay close to Python and HTML.

Read Hypermedia Systems, the free book from HTMX's creators, for more on HDA.

Part 3 of 3 in UserHarbor
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Dashboard Operasional Armada Rental Mobil dengan Python + FastAPI

Masbadar - Mar 12

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

Stop Implementing Authentication Inside Containers on Kubernetes

Alexandre Vazquez - Jul 25

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Python for commerce students, from a former bio kid

rahuldk1105 - Oct 30
chevron_left
2.3k Points • 42 Badges
7Posts
23Comments
40Connections
Simple is better than complex.

Related Jobs

View all jobs →

Commenters (This Week)

5 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!