A running starting point for a new app: Next.js on the web, Expo on the phone, one typed API between them. Clone it, run one script, sign in, and you have a working full-stack app with multi-tenancy, background jobs, LLM plumbing and file storage already wired.
apps/web Next.js 16 (App Router) — the UI and the API
apps/mobile Expo 57 (expo-router + NativeWind) — iOS/Android via Expo Go
apps/worker BullMQ worker + cron schedules
packages/api tRPC routers — the single API surface, shared by both apps
packages/db Prisma + Postgres
packages/auth better-auth — email+password, Google OAuth, organizations
packages/ai any OpenAI-compatible model, every call priced
packages/jobs job registry, queue, schedules
packages/storage local disk or any S3-compatible bucket
packages/env the validated environment
Needs Node ≥ 22 and Docker.
git clone <this repo> my-app && cd my-app
./scripts/setup.sh # .env + deps + postgres/redis + migrations
make dev # → http://localhost:3200Sign up with any email and an 8+ character password. You get your own workspace, a list you can add to, an upload box, an AI box and a button that hands work to the background worker.
On your phone:
make mobile # Expo dev server, then scan the QR code with Expo GoThe mobile app finds your machine automatically (it derives the LAN address from the Expo dev server), so the phone talks to the same backend as the browser.
make # list every target
make smoke # the gate: production build, booted, driven in a real browser
make test # unit tests
make typecheck # tsc --noEmit everywhere
make stack # run the whole thing in Docker instead (web + worker + db + redis)
make db-migrate # create + apply a migration after editing the Prisma schema
make studio # browse the databasePorts are deliberately non-default so this coexists with your other projects: web 3200 · postgres 5442 · redis 6389 · expo 8091.
Sign-up requires confirming the address, and it works with no third-party
account: docker compose up -d mailpit runs a real SMTP server locally, and
every message lands in its inbox at http://localhost:8035. Sign up, open the
inbox, click the link — you are verified and signed in.
scripts/setup.sh starts it, and the end-to-end suite reads the message out of
Mailpit's API and opens the real link, so the confirmation flow is part of the
merge gate rather than something you hope still works.
The suite uses a second, throwaway catcher (mailpit-test, ports 1036/8036),
never your dev inbox. Its links point at the gate's isolated port and stop working
when the run ends, so letting them pile up in your inbox would make working mail
look broken.
Testing from a phone? localhost:8035 is not reachable from another device — put
the inbox behind a tunnel and point DEV_MAIL_INBOX_URL at it, so the "confirm
your email" screen links somewhere you can actually open.
Two honest limits:
- Mailpit shows mail; it never delivers it onward. It is an inbox for you and
your tests, not for your users. To reach real inboxes, point
SMTP_*at an SMTP relay. (Self-hosting an MTA that strangers' servers accept is a deliverability project — SPF, DKIM, DMARC, reputation — and outbound port 25 is usually blocked anyway.) - Verification links are built from
APP_URL. If that sayslocalhostthe link is unclickable on a phone. Set it to the origin your users actually reach.
With SMTP_HOST unset the message is printed to the server console instead, so a
fresh clone still runs and you can copy the link out of the terminal. Set
REQUIRE_EMAIL_VERIFICATION=false to let unconfirmed accounts sign in — but
that means anyone can register an address they do not own.
Wifi works out of the box. To reach the app from off the LAN — and to get real HTTPS, which Google OAuth requires for any non-localhost redirect — put it behind Tailscale:
tailscale serve --bg --https=8443 http://127.0.0.1:3200
tailscale serve status # confirm you did not overwrite another mappingThat publishes https://<machine>.<tailnet>.ts.net:8443, tailnet-only, with a
valid certificate.
Two things must then be true:
- Pick a port nothing else is serving.
--https=443is the obvious choice and also the one most likely already taken by another project on the same machine;tailscale serve statusbefore and after is the check. - Add the origin to
TRUSTED_ORIGINSin.env(comma-separated), both the HTTPS name and the rawhttp://<tailscale-ip>:3200the phone uses. Without it every sign-in fails with "Invalid origin" — that check is a CSRF boundary, so add the origin rather than relaxing it. Recreate the containers afterwards (docker compose --profile full up -d --force-recreate web worker);env_fileis read at container creation.
For the phone, start Expo advertising the Tailscale address and leave
EXPO_PUBLIC_API_URL empty — the app derives its API origin from whichever host
served the bundle, so the same build follows you between wifi and Tailscale:
REACT_NATIVE_PACKAGER_HOSTNAME=<tailscale-ip> npm run dev:mobiletailscale serve is tailnet-only. tailscale funnel would expose it to the
public internet — do not reach for that to test a phone.
Optional — email/password works with no external setup. To enable it, create an
OAuth client at
console.cloud.google.com
(type: Web application), add these redirect URIs, and put the two values in
.env:
http://localhost:3200/api/auth/callback/google
<your public https origin>/api/auth/callback/google
The mobile app reuses the same client through the apptemplate:// deep link, so
there is nothing extra to configure for the phone.
- Multi-tenancy from row one. Every user gets an organization on sign-up;
every org-scoped query runs through
orgProcedure, which supplies the tenant. An end-to-end test asserts one tenant cannot see another's rows. - Auth that works on both surfaces. One better-auth server; the web client uses cookies, the Expo client keeps its session in the OS keychain.
- Background work. Redis-backed queue, cron schedules, and a durable
job_runrecord — so a job that silently vanished is still visible. - LLM calls that are auditable. Provider-agnostic (OpenAI, Azure, DeepSeek,
OpenRouter, Groq, Ollama — it is a URL), with tokens, latency and USD cost
written to
llm_callper tenant and per feature, plus a daily spend ceiling. - File uploads. Content-type allow-list, size cap, tenant-namespaced keys, authenticated download. Local disk by default; S3/R2/MinIO by changing one variable.
- A real gate.
make smokebuilds for production, boots the standalone server against real Postgres and Redis, and drives the actual user journey through Chromium. CI runs the same thing plus the Docker builds.
- Rename:
expo.scheme+APP_SCHEME,expo.name/slugand the bundle identifiers inapps/mobile/app.json, the<title>inapps/web/src/app/layout.tsx. - Replace the demo
Itemmodel with your domain. Keep itsorganizationIdcolumn and index shape;packages/api/src/routers/items.tsis the reference. - Delete what you do not need —
packages/ai,packages/storageandpackages/jobsare independent. - Keep
make smokedriving your core journey.
Agents working in this repo should read CLAUDE.md — it is short
and it lists the traps.