my-library
Architecture
One app, two front doors: a REST API for the mobile clients and a website that skips it entirely and talks to Postgres.
The stack
| Piece | Choice | Why |
|---|---|---|
| Framework | Next.js 15.5 (App Router) | Route handlers and pages in one project. The API and the site deploy together, so the site can never be reading a schema the API has moved on from. |
| Database | Neon Postgres | Same choice as kanji-buddy, for the same reasons: real Postgres, scales to zero, and branching clones the whole database instantly. |
| Query layer | Drizzle ORM 0.36 | Types come from the schema rather than a codegen step. The rating sort needed hand-written SQL dropped into a query builder, which Drizzle makes ordinary. |
| Driver | postgres 3.4 | Talks to Neon's pooler. prepare:false is mandatory — the pooler is PgBouncer in transaction mode and cannot do prepared statements. |
| Validation | zod 3.25 | One schema defines a valid book, and both the API routes and the import scripts parse through it. Bad OpenLibrary data is rejected at the same gate as a bad request body. |
| Styling | Tailwind 4.3 | CSS-first config: the palette is @theme tokens copied from mblrc-web, so the library matches the main site without a shared package. |
| Hosting | Vercel | Zero-config for Next, and push-to-deploy via the Git integration rather than a workflow to maintain. |
Exact versions
Written against next 15.5.21, react 19.2.8, drizzle-orm 0.36.4, postgres 3.4.9, zod 3.25.76, tailwindcss 4.3.3; dev: @electric-sql/pglite 0.5.4, drizzle-kit 0.28.1, tsx 4.23.1, typescript 5.9.3.
The decision that shapes everything
kanji-buddy made a point of having its pages fetch from its own API like any other client, so access control had one place to live. This app does the opposite, and the reason is the size of the shelf.
The REST API returns the entire library in one response — there is no pagination in it. That is fine for a mobile client that syncs once and keeps a local copy. It is indefensible for a web page, which would ship 3,367 rows to the browser on every load to display twenty-four of them.
Because the site is Server Components in the same project, it can issue LIMIT/OFFSET queries the REST API doesn’t expose. That is the whole argument:
- Pagination it otherwise wouldn’t have. Twenty-four rows leave the database instead of three thousand.
- No token in the browser. A public read-only site that fetched its own authenticated API would have to ship a credential to do it.
- One less hop. No function calling a function on the same machine.
What this costs
Two read paths now exist against one table, and a schema change has to satisfy both. That is a real cost and it is why the split is drawn at reads: the website never writes. Every mutation goes through /api/v1/books, so validation and ownership checks still have exactly one home.
Where the logic lives
Route handlers authenticate, parse with zod, call Drizzle, serialise. The web pages read through lib/queries.ts, which is the only module that knows about paging, searching and sort order. Nothing in either path reaches OpenLibrary — that happens offline, in scripts/.
listBooks({ query, sort, page, perPage }) → { books, total, page, totalPages }
getBookById(id) → book | null
getLibraryStats() → { total, authors }Each of those resolves the owning user first and scopes on it, exactly as the API routes do. The website is unauthenticated, but it is not unscoped — it asks for one user’s books, not for every row in the table.
Sorting by rating is not a sort
Ordering by community_rating DESC puts a book rated 5.0 by one person above The Hobbit at 4.3 from 497 ratings. That is not a ranking anyone wants, so the rating sort applies Bayesian shrinkage — each score is pulled toward the mean in proportion to how little evidence backs it.
(v / (v + m)) * R + (m / (v + m)) * C m = 25, C = 4.0
The Hobbit 4.3 from 497 → 4.29
a lone 5.0 5.0 from 1 → 4.04NULLS LAST keeps the roughly half of the library with no rating off the top. Details in Web app.