Lystz
Shared lists for a household. Groceries, chores, anything you’d otherwise text each other about, updating live for everyone in the circle. Invite‑only beta.
lystz.live

screenshot taken 2026-08-30 · open lystz.live
at a glance
- what
- Shared lists for a household: groceries, tasks, or anything generic
- stack
- TanStack Start (React 19, SSR) · better‑auth · Prisma 7 on Postgres 16 · Tailwind v4 · shadcn/ui
- auth
- Passkeys first, email + password fallback; invite‑only, no open sign‑up
- offline
- Hand‑written service worker: cached read‑only viewing, partitioned per user
- tests
- Vitest for authorization rules, Playwright end‑to‑end, a Docker smoke boot in CI
- runs as
- One Node image + a one‑shot migrate container + Postgres, on Coolify
What it does
A household is the unit of everything. People join one by invite; the household owns lists; lists hold items. Every list has a kind, grocery, task or generic, which decides its colour and what its items can carry, and can be split into sections (aisles for a grocery run, phases for a project) that you reorder with up and down rather than dragging. Check something off and everyone else in the household sees it happen without reloading.
Items can carry structured fields. A household keeps its own field templates, ordered definitions of text, number, checkbox, date or select fields, and a list can attach one so each item gets those fields with validation built from the template at request time. Every new household starts with a few default templates that become ordinary editable rows.
Sign‑in is passkeys first, with email and password behind it. It installs to a phone’s home screen as a web app and keeps working offline for reading: the last lists you looked at are cached, writes are not attempted, and the cache is partitioned by user so a shared device never shows one person’s lists to another.
There is a waitlist on the front page. It’s a beta; a household admin lets you in.
How it’s built
Household‑first ownership is the whole security model. Every list and item resolves to
the household that owns it, and every server function checks membership of that
household before touching data. Who created a list is recorded for attribution only; it
is never an authorization path. A request for an id that doesn’t exist gets a 404 rather
than a 403, so probing for ids learns nothing, and ids are cuid() strings rather than
sequential integers for the same reason.
There is no GraphQL layer and no REST layer to keep in step with the UI. Data access is TanStack Start server functions, one file per model, each with three things in a fixed order: a session middleware that resolves the user, a Zod validator shared between client and server, and the membership check. The route‑level “are you signed in” guard only redirects browsers; the server functions re‑check everything because they are reachable directly.
Routing is file‑based, with a pathless layout that makes everything under it require a
session. Prisma 7 generates its client as TypeScript into the source tree rather than into
node_modules, which changed three habits at once (import path, connection via a driver
adapter, a config file instead of .env magic) and is the kind of thing that only bites in
the Docker build stage.
The UI is shadcn/ui vendored into the repo as source, on Tailwind v4 configured in one CSS file with Lystz’s own semantic tokens on top: list‑kind colours, an urgent red, a warning amber for “degraded but not broken” like the offline banner. Both themes are defined and the installed home‑screen icon follows the in‑app theme, which needed two manifests because the manifest spec has no media query for icons.
Data
Auth tables (user, session, account, verification, passkey) are generated by better‑auth’s CLI and live in the same Prisma schema as the app’s own models: household, membership, list, list section, list item, field template, field definition. A list’s kind‑specific extras go in a JSON column rather than new tables, which is why there is no separate “store” table: grocery concepts fold into the list’s location and metadata.
Deploy and operations
One Docker image with two published tags: the runtime stage, and the dependency stage that
still has the Prisma CLI so a one‑shot migrate container can run migrations before the app
starts. Both tags come from the same commit so they can never drift apart. Coolify runs the
compose file; Traefik terminates TLS; Postgres is never exposed to the host. Backups are
pg_dump wrappers.
Rate limiting is two layers: better‑auth’s own, plus an IP‑keyed in‑memory limiter on the sign‑in and password‑reset routes. In‑memory is fine for one instance, which is what this is.
What it taught me
Two of the three write‑ups under broke came from this app: the postgres service name that
resolved to another project’s database on a shared Docker network, and the mutable latest
tag that a cached host never re‑pulled. The third lesson isn’t a write‑up yet: a one‑shot
migration container that Coolify restarted forever, because it injects restart: unless-stopped onto any service that doesn’t set its own.
The mascot is Lyz, a clipboard with two magnet eyes. It blinks, and that is the only thing it is allowed to do in the app chrome.