← Blog

Launching Words Per Day, and the local-first rabbit hole

astro typescript privacy local-first launch wordsperday

I have launched Words Per Day, a small app for building a daily writing habit. You set a number of words, show up, and watch a line fill toward the goal. That is the whole thing: no accounts, no cloud, no distraction.

It is also the most stubborn small project I have built in a while, and not for the reason I expected. The writing surface was the easy part. The hard part was a question that sounds trivial and is not: if you promise that someone’s words never leave their browser, how do you stop the browser from throwing them away?

This post is both the launch note and the write-up of that rabbit hole.

The idea, and the constraint that shaped it

One job, done calmly. The constraint I set was privacy taken literally: the app collects nothing, because there is nowhere for anything to go. No database, no API for the app itself, no analytics, no third-party requests, no fonts phoning home. Everything you write lives in your own browser and nowhere else.

That constraint is the whole personality of the thing. It is also, as I will get to, its biggest liability.

The stack

  • Astro, static output. Every page is prerendered to HTML; no server runtime.
  • TypeScript for the client logic, with no UI framework. The interactivity is a handful of small, hand-written modules, not React.
  • Hand-written CSS with custom properties for the whole design system: colour, type and spacing tokens, light and dark themes, no utility framework.
  • Self-hosted fonts (subset woff2), so no visitor request ever leaves the origin.
  • GitHub Pages for hosting, built by GitHub Actions, with Cloudflare in front for DNS and TLS.

The writing surface

A few things took most of the care:

  • A typewriter caret. The line you are on stays pinned at the vertical centre of the screen; finished lines rise above it as you type. It is a textarea underneath, so the caret is real and native, with the page scrolled to hold it in place on each keystroke. Getting that to feel planted rather than jittery took more attempts than I would like to admit.
  • Live Markdown, without a rich-text editor. Headings, bold, italic, lists and quotes render as you type, but the editor is still a plain textarea. The styling is painted onto an overlay sitting exactly behind the transparent text, sharing identical type metrics so the caret never drifts. The trick is that the styling only ever changes colour, weight and slant, never size, so every character keeps its position. Print, by contrast, typesets the Markdown properly with real heading sizes.
  • A focus mode that dims everything but the current sentence or paragraph, drawn with the same overlay.
  • Autosave debounced with a visible state, plus a flush on beforeunload.
  • Paper textures, procedural ones. A subtle grain behind the page, generated from SVG fractal noise in the browser rather than downloaded image files, so nothing is fetched from a texture shop and there is no licence to worry about.
  • Keyboard navigable, and it honours prefers-reduced-motion.

The one deliberate backend

There are two small network features, both optional, and both handled by a single Cloudflare Worker backed by one Durable Object:

  • A solidarity panel on the support page that shows how many people are writing right now and a daily count of who showed up. It stores only counts and short-lived random session ids. No accounts, no personal data, no IP logging, no cookies.
  • A supporter wall. Ko-fi can fire a webhook on each donation, and the app turns that into a patron or a thank-you on the page. But nothing auto-publishes. The webhook stores the entry as pending, and it only appears after I approve it on a small secret admin page. That keeps the wall free of spam by design. The approved entries are then served from the Worker and merged into the page at runtime, so there is no rebuild and no GitHub token anywhere.

A note for anyone doing the same: Durable Objects on Cloudflare’s free plan must use the SQLite storage backend, so the migration declares the class with new_sqlite_classes rather than new_classes. The key-value storage API is identical either way.

The rabbit hole: keeping local data alive

Here is the problem I did not see coming. “Your words stay in your browser” sounds safe. It is not, by default. Browser localStorage is the most evictable storage there is, and the rules differ everywhere:

  • Safari clears a site’s local data after about seven days without a visit. For a daily-writing habit, that is precisely the failure that destroys trust: come back after a holiday and your streak, and your words, are gone.
  • Chrome and Firefox are kinder, but will still evict under storage pressure unless you ask them not to.

So the app does several things, in layers, none of which is a real server:

  1. It calls navigator.storage.persist() to ask the browser to mark its storage as persistent. Chrome and Firefox honour this. Safari does not, for the seven-day rule.
  2. It ships as an installable app (a web manifest, icons, Apple meta tags, and a service worker). Adding it to the iOS Home Screen exempts it from Safari’s seven-day wipe, which is the single most effective fix on Apple devices. The start screen explains this, so the “why” is not a mystery.
  3. The service worker is deliberately conservative. It is network-first for pages, so a deploy is picked up immediately and nobody is ever stranded on a stale version; it only caches immutable, content-hashed assets aggressively; and it never touches localStorage. An update cannot eat your writing, because the thing doing the updating and the thing holding your words are separate stores.
  4. There is a proper backup and restore: download everything as one JSON file, and restore it on any browser or device (which is also how you move your writing into the installed app). A calm, dismissible reminder nudges you to back up once enough new writing has piled up. On a phone, a “Share backup” button uses the Web Share API to hand that file to the OS share sheet, so you can email it to yourself, AirDrop it, or drop it in Notes. The device does the sending, so the words still never touch a server I control.

The pleasant side effect of all this is that the app works fully offline. Once you have opened it once, the service worker has the page cached, and your writing lives in localStorage, so reading, writing, autosave, export and print all work with no connection at all. Only the optional solidarity counter needs the network, and the app is fine without it.

That is the honest shape of local-first without a server: not one clever trick, but a stack of mitigations plus a frank explanation of the trade-off. Privacy and durability genuinely pull against each other, and I would rather be honest about that than pretend a tick box has solved it.

A lesson from page transitions

One last note, because it cost me a bug. The site uses Astro’s view transitions for a gentle cross-fade between pages. That swaps the document without a full reload, and it broke two things I had taken for granted:

  • Client-side state set by script. The theme, font and paper are applied to <html> by a small script on first load. On a transition the new document does not re-run that script, so the saved theme silently reverted to the OS default until I re-applied it on Astro’s astro:after-swap event.
  • Event listeners. Buttons bound once at module load go dead after a swap, because their elements were replaced. Every interactive component now re-attaches its listeners on astro:page-load, guarded so it never double-binds.

If you add view transitions to an app with any client-side state or behaviour, budget for those two.

Is it any good? An honest self-review

The category is crowded. Minimal writing apps are littered across the web and have become many a weekend project. The privacy stance is a real difference in ethos, but to a lot of people “no sync” reads as a missing feature, not a principle.

So I am not selling this as the next anything. It is a small, careful tool that does one thing without watching you, kept free and supported by people that find it useful.

If you write, give it a try. It is free. If you build local-first things, I hope the durability notes above save you the time I spent finding them.