Eli Zibin

Building Hapax: An Offline-First Dictionary App (iOS for now)

24/02/2026

ios react-native offline-first sqlite supabase

A casual walkthrough of Hapax: an offline-first personal dictionary app.

Hero image for Building Hapax: An Offline-First Dictionary App (iOS for now)

Hapax is a small dictionary app I built for myself. I try to write down/look up every word I come across that I don't know. I've kept a word list for years as a basic csv, but I've always wanted to experiment with it in an app format.

It's built as local-first data with an optional sign-in, practical sync features, and lightweight AI helpers to fill in details and enrich entries.

The app is designed to save first and organize later via entry or AI enrichment. This post is a straightforward tour of how it works.

Quick look at browsing entries in the real app.
Same flow in dark mode.

Why Hapax exists

I wanted a quick capture flow that does not depend on a network and does not force me to fully edit every entry in the moment.

So the core idea is simple: add the word now, enrich it later.

Capture in a few seconds.

Product constraints that shaped architecture

Hapax is iOS-first today. Android and Expo web are possible, but not a real priority yet.

The main constraints were straightforward: offline support, fast local interactions, and optional account usage instead of hard auth gating.

Diagram mapping product constraints to architecture decisions in Hapax.
Constraints drove most of the technical choices.

System map in one screen

UI writes to local SQLite first. Sync reconciles with Supabase when available. AI helpers run through edge functions and feed back into normal data flow.

System diagram of Hapax showing UI, local SQLite, sync engine, Supabase, edge functions, and export.
Local-first core with optional remote services.

SQLite as the source of truth

All core screens read from SQLite. That keeps search and list views fast and keeps the app usable when offline.

The main local tables are entries, tags, entry_tags, and sync_state. Sync metadata sits with rows so pending work is easy to track.

Local SQLite data model diagram for entries, tags, entry_tags, and sync_state.
Core local schema.

Auth is not access mode

A useful design choice was separating sign-in state from app access.

TS

type AccessMode = "supabase" | "local" | "none";
Access modes used by the app.

This means someone can use local mode immediately, then sign in later if they want sync across devices.

State diagram for access modes supabase, local, and none.
Session and app access are related but separate.

Local mode migration on sign-in

Local mode rows are stored with user_id as null. On sign-in, those rows are reassigned to the signed-in user.

Tag collisions are merged by name, migrated rows are marked pending, and the process is safe to retry.

Flow diagram showing local mode data migration from null user_id to signed-in user_id.
How local data becomes syncable account data.

Sync engine deep dive

Sync runs one job at a time per user. That avoids overlapping transactions and keeps behavior predictable.

The flow is push local pending changes first, then pull remote updates, then update cursors. If something fails, retries use backoff.

Sequence diagram showing push-then-pull sync flow between SQLite and Supabase.
Push first, then reconcile.

Conflict policy and tradeoffs

Conflict handling is intentionally simple: newer updated_at wins.

It is not perfect, but it is easy to understand and keeps sync logic small enough to maintain.

Timeline diagram showing last-write-wins conflict resolution using updated_at.
Simple conflict rule with clear tradeoffs.

Query layer and cache hygiene

TanStack Query handles cached reads. MMKV persists cache between launches.

When access mode or user changes, cache hygiene clears old context and invalidates key queries.

Diagram of query cache persistence and cache invalidation during auth and access changes.
Warm cache when stable, reset when identity changes.

Entry lifecycle end-to-end

Create, edit, tag updates, favorite toggles, and soft delete all start as local writes.

Rows are marked pending and synced later, so UI actions stay fast without waiting on network calls.

Words list and favorites behavior in the app.
Tag flows in practice.
Flow diagram for entry lifecycle from create to edit, tagging, favorite, and soft delete.
Local-first mutation flow.

AI enrichment flow

Enrichment is optional. It can fill fields like definition, etymology, notes, and tags. I've been really happy with how this is working so far!

The app uses a sync-safe path: create local entry, sync if needed, call edge function, pull updates, refresh queries.

Flow diagram for AI enrichment from local entry to edge function and sync pull back.
AI as assist, not source of truth.

AI suggestions flow

Suggestions are user-triggered and cached server-side.

A hash of entry content and tags is used to decide whether cached suggestions can be reused or regenerated.

Diagram showing AI suggestions cache hit and cache miss paths based on input hash.
On-demand suggestions with cache reuse.

Export and portability

Exports are local and simple: JSON or CSV from local data. This way I can always go back to my original data and workflow if needed.

Web uses file download, native uses the share sheet. No server export pipeline is required.

Settings and export entry points.
Export flow diagram for JSON and CSV output on web and native.
Straightforward local export path.

Closing thoughts

Simple summary diagram of local-first data, sync, and user-controlled features.
Small app, clear priorities.

Hapax is intentionally simple. The main goal is still to make word capture easy and reliable, with optional sync and optional AI on top. It's available on TestFlight, but probably won't make it to the App Store as it's sort of just for me.

Thanks for reading!