How TwitchR is built: an offline-first wildlife recording app

For the technically curious. None of this is needed to use the app — it's here for people who want to know what's under the bonnet, or who want to get their records in and out programmatically.

Offline first, not offline tolerant

The app is a Progressive Web App: plain HTML, CSS and JavaScript ES modules with no build step and no framework. A service worker precaches the app shell so it opens with no connection at all, and every record you make is written to a local IndexedDB database on the device first. The server is a replica of your device, not the other way round.

Synchronisation runs in the background whenever the device is online. Changes go up in batches; changes from elsewhere (another device, a friend's shared records, an import) come down as a delta — the client asks for everything updated since the server timestamp it last saw, and soft-deleted records come back as tombstones so a deletion made while you were offline isn't missed. Every syncable record carries a version, and for your own records the rule is simply last write wins.

A documented REST API

The browser app is just one client of the API — the same JSON endpoints are available to anything else you'd like to connect. The contract was written with desktop synchronisation in mind (Microsoft Access was the original target), so it's deliberately conservative: versioned URLs, a consistent response envelope, cursor-based delta sync and predictable error codes.

Browser sessions
Short-lived JWT access tokens with a remember-me refresh flow
Your own tools
Per-user API keys, created and revoked from Account → API keys inside the app
Delta sync
?updated_since=<ISO 8601> on locations, visits and observations, returning a server-side cursor for the next call
Reference data
Species lists, taxon groups, regions and breeding-status codes are all readable so a client can build its own pick-lists
Bulk data
History exports and imports use plain CSV that round-trips — what you export can be imported back unchanged

Identification

The Identify feature scores the field marks you pick against a local table of species attributes first, entirely on the device. Only when that doesn't produce a confident answer does it fall back to an AI service — occasionally asking one follow-up question — and it tells you which route produced the suggestion.

Maps and species information

Maps are drawn with Leaflet over OpenStreetMap tiles; the Leaflet library is bundled with the app so map pages still load offline (the tiles themselves need a connection). Place names for recorded positions come from OpenStreetMap's Nominatim reverse geocoder, and species photographs and summaries come from Wikipedia and Wikimedia Commons. None of these require an account or key, and your position is only sent to look up a place name when you choose to record it.

The stack

Front end
Vanilla JavaScript ES modules, IndexedDB, service worker, Leaflet 1.9
API
PHP 8.2 with a small hand-written kernel and router — no framework
Database
MariaDB 10.11
Auth
JWT (firebase/php-jwt), Argon2id password hashing, email verification and password reset
Mail
PHPMailer over SMTP — first-ever records of a species are notified to the administrator
Hosting
One Docker container (php:8.2-apache) plus a database container, behind a Caddy reverse proxy that handles TLS
Testing
Front-end tests render real views against seeded IndexedDB data under jsdom; every served module is parsed as ESM before a deploy so a single syntax error can't take the whole app down

Privacy in the design

Locations are personal to each recorder rather than a shared gazetteer, so a favourite patch is nobody else's business unless you share records with a friend. Exact coordinates are optional on every record — you can always just type a place name. There is no analytics, no advertising and no third-party tracking on any page.

Want to build something against the API? Create an account, generate a key under Account → API keys, and email admin@twitchr.app for the full API contract.