a private, local first journal, and the thinking behind it
Daybook started from a fairly simple frustration, most journaling apps ask you to sign up before you're allowed to write a single sentence about your own day, and once you do sign up your private thoughts are sitting on someone else's server whether you like it or not. I wanted something that felt closer to an actual paper diary, you open it, you write, it stays where you left it, and nobody but you ever sees it. That single idea shaped almost every technical decision in this project, more than any framework or library choice did.
The working name for a long time was just "the journal thing" before it became Daybook. The tagline I kept coming back to while building it was write privately, store locally, own your thoughts, and I tried to let the architecture actually earn that line instead of just printing it on a landing page.
At its core Daybook is a single page journaling application, you write an entry for a given day, you can attach a photo or two, you can tag a mood and a rough sense of the weather, and the app remembers all of it the next time you open it, even after closing the tab or restarting the browser. There's a calendar view to browse past days, a simple search to find an old entry by a word or phrase, and an export feature that turns your whole journal into a PDF you can keep somewhere else if you want a backup outside the browser.
What make this project worth documenting properly isn't the feature list though, it's that there is no backend at all. No server, no database sitting in the cloud, no account system authenticating you against anything. Everything the app needs to function lives inside the browser that is currently open, and once the page has loaded for the first time it doesn't need the network again to keep working.
The whole system can be described as one flow rather than a diagram full of disconnected boxes. A user's browser requests the app once, that request goes to Vercel, which serves a static Next.js bundle back to the browser, and that is the very last time the network gets involved in any meaningful way. From that point everything lives inside the browser tab. The presentation layer, built with Next.js and React, renders the UI, styled with Tailwind and a set of Radix primitives wired together with class variance authority, which is the same pattern the shadcn component library popularized, unstyled accessible components dressed up with utility classes rather than a heavier component framework. Framer Motion handles the small transitions that make the interface feel less static than plain HTML would.
Underneath the presentation layer sits what I've been calling the service layer, a single file, db.ts, that exposes a set of typed async functions like saveEntry, getAllEntries, addPhoto and so on. React components never talk to the browser's storage APIs directly, they call these functions, and the functions decide what actually happens underneath. This mattered a lot for me while building it, it means the storage engine could theoretically be swapped later without touching a single component.
That service layer sits on top of two separate storage engines, and this is probably the part of the architecture I'd most want a reader to actually understand rather than skim past. Small pieces of state, the ones that are simple key value pairs like a theme preference or the last date the app was opened, live in localStorage, which is synchronous and string only but perfectly fine for tiny bits of data. Journal entries and photos live somewhere else entirely, in IndexedDB, accessed through the idb library which wraps the native, fairly clunky callback based IndexedDB API in something that reads like normal async JavaScript. IndexedDB was the only real option here once photos entered the picture, localStorage caps out around five to ten megabytes total and only stores strings, which would mean either overflowing that limit almost immediately or base64 encoding every photo and eating a third more space than needed for no good reason.
There are two object stores inside the IndexedDB database, one called entries and one called photos. Every entry is keyed by an internally generated id, and there's a unique index built on the date field, so the database itself enforces that a given calendar day can only have one primary entry, the saveEntry function checks that index before deciding whether it's creating something new or merging into what's already there. Photos are keyed by their own id too, but carry an entryId field with a non unique index on top of it, which is really just a foreign key relationship done the way IndexedDB expects you to do it, since there's no such thing as a join here. One entry, many photos, tied together by that index rather than by any kind of relational constraint.
Photos themselves are stored as base64 data URLs directly inside the database record rather than as raw Blob objects, which was a deliberate simplification, it keeps the data self contained inside a single IndexedDB value without needing to manage separate blob references, at the cost of the base64 overhead on storage size. For a personal journal with a modest number of photos this tradeoff felt fine, it might not for an app expecting thousands of high resolution images.
Search across entries is intentionally simple, when a user searches, all entries get pulled out of the database into memory and filtered with a plain string match across the title, content, location and mood fields. There's no dedicated search index sitting behind it. This is a conscious tradeoff rather than an oversight, a personal journal is going to hold hundreds of entries at most, not millions, and a linear scan over that amount of data is imperceptible to a user. If this app ever needed to scale past that, the honest next step would be a manually maintained word index as its own object store, or pulling in something like FlexSearch to run over the loaded entries in memory.
There is no state management library anywhere in this codebase, no Redux, no Zustand, nothing like that. This wasn't an oversight either, the data model here is genuinely simple enough that React's own hooks cover it without needing a global store layered on top, and I'd rather have less machinery than more machinery I don't actually need.
There is also a UserProfile type defined in the codebase with a name, an email, and an id, and unlike what I first assumed while writing an earlier draft of this document, it isn't dead code, it backs a real, if deliberately small, auth layer.
Auth in Daybook lives in its own file, lib/auth.tsx, and it is intentionally a lightweight, client-side identity layer rather than anything resembling a real backend. It's built as a useAuth() hook that exposes user, login, signUp, logout, and resetPassword, and everything behind that hook is stored in localStorage, not IndexedDB. That separation was deliberate, journal content and identity are two very different kinds of data with two very different lifecycles, and keeping them in different files and different storage entirely means one can be reasoned about without dragging the other in. The auth layer's only real job is to gate the /journal route so the workspace isn't wide open, and to put a name or a set of initials somewhere in the UI so it doesn't feel anonymous.
I want to be honest that this is not production authentication, and it isn't meant to be. Before this app is ever actually launched for real people to depend on, that lib/auth.tsx implementation needs to be swapped for something real, NextAuth.js, Clerk, or Supabase Auth would all be reasonable choices. The useAuth() hook was shaped the way it was specifically so that swap is possible without touching journal code at all, login, signUp, logout and resetPassword are the whole surface area any replacement needs to satisfy.
One consequence of accounts being local to a single browser right now is worth stating plainly rather than letting someone discover it the hard way, logging in on a new device will not show past entries. This isn't a bug so much as it is the local first storage model being consistent with itself, and the app says as much to the user directly rather than leaving them confused. The way around it today is the Export and Import flow under Settings, which lets someone move a whole journal from one device to another manually.
The app is organized around a small, fairly flat set of routes.
/ marketing landing page, a static preview, not the real workspace
/signup account creation
/login sign in
/login/reset password reset flow
/journal the actual workspace, two pages side by side on desktop, one page on mobile
/journal/settings profile, privacy, storage, export and import, delete all, logout
Because there's no server anywhere in the picture, there's no database to breach and no account credentials to leak, the privacy claim on the landing page isn't really a marketing decision so much as a direct consequence of how the app is built. That is the tradeoff I was most willing to make going in.
The other side of that same coin is that this data lives in exactly one browser, on exactly one device, and it doesn't sync anywhere by default. Clear your browser's site data, or open the app in a different browser, and your entries simply aren't there. The PDF export feature exists partly as an answer to that, a way to pull your journal out into something portable that isn't tied to any particular browser's storage, but it's a manual step, not automatic syncing, and I think that's an honest limitation to state plainly rather than gloss over.
A few directions feel natural from here, though none of them are committed to yet. The most immediate one is replacing lib/auth.tsx with a real provider before this ever goes anywhere near a real launch, NextAuth.js, Clerk and Supabase Auth are all on the table and the useAuth() hook is already shaped so that swap shouldn't touch the journal code at all. Past that, a slightly smarter search, even something modest like the FlexSearch approach mentioned earlier, and probably storing photos as actual Blobs rather than base64 strings if the photo count per entry grows, since that would meaningfully cut down on stored size.
For now though, the version that exists does what it set out to do, it is a quiet, private place to write down a day, and it doesn't ask anything of you to let you do that.


