The Stacks: Building a Library Management System That Feels Like a Place
There is a particular kind of calm that good software borrows from good paper. A library card, filled in by hand, signed and stamped, says as much about the institution as about the book. The Stacks — a small library management system I built this spring — tries to earn some of that calm. Under the hood it is a conventional React + FastAPI monorepo with Postgres and a Clerk-backed auth layer. On the screen it is an editorial archive: Fraunces set on cream, oxblood stamps, hairline rules, page-number margins. This is an article about how the two halves of that came together, where the seams are, and what I’d do again.
What it does
The product is deliberately narrow. Volumes can be searched, acquired, edited, removed, checked out, and checked in. Users have one of two roles — librarian or member — and the first person to sign in is auto-promoted to librarian. A librarian can promote or demote anyone else from a panel labelled “Staff records.” Search matches title, author, genre, or ISBN, debounced at 200 ms. Due dates default to fourteen days from today and overdue books are flagged in stamp-red at the right of each entry. Pressing / focuses the search input from anywhere.
That is, genuinely, almost the whole surface. Most of the care in the project is in the shape and feel of those interactions, not in a long feature list.
Architecture at a glance
The repo is a two-app monorepo:
apps/
web/ React 18 + TypeScript + Vite + @clerk/react
api/ FastAPI + SQLModel + Alembic + Postgres (psycopg v3)
docker-compose.yml
docker compose up --build is the one command anyone should ever need to run. The web container runs Vite’s dev server with polling for volume-mounted hot reload; the api container runs alembic upgrade head && uvicorn --reload; the db container is a vanilla postgres:16-alpine with a named volume. The .env.example at the repo root is a three-line file and every value in it is optional.
That last point is the load-bearing one.
The DEV_MODE escape hatch
Early on I made a decision that paid off repeatedly: if the Clerk environment variables are unset, the whole authentication stack short-circuits. The frontend doesn’t wrap the app in <ClerkProvider>. The backend doesn’t try to fetch JWKS. Every API request returns a seeded user named Dev Librarian with full permissions, and a small red “Preview” stamp appears in the masthead corner so nobody mistakes this for a real session.
# apps/api/app/auth.py
DEV_MODE = not os.environ.get("CLERK_ISSUER")
def get_current_user(...) -> User:
if DEV_MODE:
return _seeded_dev_librarian(session)
# ... full JWT verification path
This was tempting to treat as a local-only shortcut. I resisted. DEV_MODE is intentionally a deploy-anywhere state: if you push the API to Railway and the web to Cloudflare Pages without ever touching Clerk, a visitor gets a fully working instance with zero clicks and zero accounts. Same code path as local. The production version of “demo mode” is just the staging version of “dev mode,” which is just the tutorial version of “it works out of the box.” One code path, three use cases.
The tradeoff is that you need to trust the deployer to remember to set the Clerk variables when they want real auth. I decided I’d rather have a reversible default than an irreversible gate.
Letting Clerk own identity, keeping roles local
Clerk handles the sign-in modal, email verification, password resets, MFA if you enable it, the user button — all the identity plumbing I have no desire to rewrite. What Clerk doesn’t know about is whether a given user is a librarian. That lives in our user table, keyed by clerk_user_id.
The flow when a real user signs in for the first time:
- Frontend acquires a Clerk session JWT and attaches it as
Authorization: Bearer …. - Backend verifies the JWT against Clerk’s JWKS endpoint, pulling the cached keys via
httpx. - Backend uses the Clerk secret key to call the Clerk REST API for the user’s email and display name.
- Backend inserts a local
userrow with a freshly minted UUID and the profile fields it just enriched. - If no librarian exists yet in the table, this user becomes one. Otherwise they default to member.
existing_librarian = session.exec(
select(User).where(User.role == UserRole.librarian)
).first()
role = UserRole.member if existing_librarian else UserRole.librarian
That three-line block is the whole bootstrapping ceremony. No seed script, no admin CLI, no environment variable for “initial admin email.” The first person through the door owns the keys.
That check, as written, reads and writes in separate statements — so two users signing up in the same second could both see “no librarian yet” and both commit a librarian row. The hardening was to add an is_seed_admin boolean on user and enforce at most one true row through a partial unique index. When two inserts race to claim the flag, Postgres rejects the losing transaction; the loser rolls back, re-reads, finds the winner, and settles for member. The “first person through the door” now has the database’s backing, not just the application’s optimism.
The pattern of “external IdP for identity, local DB for authorization” is not novel, but it keeps the valuable part — role assignments and their audit surface — inside the system you control. If Clerk disappeared tomorrow you’d need a new sign-in provider, but you’d still have the user table intact.
The data model: inline vs. normalized
There are exactly two tables.
book holds not just the bibliographic metadata (title, author, ISBN, published year, genre, description) but also the current checkout state: status, borrower_name, borrower_user_id (a nullable FK to user.id, added later), borrowed_at, due_date. Checking out a book mutates these fields in place; checking in nulls them out.
user is the local user cache — clerk_user_id, email, first_name, last_name, role. (The original single name column was split later in migration 0003, when the UI wanted to address readers by first name without the string-parsing dance.)
The obvious “correct” schema would have a third table, loans, with one row per checkout event. That gives you history, audit, statistics, the satisfaction of normalization. I wrote it inline anyway, for the same reason I didn’t put a Redux store in the frontend: it would be inventing a schema to serve features that don’t exist yet. The scope is one library, staff who know each other, books that come back within a fortnight. If the product ever grew a “my past loans” page or a “top borrowers” leaderboard, that’s when you split the table — and you’d have the commit message write itself.
A note on the borrower: borrower_name started as a free-text string, not a foreign key to user. You could check a book out to “a visiting colleague” without making them sign up. This was a product decision disguised as a schema decision — the library wanting physical-world flexibility, the user table existing to gate what the system lets you do, not to enumerate who is allowed to receive books. That lasted until the first permission check; see “The borrower question” below.
The ISBN-unique saga
ISBNs are unique at three layers, and there’s a pleasing little story in how each layer came into existence.
The model declares it:
# apps/api/app/models.py
isbn: str | None = Field(default=None, index=True, unique=True)
The Alembic migration enforces it at the database:
sa.UniqueConstraint("isbn"),
op.create_index("ix_book_isbn", "book", ["isbn"], unique=True)
And the API turns the resulting IntegrityError into a 409:
except IntegrityError:
session.rollback()
raise HTTPException(409, "A book with this ISBN already exists")
All of which had been in the codebase for weeks before anyone noticed that the frontend silently swallowed the 409 and closed the acquire form as if nothing was wrong. addBook awaited the fetch, discarded the response, cleared the form, and reloaded the list — and the duplicate had simply not been added. The UI reported success; the database reported a conflict; no one was the wiser.
The fix was small:
const r = await authFetch("/books", { method: "POST", body: ... });
if (!r.ok) {
const body = await r.json().catch(() => null);
setAcquireError(body?.detail ?? "Could not commit this volume.");
return;
}
Plus an .acquire__error banner styled to match the rest of the archive — italic Fraunces, thin stamp-red border, a small star glyph — and a .field__input--error class that thickens the ISBN underline so the culprit field is obvious. Typing into the field clears the error, as do the Dismiss button and a successful submit.
The lesson is old but worth repeating: every response code you define has a corresponding frontend state. If you don’t have a visual for it, you don’t really have the feature — you have a bug waiting for a user.
The borrower question
The free-text borrower_name design lasted until the first real permission check. Once the app grew a rule like “a member can only return a book they checked out themselves,” the implementation revealed the flaw: there was no structural link between the book row and the user making the API call. The only way to decide ownership was to compare a string (book.borrower_name) to another string (current_user.display_name), and the moment a user had a trailing space, a typo, or a newly-edited profile, the check would fail closed on their own book — or worse, fail open on someone else’s.
The fix added borrower_user_id as a nullable UUID FK on book, populated from the authenticated user at checkout time. borrower_name stayed, but the server now derives it from the target user’s profile (first_name + last_name, falling back to email, then clerk_user_id) rather than accepting client-sent text. Check-in then becomes a clean guard:
if user.role != UserRole.librarian and book.borrower_user_id != user.id:
raise HTTPException(403, "Only the borrower or a librarian can return this book")
A second design question came with the checkout dialog. If the name is server-derived, what does the client send? The answer depends on who you are. Librarians can legitimately check out on behalf of anyone — so the dialog shows a searchable combobox of the full roster (keyboard-navigable, filters by name or email, backed by a new GET /users/directory endpoint). Members can only borrow for themselves — so the same slot shows a read-only field with their own name. One dialog, two modes, same POST body shape: { borrower_user_id, due_date }.
Gone is the “check out to a visiting colleague” flexibility. In exchange, every loan is attributable to a real account, check-in is enforceable, and the name on the stamp matches the user it refers to — a worthwhile trade for a product where accountability mattered more than informality.
The same work produced a parallel visibility rule on the frontend. Who a book is checked out to is now considered staff information: members see the stamp and whether the book is on-shelf or out, but not the borrower’s name or due date. That belongs to the librarians. And when a book is checked out to you, the stamp carries a small “by You” subtitle — a tiny bit of personal attribution that costs almost nothing and gives the interface the warmth of a circulation desk greeting.
The frontend: restraint as a state-management strategy
The web app has no Redux, no Zustand, no TanStack Query, no React Context beyond what Clerk provides. All state is useState inside LibraryApp.tsx. Book list, search query, form state, admin panel visibility, checkout-dialog target — every field is a hook.
This works because:
- There is exactly one screen. You never need to preserve state across a route transition because there are no routes.
- Mutations re-fetch. A POST to
/booksis followed immediately by aloadBooks(query). No optimistic updates, no cache invalidation graph. - The server is the source of truth for everything that matters. The frontend doesn’t negotiate with it; it asks and displays.
When an app has those properties, reaching for a state library is a way of paying rent on future complexity that may never arrive. If the product grew a route tree and a detail view and offline capability, I’d add the library then. For now: hooks and a 200 ms debounce.
One qualifier arrived with scale. Once the catalogue was large enough that returning every row on every search felt wasteful, /books started returning a shaped response — { items, total, on_loan } — and accepting limit and offset params. The frontend keeps books as the currently-loaded slice and fetches the next page with offset=books.length when the reader asks for more. The total and on_loan numbers being server-authoritative matters: the stats band in the masthead (“N volumes catalogued · M in circulation”) stays correct even when you’ve only loaded the first page, where the old client-side books.filter(b => b.status === "borrowed").length would have quietly under-counted. No state library was added — hooks, pagination math, and a “Load more” button cover it.
Failure as a first-class UI state
The earliest version of the frontend treated network calls as optimistic: fire the request, update the list, trust the round trip. That worked at the speed of docker compose up on a local network. It held less well against the reality of a user on spotty Wi-Fi or a browser tab that returned from fifteen minutes of laptop-lid-closed with a stale Clerk token.
The pattern that replaced it is small and has spread across the app. Every mutating call goes through a call() helper that catches network errors and non-OK responses, extracts the FastAPI detail string if one is available, and posts a Notice — a banner near the masthead, tone-coded error or info, auto-clearing after five seconds. Two things that used to be silent are now visible: a 403 on a check-in you don’t own, and a request that fails mid-session because the token expired.
POST /me gets a special case. If the initial call fails, the app stays on its “collecting cards” loader with a visible retry rather than dropping the reader into an empty dashboard they cannot explain. The difference between the app is broken and try again is almost entirely about whose agency the failure belongs to.
The assistant drawer
A recent addition is a chat drawer — a slide-out panel where a reader can ask “do we have any Le Guin?” or “check Piranesi out for me” and have the catalogue resolved, books borrowed, or loans returned on their behalf. It is backed by Claude Haiku 4.5 through the Anthropic SDK and a single POST /chat endpoint.
The design principle is that the assistant is given the same verbs the UI has, expressed as tool definitions: search_books, get_book, checkout_book, checkin_book, list_my_loans, create_book, update_book, delete_book, search_users, update_user_role. Ten tools, one per operation already in the REST layer. An agentic loop runs up to eight iterations per user turn; each iteration the model either returns a final text reply or emits tool_use blocks that the server dispatches against Postgres and returns as tool_result. When a mutating tool runs, the drawer emits an onMutated callback to its parent, which re-fetches the book list so the catalogue visibly updates alongside the conversation.
Authorization lives inside the tool handlers, not inside the prompt. Members cannot promote other users no matter how politely they ask; the update_user_role tool raises a ToolError before the database is touched, and Claude sees "This action requires librarian access." in the tool_result and corrects course. The system prompt tells the model what it can do per role, but the prompt is guidance; the backend is the boundary. If the model tried a librarian-only tool while serving a member, it would get a polite rejection and move on.
Like Clerk, the whole feature is gated on a single environment variable. If ANTHROPIC_API_KEY is absent, chat_enabled() returns false, /chat returns 503, and the frontend renders a disabled affordance. The DEV_MODE ethos — an optional dependency that is gracefully absent rather than loudly required — now covers three layers: authentication, authorization bootstrapping, and the language-model assistant.
The design: paper, not skeuomorphism
The UI is a variable-font love letter. Fraunces with its optical-size (opsz) and SOFT axes handles display, body, and italic. JetBrains Mono handles labels, tags, and numerals. The palette is four creams and three inks, with one accent — the oxblood stamp red that marks borrow events, errors, and the “Preview” watermark.
The whole page sits inside a fixed-position Perlin-noise SVG filter with multiply blend mode, which gives the otherwise flat cream a faint paper grain. The masthead uses a 3px double bottom border — the same treatment I used on the footer — which evokes the rule that a newspaper sets between the masthead and the first column of type. Book entries have a slowly animated hover underline that grows from 0 to 100% width on the title, mimicking an ink-stamp. Checkout and check-in both trigger a stampThud keyframe that rotates, pops, and settles a stamp badge on the relevant entry.
None of this is skeuomorphism in the iOS-6 sense — there are no leather textures or faux-wood bookshelves. The references are typographic: the layout of a page of type, the hierarchy of a masthead, the red-stamp-on-cream of a due-date slip. It reads as a catalogue, not a catalogue of a catalogue.
The recent trim-downs (removing the “Vol. MMXXVI · Issue 021” slug row; pulling the Sign in / Request a card buttons out of the masthead; pinning the footer to the viewport bottom with margin-top: auto on a flex-column .stage) were all in service of letting the hero title breathe. The masthead is now: title, subtitle, colophon, rule. Nothing above the title. The signed-in Clerk <UserButton /> floats in its own tiny top-right absolute slot, so it doesn’t have to claim a row of its own.
Deployment
The API is set up for Railway and the web for Cloudflare Pages, with Railway Postgres as the database. Three details made this easier than it might have been:
DATABASE_URLis normalised at startup frompostgres://orpostgresql://topostgresql+psycopg://. Railway hands out the first form; SQLAlchemy with psycopg v3 requires the third. A six-line function indb.pykeeps the standard Railway URL working as-is.- CORS origins are configurable via a
CORS_ORIGINSenv var (comma-separated), in addition tohttp://localhost:5173. The deployed Cloudflare Pages URL goes in that variable. - The API Dockerfile reads
${PORT:-8000}. Railway injectsPORT; locally it falls through to 8000.
Clerk development instance keys work on deployed URLs, so a live demo can run on a free Clerk tier without upgrading to a production instance unless you cross 100 monthly active users or want a custom auth domain. This matters a lot for public demos.
Adding tests after the fact
The prototype shipped without automated tests, on the premise that the scope was small enough for manual verification to stay honest. That held until the permission logic started to grow. The checkin-ownership guard, the librarian-only picker, the ISBN-uniqueness dance, the first-sign-in auto-promotion branch in _get_or_create — each new rule was a place where a silent regression would cost more than the test would. A suite and a CI pipeline went in together, so that subsequent refactors could be undertaken confidently rather than cautiously.
The API suite uses pytest with FastAPI’s TestClient, an in-memory SQLite engine wired through a StaticPool, and a dependency override for get_session. Auth is mocked by overriding get_current_user to return whichever fixture user the test wants — as_librarian and as_member clients handle both cases. Eighty-one tests cover routes, role guards, ownership, pagination, the user-provisioning path, and the chat tool dispatcher. The full suite runs in about half a second.
The web suite uses Vitest + React Testing Library + jsdom. Most tests are component-level (CheckoutDialog, BookIndex, AdminPanel, Stamp, ChatDrawer, and the buildFetch wrapper); a handful of LibraryApp integration tests mock authFetch with a tiny router-shaped stub and assert on request payloads. Forty-nine tests, about two seconds.
Two GitHub Actions workflows run on PRs — api-tests.yml and web-tests.yml — each path-filtered to its apps/* directory so a web-only change doesn’t wake up the Python installer. The API workflow pins Python 3.12 (matching the production Dockerfile); the web workflow runs tsc -b before the tests, because a type error is a failure I’d rather catch here than on Cloudflare.
The rule of thumb this reinforced: deferring tests through the early exploratory phase can be reasonable, but there is an inflection point where each new feature accretes untested behavior faster than the previous one did. That is when a suite stops being optional. For this project the inflection arrived around the borrower-ownership refactor; the backfill effort was small and the gain in refactoring confidence was immediate.
What’s not here (and that’s fine)
- No loans history. Inline checkout state, as discussed.
- No email reminders, no barcode scanning, no return queues. Those are product expansions, not platform gaps.
- No end-to-end tests. The unit and integration suites catch most regressions; a Playwright layer would catch a class of Clerk-session and layout bugs neither touches, but the surface is small enough that manual verification still fits in one coffee.
- No observability. Uvicorn’s access log is the closest thing; if something goes wrong in production I’ll be reading Railway’s logs, not a dashboard.
The project is the smallest thing that captures the feeling of running a library: cards, stamps, returns, a roster of readers, a single shelf of volumes. That’s what I set out to build. The code is the scaffolding.
Closing
I think often about the difference between software that works and software that composes — that has the posture and rhythm of a well-run institution. The Stacks is a small attempt at the second. Many of the interesting decisions were subtractive: no loans table, no state-management library, no routing library, no masthead slug, no sign-in buttons in the top bar. Others were additive as the scope clarified — borrower ids, a test suite, a permission guard on check-in. Every absence is a stance; stances are worth revisiting when the work requires it.
The code is at KamilNasr/library-management-system.