Next.js + Swift Unified Setup
Next.js + Swift running as one product — shared users, todos, and subscription — in about 30 minutes.
This example takes Next.js + Swift from two fresh checkouts to a working Unified deployment: one Better Auth user base, cross-platform todos and profile, and one subscription row that a purchase on either platform unlocks.
Work through the steps in order — each one is a dependency of the next. The core loop (steps 1–5) is about 30 minutes; payments and push (steps 6–7) add more only because they involve third-party dashboards.
Before you start
- Both templates checked out (
soar-next,soar-swift), each building on its own — see Next Installation and the Swift docs. - A PostgreSQL database you can point at (local or hosted). Use a dedicated database — don't share one with another project.
- Xcode 16+, Node 20+, pnpm.
Database first
In soar-next, set DATABASE_URL in .env, then push the schema:
pnpm exec drizzle-kit pushOne push creates everything Unified Mode needs — the Better Auth tables, the
todos and device_tokens tables, and the subscription table. There is no
separate Better Auth migration step. For production, prefer generated
migrations over push — see Database.
Core server environment
Still in soar-next/.env:
| Variable | Value |
|---|---|
BETTER_AUTH_SECRET | A long random string (openssl rand -base64 32) |
BETTER_AUTH_URL | The server's own URL — http://localhost:3000 locally |
APP_SCHEME | soar:// — the iOS app sends this as its Origin on every request |
RESEND_API_KEY | Needed for the email OTP codes native sign-up/reset relies on |
APP_SCHEME is the handshake between the two apps: the Swift client hardcodes
Origin: soar://, and Better Auth rejects unregistered origins with 403.
Start pnpm dev and confirm the web app signs up and in.
OAuth clients (one Google project, one Apple ID)
Order matters here — create everything inside one Google Cloud project so all clients share a consent screen:
- Google web client →
GOOGLE_CLIENT_ID+GOOGLE_CLIENT_SECRET(server.env) — powers the web redirect flow. - Google iOS client (your bundle ID) →
GOOGLE_IOS_CLIENT_IDon the server (audience allow-list) and in the SwiftSecrets.xcconfigtogether withGOOGLE_REVERSED_CLIENT_ID(the SDK's callback scheme). - Apple: set
APPLE_APP_BUNDLE_IDENTIFIER(server.env) to the app's bundle ID and enable the Sign in with Apple capability in Xcode. No Services ID, no Apple OAuth secret.
Details and verification rules are in Native Authentication.
Point the Swift app at the API
In soar-swift:
cp Secrets.example.xcconfig Secrets.xcconfigSOAR_BACKEND_MODE = soar_api
SOAR_API_BASE_URL = http:/$()/localhost:3000(The $() splits // so xcconfig doesn't read it as a comment; use your
machine's address instead of localhost when running on a physical device.)
Leave the SUPABASE_* keys untouched — Unified Mode never builds the Supabase
client. See Backend Modes for what this switch
does.
First cross-platform smoke test
- Sign up in the iOS simulator (email + OTP code, or Google).
- Sign in to the web app with the same account.
- Create a todo on the web → pull to refresh on iOS → it's there.
- Edit it on iOS → reload the web → it changed.
- Update the profile on either side and confirm the other reads it.
If sign-in fails with 403, check APP_SCHEME; if API calls fail with a
configuration error, check SOAR_API_BASE_URL. You now have the identity and
data loop working — everything below adds money and push.
Payments: Creem/Stripe (web) + RevenueCat (iOS)
Configure the web side first, then RevenueCat, then verify they meet in the
same subscription row:
- Web checkout: Creem (or Stripe) API key, product IDs, and webhook
secret per Payments. Forward webhooks locally
(
ngrok/cloudflared) so purchases actually land. - RevenueCat products: create the App Store products and set the three
explicit mappings —
REVENUECAT_PRODUCT_PRO_MONTHLY,_PRO_YEARLY,_LIFETIME. Product names are never guessed. - RevenueCat webhook: point it at
https://<host>/api/payment/notify/revenuecatwith an Authorization header exactly equal toREVENUECAT_WEBHOOK_AUTHORIZATION. - Restore Behavior: in the RevenueCat project settings, keep Transfer
disabled. The server deliberately ignores
TRANSFERevents and logs them as configuration drift; receipt conflicts go through support instead of silently moving entitlements. REVENUECAT_IOS_KEYgoes in the SwiftSecrets.xcconfig; the app logs in to RevenueCat with the Better Auth user ID, which is how the webhook maps purchases back to the shared user.
Verify both directions with sandbox purchases: web checkout → iOS shows entitled (and hides the App Store buy button); iOS sandbox purchase → web account page shows the subscription. Both flows write the same row — see Subscriptions.
Storage and push (production readiness)
- Uploads:
STORAGE_PROVIDER=localis fine on localhost. For staging and production setSTORAGE_PROVIDER=r2and theR2_*keys — Uploads. - Push: set
APNS_TEAM_ID,APNS_KEY_ID,APNS_BUNDLE_ID,APNS_PRIVATE_KEY, then use the app's debug push screen to send yourself a test notification — Push Notifications.
Environment checklist
Everything Unified Mode reads, in one place:
| Where | Variable | Required |
|---|---|---|
soar-next/.env | DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, APP_SCHEME, RESEND_API_KEY | Always |
soar-next/.env | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_IOS_CLIENT_ID, APPLE_APP_BUNDLE_IDENTIFIER | For OAuth sign-in |
soar-next/.env | Creem/Stripe keys + REVENUECAT_WEBHOOK_AUTHORIZATION + REVENUECAT_PRODUCT_* | For payments |
soar-next/.env | STORAGE_PROVIDER (+ R2_*), APNS_* | For uploads / push |
soar-swift/Secrets.xcconfig | SOAR_BACKEND_MODE=soar_api, SOAR_API_BASE_URL | Always |
soar-swift/Secrets.xcconfig | GOOGLE_IOS_CLIENT_ID, GOOGLE_REVERSED_CLIENT_ID, REVENUECAT_IOS_KEY | For Google sign-in / purchases |
Staging and production checklist
- Deploy
soar-nextto a staging host; setBETTER_AUTH_URLand re-point the Creem/Stripe and RevenueCat webhooks at it. - Build a Swift staging configuration with
SOAR_API_BASE_URL= the staging host (HTTPS). - Switch
STORAGE_PROVIDER=r2and verify an upload end to end. - Use production APNs (
apns.push.apple.com) for App Store builds; sandbox tokens and production tokens are not interchangeable. - Apply schema changes via generated migrations, not
drizzle-kit push. - Confirm RevenueCat Restore Behavior is still Transfer disabled, and that auth rate limits are active (they enable in production).
Common errors
| Symptom | Cause / fix |
|---|---|
403 MISSING_OR_NULL_ORIGIN on sign-in | APP_SCHEME missing or not soar:// — must match the client's hardcoded Origin. |
| Every API call fails with a config error | SOAR_BACKEND_MODE=soar_api but SOAR_API_BASE_URL unset — deliberate, no silent Supabase fallback. |
401 UNAUTHENTICATED after working fine | Session revoked or expired; the app clears the Keychain token and asks for sign-in. Don't judge auth by /api/auth/get-session's status — it returns 200 with null. |
| Google sign-in rejected server-side | Token's audience not in the allow-list — GOOGLE_IOS_CLIENT_ID missing on the server, or clients created in different Google projects. |
| iOS purchase never reaches the web | RevenueCat webhook URL/Authorization mismatch, or a REVENUECAT_PRODUCT_* mapping missing for the purchased product. |
| Web checkout blocked with "already subscribed" | Working as designed: an active subscription on either channel blocks a second purchase until it expires. |
| Test push says token invalid | Sandbox/production mismatch — a debug build's token needs the sandbox APNs environment. |
