Context
A fantasy World Cup web app: end users register, claim a redeem code, build a squad from a pool of players within a season's budget, and submit transfers as the tournament progresses. A separate admin surface manages seasons, teams, players, redeem-code batches, contact messages, and analytics.
Engagement shape. Freelance build delivered with one other developer alongside me; design and product copy were provided by the client. I owned the architecture, the full-stack scope (Next.js App Router + Route Handlers + Supabase schema and RLS), the admin tooling, the test setup, and code review — the second engineer worked against the patterns and boundaries set up here.
The product is split into clearly-scoped route groups — (marketing) for the public landing, (auth) for end-user sign-in / OTP / password reset, (admin-auth) for staff sign-in, (user) for the gameplay surface (collection, teams, transfers, redeem, profile), and admin/* for the back office.
Problem
Fantasy games look like CRUD until you actually build them. The constraints that shape the architecture:
- Identity is mixed. End users sign in by phone number with OTP (Twilio), staff by email + password. Both paths must converge on a unified server-verified session.
- Anti-abuse on redeem. Codes are issued in batches and must be single-use, time-bounded, and not enumerable.
- Squad rules are domain logic. Budget caps, position constraints, and transfer windows belong on the server — never the client.
- Admin surface is non-trivial. Players, teams, seasons, redeem batches, contact messages, analytics charts. Building this on top of the user APIs would create coupling; building it isolated would duplicate auth.
Architecture
Why two auth strategies, one session?
Mixing OTP and password into a single sign-in flow creates UX and security regressions on both sides. Separate Route Handlers (/api/auth/login, /api/auth/verify-otp, /api/auth/admin/login) keep the flows distinct, but each writes to the same httpOnly cookie shape so downstream handlers don't care which path produced the session.
Implementation
Schema validation everywhere. Zod schemas back every Route Handler and every form via @hookform/resolvers, so the client and server share types — no drift between what the form accepts and what the API will store.
Squad and transfers. Squad construction goes through /api/user/teams/[teamId]/squad, which validates budget, position counts, and transfer-window state on the server before persisting. The client UI prevents most invalid states for UX, but the server is authoritative.
Redeem codes. Generated server-side in batches, hashed at rest, single-use; admins manage batches via /api/admin/redeem-code-batches. The user-facing endpoint (/api/redeem) checks redemption window, idempotency, and a per-user rate ceiling before unlocking the redeemed entitlement.
Admin analytics. /api/admin/analytics returns aggregated metrics that the admin dashboard renders with Recharts — registrations, OTP success rate, contact-message volume, redeem usage by batch.
Testing. Domain helpers and validation rules are unit-tested with Vitest; user-facing flows (register → OTP → redeem → build squad → transfer) are covered by Playwright specs.
Outcome
- Production-ready fantasy game with split end-user / admin surfaces and a single shared session model
- 30+ Route Handlers covering authentication, redeem, squad, transfers, profile management, and admin CRUD on players / teams / seasons / code batches / contact messages
- Domain rules for budget, transfers, and redeem are server-authoritative — the client UI is a projection of server state, not the source of truth
- Test pyramid in place from the start: Vitest for pure logic, Playwright for end-to-end flows