Bookshelf (Self-Hosted Ebook Library)

2026-08-25 · page · source ↗ · source summary

A self-hosted library for the ebooks you already own: a single Next.js app that catalogs EPUB and PDF files, serves a searchable shelf with covers, streams downloads, and reads books in the browser (epub.js and pdf.js), running either as a Cloudflare Worker over R2 or as a plain Node server over a directory on disk. What makes it worth reading as code isn't the ebook domain — it's the discipline of a hexagonal architecture done at small scale: the entire app is written against a narrow Storage interface and never against Cloudflare, so the same build serves a Worker and a VPS, and a read-only library is a first-class mode rather than an error.


Architecture

A Turborepo monorepo (~30,600 lines of TypeScript across 95 files):

apps/bookshelf       the app: a Next.js Worker over object storage
packages/core        the library format, the ZIP reader, the provider contract
packages/provider-r2 Cloudflare R2, both halves of it (worker + node)
packages/provider-fs the library as a directory on disk
packages/sync        the CLI that builds a library and publishes it
packages/fixtures    generated books, for tests and the demo shelf

The core of the design is ports and adapters. packages/core/src/provider.ts defines the contract as two faces, because the two callers run in different places and want different things: Storage (what the app reads at request time — head, read, readRange, optional write/remove, and deliberately no list) and StorageAdmin (what the CLI manages with — put from a disk path, remove, optional create/list/removeAll). A provider package exports a manifest plus a factory for each face, so @bookshelf/provider-r2/worker and @bookshelf/provider-fs/node are imported by different runtimes and get the half that suits them.

apps/bookshelf/src/services/container.ts is the composition root. createServices(storage, cache) wires the five services (CatalogService, BookContentService, ProfileService, ProgressService, and a cache) and knows nothing about any provider; getServices() is the only place that names one, choosing between the filesystem and Cloudflare at startup. There is no list on Storage by design — the catalog is what enumerates the library, so a request cannot discover books by walking the bucket; enumeration lives on StorageAdmin, which never runs in the app.

The library format (packages/core/src/catalog.ts, state.ts) is the shared contract between the CLI and the app: catalog.json at the root, one folder per book holding its formats, cover, and metadata.json. App-written state — profiles and reading positions — lives under a reserved .bookshelf/ prefix, dot-prefixed because book folders are slugified titles and can never begin with one, so the namespace is reserved by construction and a --force publish cannot delete everyone's bookmarks.

Key techniques

Design decisions

The authors optimized for portability, cheap reads, and graceful degradation, and the trade-offs are named rather than hidden. No authentication — explicitly listed under "Not done yet," with the honest warning that anyone who can reach the app can read the whole library and pick any profile. Two devices reading one profile is last-write-wins, made lossless in the common case by clients only sending positions they believe are newer. No multi-range HTTP support, because nothing that reads the library asks for more than one range at a time. No Zip64. Windows is unsupported because the sync tool finds its image tools with which.

The deepest design choice is regenerability as the safety property. Books are the source of truth; the published tree, covers, and catalog are all derived and rebuilt from scratch each publish, so a removed book cannot linger. The only non-derived data — who is reading, and how far they got — is precisely the data --force is prevented from touching. This mirrors the wiki's own raw/summary/topic split: the derived artifacts are disposable; only the sources are precious.

Comparison notes


Sources: raw/bookshelf, Bookshelf Last updated: 2026-08-25