← All case studies

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

Case Study — Vallès Classics
Stack: Next.js 16 (App Router) · TypeScript · Tailwind v4 · Supabase (Postgres + Auth + Storage) · Stripe Checkout + Webhooks · Resend · Vercel + Cron Jobs

The 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.


Registration flow: the server computes the price, zero-total shortcut, Stripe Checkout and webhook

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.

Incident: Vercel answered with a 307 and Stripe did not follow it; 42 events and 15 payments lost

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

The full Vallès Classics landing page, top to bottom in three columns

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

Vallès Classics on a phone: three moments of the same page

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.ts

  • Code 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 explicit

  • Transactional 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

More case studies