my-library
Web app
A shelf of three thousand covers that ships about a kilobyte of JavaScript, with search and paging that live in the URL.
Two pages
| Route | What |
|---|---|
/ | The shelf: cover grid, search, four sort orders, pagination. |
/books/[id] | One book: large cover, description, metadata, a link out to OpenLibrary. |
Both are Server Components reading through lib/queries.ts. Neither calls /api/v1/books; the reasoning is in Architecture. The site is read-only and unauthenticated — nothing here writes.
State lives in the query string
/?q=tolkien&sort=rating&page=2Search term, sort order and page are URL parameters, not React state. That is what makes results linkable, survive a refresh, and work with the back button — and it means the server does the filtering, so the browser never receives 3,367 rows in order to display 24.
Exactly one client component
SearchBar is the only "use client" file in the app. It debounces typing at 300ms and pushes a new URL:
const timer = setTimeout(() => {
const next = new URLSearchParams(params);
value.trim() ? next.set("q", value.trim()) : next.delete("q");
next.delete("page"); // a new search starts at page 1
startTransition(() => router.push(`/?${next}`, { scroll: false }));
}, 300);useTransition keeps the current results on screen while the next page streams in, so the grid never blanks between keystrokes. Dropping page on every new search matters: searching from page 5 and landing on an empty page 5 of two results is a bug people hit constantly.
Pagination, by contrast, is plain <Link> elements — no JavaScript required, and every page is a real URL. The control shows a window around the current page with the first and last always reachable, because 141 numbered links is not a navigation aid.
Four sort orders, one of which is not a sort
| Option | SQL |
|---|---|
| Recently updated | updated_at DESC |
| Title A–Z | title ASC |
| Author A–Z | author ASC |
| Highest rated | a Bayesian weighted score, see below |
Ordering by raw rating puts a book rated 5.0 by one person above The Hobbit at 4.3 from 497. The fix is standard shrinkage: pull every score toward the mean in proportion to how thin its evidence is.
(v / (v + m)) * R + (m / (v + m)) * C m = 25 · C = 4.0
The Hobbit 4.3 from 497 ratings → 4.29
a lone 5.0 5.0 from 1 rating → 4.04m = 25 is enough to bury a single-vote 5.0 while barely moving a book with hundreds of ratings. NULLS LAST keeps the ~49% of the library with no rating off the top, which Postgres would otherwise put first on a DESC sort.
Search is ILIKE, and that is fine
or(ilike(books.title, `%${term}%`), ilike(books.author, `%${term}%`))A trigram index or full-text search would be faster and would handle typos. At 3,367 rows a sequential scan is imperceptible, and the honest engineering answer is that the index would be there for a scale this table does not have. If the library reaches a size where it matters, pg_trgm is a migration, not a rewrite.
Design
The palette is lifted from mblrc-web’s stylesheet as Tailwind 4 @theme tokens — #191c22 background, #e9e6e0 text, the brass #d9a441 accent — so the library reads as part of the same site without a shared package to keep in sync. Inter throughout.
It is dark-only, matching the main site. A light variant existed briefly and was removed: on a light-mode OS it produced a cream library hanging off a dark homepage, which looked like two different products.
The grid runs 2 columns on phones to 5 on desktop. Covers do the work — with this many books the artwork is the interface, and titles sit under them rather than competing.
What it ships
The shelf page is about 1 kB of route JavaScript. Everything except the search box renders on the server, so the browser receives HTML and 24 image URLs rather than a dataset and a renderer.
Not built
- No editing. Writes need an auth layer this site doesn’t have. The mobile clients cover that.
- No filters. Author, year and publisher are all indexed facts and none of them are filterable yet — search is the only way to narrow the shelf.
- No infinite scroll. Pagination was chosen for linkable pages; it also happens to bound how many covers a session can request, which matters given how covers are served.