# Building good apps here (https://lazyfrog.dev/docs/guide/building-good-apps-here)

{/* Generated by scripts/generate-docs.ts from services/gateway/src/guide.md. Do not edit. */}

- **Offline and installable apps (PWA)**, e.g. a technician's phone app: a service worker and a
  queue in IndexedDB with an id per change (so a resend does nothing twice). Cache only `ok`
  responses: while the site wakes, the platform answers 503 with `no-store`, and a worker that
  caches it saves the waking page as the app. Leave `/_lf/*` (uploads, forms) to the network.
  `/sw.js` is served with `max-age=0`, so updates arrive. The preview runs the dev server, which can't
  work offline: test offline on the live site. A request to a sleeping site waits while it wakes
  (up to about 25 s) and then succeeds, so a queue drains after a wake without special handling.
- **API products** (a paid API, a dashboard beside it): one project, a web service and an api
  service, with routing `rewrites` sending `/api/:path*` to the api; FastAPI behind that rewrite
  needs `root_path="/api"` (or keep the prefix) for `/docs`. CORS in routing (`expose` the
  rate-limit headers, `max_age`); preflights are answered without waking the site. When the
  platform itself answers (asleep, restarting, a preview password, too many waking at once), API
  clients (no `text/html` in Accept) get JSON `{"error": {code, message, request_id, from:
  "lazyfrog"}}` with the status and `Retry-After`. There's no platform rate limit on a site's own
  traffic: count per key in Postgres (a sliding window, not a fixed minute). Outbound calls are
  limited per destination (pool connections and reuse them); private addresses are refused. Webhooks wake a sleeping site (they are visitors); cron jobs and the app's own retries
  don't run while it sleeps, so an app that needs them on time (a sale opening, expiring holds) is
  always on, at least for those days. A
  custom domain is the whole site's: `api.` and `app.` as two entry hostnames of one project
  both reach the same app, which routes by `Host` (Custom domains). Integration tests against a private preview send
  the preview password as Basic auth (any username). `service run` runs in the preview only.
  Rolling the database back also undoes customers' sign-ups, keys and usage since then: prefer a
  forward fix. Bandwidth counts toward the plan's month; live sites pause when it reaches the
  cap (Hobby after 48 hours' grace), not at the month's end, so watch `usage_get`: from 80% it
  has the owner's way to more (a paid plan's one-tap 100 GB boost with its price, until the
  period ends; an upgrade from Free). You can't pay for them.
- Accessible by default: labelled fields, `fieldset` and `legend` for groups of choices, 44 px tap
  targets, visible focus, never colour alone for right and wrong, a timer announced at a few
  moments rather than every second, and a readable font served from the project (Atkinson
  Hyperlegible reads well for dyslexic readers).
- Quizzes and tests: the answer key and the time limit stay on the server, one attempt per person
  is a unique constraint, and the answers show once everyone has finished.
- Menus and food: allergens and diets in words, not colour alone ("V Vegetarian", "Contains nuts"),
  a line asking guests to tell staff about allergies ("we can't guarantee no traces"), and never
  "nut-free". On the owner's form, allergen questions are a yes or no that must be answered, not a
  box that starts unticked.
- Keep the health check route cheap and unauthenticated (`/api/health`, `/health`).
- Web apps: server-render or build static assets; keep dependencies few and pinned (the lockfile is
  committed). Add a package at its current release (`pnpm add better-auth`, `uv add httpx`: no
  version), never a version you remember, which is often old and has known holes (2026-11-13: a
  build pinned a sign-in library a year out of date with 15 advisories); the lockfile then pins
  it, and `project_scan` checks it. New npm and Python package versions must be at least a week old (pnpm and uv
  enforce it); for other ecosystems pick releases that have been out a week or more too.
- Go: put build tools (sqlc, goose) in go.mod as `go tool` dependencies (Go 1.24+) rather than
  `go install`, so they are pinned with the project. Builds reuse the preview's cache only with
  the same flags: the first build with new flags (`-trimpath`, `-ldflags`) recompiles everything,
  later ones reuse the cache.
- Background work: a queue in the project's PostgreSQL needs nothing else to run or back up
  (pg-boss or Graphile Worker on Node, Procrastinate on Python, Solid Queue or GoodJob on Rails, whose
  Rails 8 default expects separate cache, queue and cable databases: use the one `DATABASE_URL`
  (drop `config.solid_queue.connects_to` and the cache and cable `database:` keys) and copy
  `db/queue_schema.rb`, `db/cache_schema.rb` and `db/cable_schema.rb` into a migration, or the
  tables are missing,
  Oban on Elixir, River on Go). PostgreSQL is the project's database, kept in every snapshot and
  publish; another engine (Redis, MySQL, MongoDB, SQLite) can run as a service in the workspace with its
  data under `/data`, or as a hosted one. Before each snapshot LazyFrog makes a clean copy of every
  SQLite database (`/data/.lazyfrog/clean/`) and runs a service's `backup` command (a
  `mariadb-dump` or `mongodump` into that folder), so a restore has a clean copy beside the files
  as a crash would leave them; publishing carries code, not their data, so content an app keeps
  there (a CMS's posts) is written on the live site, not carried over from the preview (the
  first publish can copy it once, if the user asks: see "Publishing"); a `migrate` command runs
  on a copy of the live data first, then on the live data after a snapshot.
- Store uploaded files under `/data` (through the S3 API for visitors' uploads).
- Cookies: scope them to the site's own host (or use the `__Host-` prefix).
