Readifyr
Readifyr is a personal EPUB reader I built for myself: drag in a book, read it, highlight passages, close the tab, and come back later to the sentence you left on. There is no account and no server. Books live in the browser, which keeps a personal library private and offline-capable once imported, and makes the browser the only place reading state can be kept. It is my own project, end to end.
Context
I started with a ready-made React EPUB component. It rendered books, and that was roughly where its usefulness ended.
That is structural rather than a shortcoming of the library. An EPUB is rendered by epubjs into an iframe, and everything interesting happens in there: relocation between pages, text selection, taps and swipes, chapter boundaries, typography. React sits outside that document with no access to its events except through the renderer's own callbacks, which fire on the library's schedule rather than React's. Anything worth building — resuming at a position, highlighting a phrase, theming the text itself — has to cross that boundary in both directions.
The second constraint was wanting no backend at all. That is a product decision, in that nobody's books get uploaded anywhere, with a direct engineering consequence: IndexedDB has to hold entire EPUB files along with everything derived from them, and a browser tab has to behave like a durable library.
Architecture
The renderer gets a service, not a wrapper. I moved off the React component and took epubjs on directly, behind one object that constructs the book and rendition, registers themes and reader settings, attaches listeners, restores the stored position, and tears itself down. Every interaction with the renderer passes through a single known lifecycle. That is what made the ordering bug below fixable at all, because there was exactly one sequence to be wrong.
The library is a database, not a cache. Dexie over IndexedDB holds the original file blob together with parsed metadata, cover, table of contents, generated locations, reading position, highlights, and reader settings. Nothing is re-derived at runtime from a source of truth somewhere else, because there is nowhere else.
Between the two sits the bridge. Renderer callbacks fire outside React's lifecycle, so application state lives in a standalone Jotai store the service writes to directly. Components subscribe to it; the service does not need a mounted component to exist in order to report that the reader moved into a new chapter.
Stack
Client — Next.js 14 · React 18 · Jotai
Reading — epubjs
Persistence — Dexie · IndexedDB
Theming and delivery — next-themes · next-pwa · Workbox · Vercel
Engineering challenges
Taking over a lifecycle the wrapper was hiding
The problem. Generated locations for progress, custom content themes, annotations, swipe navigation, and persistence hooks all needed control the React wrapper did not expose. Each one individually could be worked around. Together they were the reader.
Why it was hard. A wrapper's job is to hide the renderer, and what I needed was the renderer. Taking it over means inheriting everything the wrapper had been absorbing: mount and unmount ordering, iframe event registration, cleanup on route change, and the fact that epubjs reports what it did after it has done it, on its own schedule rather than React's.
The decision. Own the lifecycle explicitly in one place instead of distributing renderer knowledge across components.
Implementation. A single service constructs the book, generates locations at import so progress can be expressed as a percentage, registers themes and reader settings, attaches relocation, selection, and touch listeners, displays the stored reading position, and on destruction persists position before cleaning up. Everything React needs flows out of those callbacks into the external store: current chapter, progress, selection state, and reader controls.
Outcome. Renderer ordering became a decision made in one method rather than a race distributed across a component tree, which is what the next story depends on entirely.
A book that flashed the wrong theme and then showed the wrong chapter
The problem. Later in the project, opening a book flashed the previous theme before settling, and navigating through the table of contents could leave stale or blank content behind.
Why it was hard. There are two theming systems, and they are not variations of one mechanism. The application shell is themed at the document level. The book is themed by styles registered with the renderer and injected into the iframe, which cannot see the shell's variables and does not change at the same moment. A flash is the symptom of the second system arriving late. Blank and stale content is the same class of fault seen from the other side: work performed against a rendition that was not ready yet, or not redone after it was replaced.
The decision. Treat it as an ordering problem rather than a rendering problem. Nothing new needed to exist; the sequence was wrong.
Implementation. Themes and reader settings are registered and applied before listeners are attached and before the stored position is displayed, so the first painted frame is already correct instead of being corrected. Table-of-contents navigation reapplies the content theme rather than assuming the rendition carried it across.
Outcome. The flash and the stale-content cases that came from that ordering were corrected, and the entire fix was a reordering inside one method. That is the return on having given the renderer a single owner.
Selecting text in a document the application cannot reach
The problem. Highlighting needs a menu positioned over the selected text. The selection happens inside the iframe. The menu belongs to the parent application. Coordinates from one are meaningless in the other.
Why it was hard. A selection rectangle is measured against the inner document's own viewport, which is positioned inside a view, inside a scrollable container, inside the page — so the menu has to be placed in a coordinate space the selection knows nothing about, and placed near an edge without falling off screen. Events have the same asymmetry. Clicks inside the iframe never reach the parent document, so the ordinary dismiss-on-outside-click listener never fires when the reader taps the book, which is the single most likely place for them to tap.
The decision. Translate the geometry properly rather than approximate it, and listen where the events actually occur rather than where the menu lives.
Implementation. The service reads selection rectangles from the rendition's contents and translates them through the iframe, view, and container offsets into parent-page coordinates, then clamps the result to the viewport so a highlight against an edge still produces a fully visible menu. Dismissal listeners are registered inside the rendition as well as outside it. The chosen color is written to Dexie against the book and the chapter it came from, so highlights can be listed and navigated by chapter afterwards.
Outcome. Selection and highlighting behave like one application even though they span two documents, and the highlight menu lands on the text in both reading layouts and on touch.
Returning to the sentence, not the chapter
The problem. Reopening a book should land on the exact place reading stopped, and the library has to survive a closed tab, with no server anywhere in the system.
Why it was hard. A page number is not a stable thing in an EPUB. Text reflows with font size, line height, and window width, so a position recorded as an offset into a layout stops meaning anything the moment any of those change. Storage has the same trap in a different form: books are whole files, sometimes large, so ordinary key-value web storage is the wrong instrument for the job. And because all of it is browser-only, the first server-rendered pass has none of it available.
The decision. Persist a position that survives reflow, and treat IndexedDB as the system of record rather than an optimization.
Implementation. Reading position is stored as an EPUB canonical fragment identifier, which points into the document's own structure instead of its current layout, and is written on every relocation event and again when the reader is destroyed, so an abrupt close does not cost the last page. Percentage progress comes from locations generated at import. Dexie holds the file blob alongside metadata, cover, progress, highlights, and settings, with a duplicate-title guard on import. The library view waits for mount before reading any of it, so the server-rendered pass and the hydrated one never disagree.
Outcome. The loop closes: import, read, annotate, close the tab, reopen, and land on the same sentence with the same typography and theme. Because books are stored rather than fetched, the library is offline-capable once a book is in it.
Outcome
Readifyr does the thing it was built to do. A book is imported and stored locally, read with typography and theming under the reader's control, annotated by color and revisited by chapter, and reopened at the position it was left at — without an account, an upload, or a server.
The architectural payoff is in where responsibility sits. One service owns the renderer's lifecycle, one database owns everything that has to survive a closed tab, and one external store carries renderer events into React. The theme-ordering bug is the evidence that the split was right: a fault that presented as a rendering problem turned out to be a sequence in a single method, because there was only one place that sequence existed.
Working against a library that predates React's model was the point of the project rather than an obstacle to it. epubjs does not ask how a component tree is arranged; it renders into a document of its own and tells you about it afterwards. Most of the engineering here is meeting it where it is — owning its lifecycle, translating its coordinates, and persisting what it reports — instead of wrapping it and hoping the two models line up.