Approach

Postgres holds the model and access rules. Clients show honest loading, errors, and sync state. Local queues only when dropping offline would lose real work.

How I build

  1. 01

    Rules in the database

    Postgres enforces who may read and write; the UI does not guess policy.

  2. 02

    Resilient clients

    Retries and clear failures by default; device queue when offline would lose work.

  3. 03

    Clear errors

    Failures surface in the product and in telemetry so nobody is left guessing.

Work examples

The same ideas show up in different products. Each write-up explains the choices.

All work →

Rules I follow

  1. Access rules belong in the database

    RLS and storage rules keep tenant data apart. APIs stay thin and easier to check.

  2. Offline-first is a product choice, not a default

    When the use case needs it, work is saved locally first and an outbox sends it later. Many apps stay online-first with good cache boundaries and visible errors instead.

  3. Realtime is scoped and optional

    Realtime updates are scoped per tenant. The app only listens to what it needs.

  4. Simple infrastructure beats clever hosting

    Managed Postgres, auth, and storage reduce maintenance work. Less ops load is a feature.

  5. When something fails, show it in the product

    Sync state and error states belong in the product, not only in logs. Users should know what failed.

Patterns I use

Small system patterns I reuse when the product needs them.

Click a card to expand the diagram.

Auth & RLS

JWT claims carry org context. Postgres decides which rows are visible. Storage follows the same rule.

Sync & offline

When disconnect-tolerant work is required, server data, local state, IndexedDB, and an outbox layer so changes can sync later. Otherwise the client stays thinner and leans on the server with clear loading and error states.

Realtime to many clients

Changes go to the right clients. Tenant-scoped channels keep noise and cost down.

Async work & side effects

Webhooks, cron, and DB-triggered jobs run outside the user request. Retries are safe when calls fail.

Client cache & server entry

TanStack Query handles server data. Zustand handles UI state. Reads and writes still go through Supabase with the same auth context.

Logs & errors

Useful errors show in the UI. Client and server errors go to Sentry. Logs and Postgres metrics help trace what happened.

Stack by category

The tools grouped by what they are responsible for.

Language
  • TypeScript

TypeScript across the app, so data shapes and contracts are clear while building.

UI layer
  • React
  • shadcn/ui
  • Tailwind CSS

React, shadcn/ui, and Tailwind for small UI pieces that are easy to change.

App shell
  • Next.js
  • TanStack
  • Vite

Next.js for most apps. Vite and TanStack Router when a focused SPA is a better fit.

Data
  • PostgreSQL
  • Supabase

PostgreSQL and Supabase for data, auth, storage rules, and tenant boundaries.

Client data
  • TanStack
  • PWA

TanStack Query for server data. Zustand for UI state. IndexedDB and an outbox when work must sync later.

Packages & CI
  • pnpm
  • Turborepo

pnpm by default. Turborepo when multiple apps need to share code.

Ship & observe
  • Vercel
  • Sentry

Vercel to ship. Sentry, logs, and Postgres metrics to see what broke.