A bedtime-story generator for kids 2–7. Pick characters from familiar shows, name a theme, and the model spins a short story tuned for read-aloud bedtime — with optional turn-taking, where the listener picks the next direction at each pivot.
Built on Next.js 16 (App Router), Clerk auth, Supabase, Stripe, OpenRouter, msedge-tts / Fish Audio, Groq STT.
- Listen mode — one streamed story, ~5 min read-aloud, with TTS that plays sentence-by-sentence as the prose lands.
- Turns mode — the AI writes a small segment, stops at a pivot, and offers three choices (action / dialogue / event). The listener picks (or types their own line). The next AI segment is steered by what they said: the prompt's cue directive demands the first sentence visibly act on the listener's pick. 4 pivots + 1 closing for short stories.
Both modes share a unified read-aloud control (play/pause + speed dropdown) in the header. Auto-read-aloud while streaming is gated by a global "Sound" preference in the home dropdown.
| Anonymous | Free signed-in | Paid ($1.99 / mo or $19.99 / yr) | |
|---|---|---|---|
| Stories per session | 3 | 5 / day | unlimited |
| Library cap | none | 10 saved | unlimited |
| TTS voice | Edge | Edge | Fish Audio (premium) |
| Story model | gpt-oss-120b (free) | gpt-oss-120b (free) | Claude Sonnet 4.5 |
Paywall and /subscribe are the same screen — context-appropriate eyebrow ("You've used today's free stories" / "Library is full" / "Upgrade") + unified plan picker.
- Runtime: Next.js 16 (App Router, Turbopack), React 19, Bun for dev / build
- Auth: Clerk (with Svix-signed webhooks for
user.created/user.updated) - Database: Supabase Postgres + RLS, Storage bucket for TTS cache
- Billing: Stripe Checkout + Customer Portal + signed webhooks (
checkout.session.completed,customer.subscription.*,invoice.payment_failed) - Rate limit + counters: Upstash Redis (per-IP rate limit, daily story counter, 60 s subscription cache)
- LLM: OpenRouter (free tier:
openai/gpt-oss-120b:free; paid:anthropic/claude-sonnet-4.5) - TTS: msedge-tts (free, via
/api/tts) and Fish Audio (paid voices); audio is HTTP-streamed and cached in Supabase Storage - STT: Groq Whisper (
/api/stt) for the take-turns mic input - Moderation: OpenAI moderation (currently soft-disabled in story routes — see
app/api/story/route.tsfor how to re-enable) - Observability: Sentry (browser + server, source-map upload, ad-blocker tunnel route at
/monitoring), PostHog (anon + identified events) - Cron: Vercel Cron →
/api/cron/spend-alert(daily, authed viaCRON_SECRETBearer)
Prerequisites: Bun, a .env.local with the keys listed in .env.example.
bun install
bun run dev # http://localhost:3000
bun run build # production build (also typechecks)For end-to-end testing of the Stripe webhook locally:
stripe login
stripe listen --forward-to http://localhost:3000/api/webhooks/stripe
# Copy the printed whsec_… into .env.local STRIPE_WEBHOOK_SECRET, restart dev.The Clerk webhook is not load-bearing for sign-up locally — requireUserId() in lib/auth.ts lazy-creates the Supabase users row on every authenticated request. Wire the Clerk webhook only in prod (it's used for user.updated — email changes etc.).
app/— App Router pages + route handlers (app/api/**)components/— UI components (one per surface; e.g.TakeTurnsMode.tsx,SubscribePanel.tsx,Paywall.tsx,ReadAloudControl.tsx)hooks/— client hooks (useStoryGenerator,useStoryTurns,useReadAloud,useAutoReadAloud,useBillingState,useUserProfile,useSavedStories,useTTS,useSpeechRecognition,useLanguage)lib/— shared modules (claude.ts— prompts + OpenRouter streams;tts.ts— TTS speaker logic;billing.ts,auth.ts,storage.ts,storage-server.ts,tts-cache.ts,analytics.ts, etc.)lib/config/site.ts— single source of truth for legal entity name / contact / DMCA mailing address (rendered in/termsand/privacy; the DMCA procedure lives at/terms#dmca)supabase/migrations/—0001_init.sql(users, stories, profiles, RLS, atomic free-story RPC) +0002_billing.sql(subscription columns)proxy.ts— Clerk middleware. Route allowlist for anon-friendly surfaces; protected/api/*routes return JSON 401 instead of HTML redirect (so clientfetch()doesn't choke on<from the sign-in page)vercel.json— registers the daily spend-alert cron
See DEPLOYMENT.md for the full Vercel + Stripe + Clerk launch sequence. Short version: deploy on *.vercel.app first, wire webhooks, smoke-test the full sign-up → paywall → subscribe → cancel cycle, then promote to a custom domain.