TypeVoice — Deployment Guide

The app runs fully keyless in development. Production is deliberately small — 6 services, ~21 env vars, and every integration is already coded and env-gated:

ServiceJobWhy this one
RailwayApp + Postgres + domainOne dashboard for compute, data, deploys, and typevoice.ai
SupabaseAuth only (magic link + Google)Already coded in lib/auth.ts/proxy.ts; free tier; no user data lives there — our DB is Railway
Cloudflare R2Audio storageZero egress fees — audio playback is the main bandwidth cost
OpenRouterTranscribe / extract / scoreProvider fully implemented, inert until keyed; one key, models swappable by env
StripeBillingCheckout + Portal + webhook already coded
ResendEmailNotification send fully coded, inert until keyed

The map: env var → what the stub does when it's absent

Env var(s)ServiceBehavior when absent
DATABASE_URLRailway PostgresSQLite file at prisma/dev.db — always works
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEYSupabase AuthDev-stub session: auto signed-in seeded owner
AI_PROVIDER, OPENROUTER_API_KEYOpenRouterMock: no transcripts/scores on real recordings, honest violet card; audio playback fine
STORAGE_PROVIDER, R2_* (4)Cloudflare R2Local disk → .data/
RESEND_API_KEY, EMAIL_FROMResendNotifications logged to console (settings persist and apply once live)
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_* (4)StripeBilling page visual-only, honest modal on upgrade buttons
NEXT_PUBLIC_APP_URLhttp://localhost:3000
IP_HASH_SALT"dev-salt" fallback — set a long random string in prod
TRUST_PROXYUnset: x-forwarded-for ignored (spoofable without a proxy). Set 1 on Railway.

Nothing else. Inngest and Upstash from the original spec are not used — see "When you scale" at the bottom.

1. Railway — app, Postgres, domain

  1. New project at railway.com → "Deploy from GitHub repo". The builder auto-detects Next.js (npm run build / npm start; next start respects Railway's injected PORT).
  2. Right-click the canvas → add a PostgreSQL service. On the app service, set DATABASE_URL=${{Postgres.DATABASE_URL}} (a reference variable — stays correct if credentials rotate).
  3. Switch Prisma to Postgres: in prisma/schema.prisma change provider = "sqlite""postgresql", then npx prisma migrate dev --name init against the Railway DB (dev used db push, so fresh migrations generate cleanly). Add npx prisma migrate deploy as the pre-deploy command. Optional: npm run db:seed for the demo forms.
  4. Set the remaining env vars from .env.example in the Variables tab: NEXT_PUBLIC_APP_URL=https://typevoice.ai, TRUST_PROXY=1, a real IP_HASH_SALT, and the service keys from the sections below.
  5. Settings → Networking → add custom domain typevoice.ai; point the DNS records Railway shows you (CNAME, or ALIAS/ANAME at the apex).

Platform notes:

2. Supabase — auth only (magic link + Google)

Already coded (lib/auth.ts, lib/supabase/*, proxy.ts, /login, /auth/callback). Supabase holds no product data — it only issues sessions; users map by email into our Postgres on first login (trial plan, 14 days, founding flag while under the cap).

  1. Create a project at supabase.com (the bundled Postgres goes unused — that's fine).
  2. Authentication → Sign In / Up: enable Email (magic link) and Google (OAuth credentials from Google Cloud Console; authorized redirect URI is your Supabase callback URL, shown in the provider form).
  3. Authentication → URL Configuration: Site URL https://typevoice.ai, and add https://typevoice.ai/auth/callback to Redirect URLs.
  4. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY (Project Settings → API).
  5. New users land in the onboarding wizard automatically (onboardedAt = null).

3. Cloudflare R2 — audio storage

Zero code changes. R2Storage is implemented in lib/storage.ts (presigned 15-min playback URLs, private bucket) and inert until keyed.

  1. Cloudflare dashboard → R2 → create bucket typevoice-audio (private). Create an API token with object read/write.
  2. Set STORAGE_PROVIDER=r2 + the four R2_* vars.

The authed /api/answers/[answerId]/audio route keeps working as a streaming proxy, or you can switch the player src to getPlaybackUrl() presigned URLs to serve straight from R2.

4. OpenRouter — the AI pipeline

Zero code changes. lib/ai/openai.ts ships fully implemented (raw fetch, no SDK) and inert. OpenRouter's API is OpenAI-compatible for both endpoints we use — including /audio/transcriptions.

  1. Create an API key at openrouter.ai and load a few dollars of credits.
  2. Set AI_PROVIDER=openrouter and OPENROUTER_API_KEY=sk-or-….
  3. Done. New submissions transcribe (openai/gpt-4o-mini-transcribe), extract per your hints, summarize, and score (openai/gpt-4o-mini, JSON mode, zod-validated, clamped 1–100). The prompts live in lib/ai/prompts.ts; the models swap via OPENROUTER_STT_MODEL / OPENROUTER_CHAT_MODEL without a deploy — useful for A/B-ing cheaper STT (e.g. openai/whisper-large-v3-turbo) or a smarter scorer later.

(AI_PROVIDER=openai + OPENAI_API_KEY still works if you ever want to go direct.)

Unit economics: ~5 min of audio per submission ≈ $0.015 STT + <$0.01 LLM ≈ ~$0.03/submission COGS (OpenRouter passes through provider pricing; their ~5% fee is on credit top-ups). 500 submissions on Pro ≈ $15 against $99. 100 on Starter ≈ $3 against $49. Fine.

To re-run AI on an old submission, use the Retry button on a failed row, or flip a submission's status to failed and retry — the pipeline resumes from the first missing artifact and never re-transcribes what it already has.

5. Resend — email

Zero code changes. The send is implemented in lib/email.ts (raw fetch, no SDK; errors are logged, never break the pipeline) and inert until keyed.

  1. Create an API key at resend.com and verify typevoice.ai as a sending domain.
  2. Set RESEND_API_KEY and [email protected].

The pipeline calls it when a score clears the owner's threshold; the notification settings UI persists notifyEnabled/notifyThresholdDefault.

6. Stripe — billing

Already coded: app/api/billing/checkout, app/api/billing/portal, app/api/webhooks/stripe, and the billing page switches from the honest modal to real Checkout the moment keys exist.

  1. Create Products/Prices: Starter $49/mo, Pro $99/mo, plus founding prices $39/$79 (monthly only, no lifetime deals).
  2. Set STRIPE_SECRET_KEY and the four STRIPE_PRICE_* ids.
  3. Dashboard → Webhooks → add endpoint https://typevoice.ai/api/webhooks/stripe with events checkout.session.completed, customer.subscription.updated, customer.subscription.deleted; set STRIPE_WEBHOOK_SECRET from it. (Locally: stripe listen --forward-to localhost:3000/api/webhooks/stripe.)
  4. Trial expiry behavior (already the product rule): forms stay live and recordings are still captured — new submissions just queue as processingDeferred until subscribed. Never hold a recording hostage.

When you scale (not before)

Post-launch checklist