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 push

One 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:

VariableValue
BETTER_AUTH_SECRETA long random string (openssl rand -base64 32)
BETTER_AUTH_URLThe server's own URL — http://localhost:3000 locally
APP_SCHEMEsoar:// — the iOS app sends this as its Origin on every request
RESEND_API_KEYNeeded 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:

  1. Google web clientGOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET (server .env) — powers the web redirect flow.
  2. Google iOS client (your bundle ID) → GOOGLE_IOS_CLIENT_ID on the server (audience allow-list) and in the Swift Secrets.xcconfig together with GOOGLE_REVERSED_CLIENT_ID (the SDK's callback scheme).
  3. 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.xcconfig
SOAR_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

  1. Sign up in the iOS simulator (email + OTP code, or Google).
  2. Sign in to the web app with the same account.
  3. Create a todo on the web → pull to refresh on iOS → it's there.
  4. Edit it on iOS → reload the web → it changed.
  5. 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:

  1. Web checkout: Creem (or Stripe) API key, product IDs, and webhook secret per Payments. Forward webhooks locally (ngrok/cloudflared) so purchases actually land.
  2. 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.
  3. RevenueCat webhook: point it at https://<host>/api/payment/notify/revenuecat with an Authorization header exactly equal to REVENUECAT_WEBHOOK_AUTHORIZATION.
  4. Restore Behavior: in the RevenueCat project settings, keep Transfer disabled. The server deliberately ignores TRANSFER events and logs them as configuration drift; receipt conflicts go through support instead of silently moving entitlements.
  5. REVENUECAT_IOS_KEY goes in the Swift Secrets.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=local is fine on localhost. For staging and production set STORAGE_PROVIDER=r2 and the R2_* 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:

WhereVariableRequired
soar-next/.envDATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, APP_SCHEME, RESEND_API_KEYAlways
soar-next/.envGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_IOS_CLIENT_ID, APPLE_APP_BUNDLE_IDENTIFIERFor OAuth sign-in
soar-next/.envCreem/Stripe keys + REVENUECAT_WEBHOOK_AUTHORIZATION + REVENUECAT_PRODUCT_*For payments
soar-next/.envSTORAGE_PROVIDER (+ R2_*), APNS_*For uploads / push
soar-swift/Secrets.xcconfigSOAR_BACKEND_MODE=soar_api, SOAR_API_BASE_URLAlways
soar-swift/Secrets.xcconfigGOOGLE_IOS_CLIENT_ID, GOOGLE_REVERSED_CLIENT_ID, REVENUECAT_IOS_KEYFor Google sign-in / purchases

Staging and production checklist

  • Deploy soar-next to a staging host; set BETTER_AUTH_URL and 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=r2 and 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

SymptomCause / fix
403 MISSING_OR_NULL_ORIGIN on sign-inAPP_SCHEME missing or not soar:// — must match the client's hardcoded Origin.
Every API call fails with a config errorSOAR_BACKEND_MODE=soar_api but SOAR_API_BASE_URL unset — deliberate, no silent Supabase fallback.
401 UNAUTHENTICATED after working fineSession 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-sideToken'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 webRevenueCat 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 invalidSandbox/production mismatch — a debug build's token needs the sandbox APNs environment.

On this page