Case Study — Vallès Classics
I designed, built and deployed the website and full management platform for a cycling event in Catalonia with 200 spots at 20€, integrating Stripe payments, registration management, a discount code system and a responsive admin dashboard. Live at vallesclassics.com.
Full Stack · NextJS · TypeScript · Supabase · Stripe · Webhooks · Admin Dashboard · Payments
Stack: Next.js 16 (App Router) · TypeScript · Tailwind v4 · Supabase (Postgres + Auth + Storage) · Stripe Checkout + Webhooks · Resend · Vercel + Cron JobsThe Brief
A client running an annual cycling event with no system in place. They needed: a public page, online registration with real payments, a dashboard to manage registrants, automated communication (confirmation email), and a discount system for sponsors/early-bird.
Architecture Decisions
Server Components + server-side actions by default. All sensitive logic (price calculation, capacity checks, discount code validation) runs on the server; the client only shows a preview. Never trust the total the client sends.
Hosted Stripe Checkout, not Elements. Cuts PCI scope to zero and hands compliance off to Stripe. Idempotent webhooks with dual TEST/LIVE support on the same endpoint (signature verification loop).
RLS-first in Supabase. Every table has explicit policies. The service role is only used for audited bypasses in internal endpoints.
Free-checkout shortcut. If a 100% discount code brings the total to 0€, the flow skips Stripe entirely and marks the registration as paid, with no pointless checkout session.

Caption: The server decides the price; Stripe only charges when there is something to charge.
Key Features


Production Issues I Solved
1. A webhook silently broken in production. Vercel redirects vallesclassics.com → www.vallesclassics.com (HTTP 307). Stripe does not follow redirects on webhooks → 42 payment events were going nowhere. 15 people had paid 20€ and didn't show up in the system. Diagnosed via Stripe Event Logs, fixed by updating the endpoint URL, recovered by using "Resend" on the events.
2. RLS gotcha with @supabase/ssr. The service role client was sending the user's cookie JWT in the Authorization header, which overrode the role → upsert blocked by RLS even though the service role "supposedly" bypasses everything. Fix: separate the anon client (auth check) from the service client (the operation itself). Documented in CLAUDE.md.
3. Specificity bug in the mobile sidebar. An inline style={{ display: 'flex' }} beat className="max-md:hidden" on CSS specificity → the desktop sidebar stayed stuck on mobile. Migrated every display rule to Tailwind classes.
4. App settings upsert without onConflict. Every Stripe mode toggle inserted a new row instead of updating the existing one → the toggle "forgot" its value after redeploys. Fixed with onConflict: 'key' + a SQL dedupe + a hardened schema.
5. Credential rotation under pressure. A mass key leak on Vercel: coordinated rotation of the Supabase service role, Stripe (test + live), Resend and webhook secrets. Documented a zero-downtime migration plan.

Caption: A redirect invisible to the browser was a black hole for Stripe.

Caption: The full landing page, top to bottom: read it left to right.

Caption: The same page on a phone, built for the thumb.
Technical Details
Shared discount validation between the public (preview) endpoint and the server-side checkout — a single source of truth in
lib/discount.tsCode usage is incremented only after a confirmed
checkout.session.completed(not when the session is created)Destructive delete modals require literally typing
ELIMINAR; selective deletes use a lighter confirmation because the scope is explicitTransactional email with sponsors rendered server-side: an HTML table (not flexbox) with CSS filters so logos render white on dark, compatible with Gmail/Outlook
Keep-alive cron because the Supabase free tier pauses the DB after 7 days of inactivity → a daily ping that writes to a log table visible in the admin

Results
Real registrations processed with payments via Stripe LIVE
Zero downtime during the test → production migration
Admin dashboard used by the client from mobile with no support needed
Average registration time: ~90 seconds from landing to confirmed payment