Skip to main content
Back to Blog
8 min read

Architecting a ticketing platform for 12K tickets per event: inside E7

End-to-end event ticketing — interactive seat maps, Stripe checkout, real-time WebSockets, QR check-in. 12 weeks from zero to production. The full build story.

case-studynext.jsstripe

E7 Entertainment runs live events in Central Europe — concerts, corporate galas, theater. When they found me, they were selling tickets through a reseller platform that took 15% per ticket, gave them no customer data, and had a mobile checkout so broken that 40% of buyers abandoned at the seat selection screen.

They wanted their own platform. I built it in 12 weeks.

The problem in numbers

Three pain points, each costing real money:

15% reseller cut. On a 1,000-seat event at €35/ticket, that's €5,250 gone before the first act starts. Over a year of events, the reseller was their second-largest expense after venue rental.

No customer data. Tickets sold through the reseller belonged to the reseller. E7 couldn't email previous buyers about new events, couldn't see which events had repeat attendees, couldn't do any marketing that wasn't paid ads.

40% mobile checkout abandonment. The reseller's mobile seat picker was a desktop interface squeezed into a phone screen. Pinch-to-zoom on an SVG seat map, then try to tap a 12px circle. The funnel was: browse event → pick seats → enter details → pay. 40% dropped between step 1 and step 2 on mobile. That's not a UX problem — it's a revenue leak.

Constraints

12 weeks, hard deadline. E7 had their biggest event of the season in 14 weeks. We needed 12 for the build and 2 for testing with real events at smaller venues.

Must handle 12K tickets per event. Their largest venue seats 3,000. Four events per month. The system needed to handle concurrent seat selection without overselling — if two people are looking at the same seat, only one gets it.

Stripe only. E7 was already on Stripe for merch sales. No new payment provider. I wrote about the Stripe webhook edge cases separately — some of those lessons came directly from this build.

What we shipped

The stack

Frontend: Next.js 15, React 19, TypeScript, Tailwind CSS. The public-facing site (event listings, seat selection, checkout) and the admin dashboard (event management, analytics, scanner) are the same Next.js app with role-based routing.

Backend: Node.js with Prisma ORM on PostgreSQL. Redis for session state, seat locks, and rate limiting. The API layer is Next.js API routes — no separate backend service.

Payments: Stripe Checkout for the purchase flow. Webhooks for fulfillment (issue tickets, send QR codes, update inventory). Stripe Connect ready for future multi-vendor events.

Real-time: WebSockets via a lightweight Node server. Seat map updates are broadcast to all connected clients in <100ms. When someone selects a seat, everyone else sees it go grey instantly.

Infrastructure: Vercel for the Next.js app. Supabase for PostgreSQL + real-time. Cloudflare R2 for ticket PDFs and QR images. Sentry for error tracking.

Seat selection — the hard part

The interactive seat map is the core of the product and the hardest engineering challenge.

Each venue has an SVG seat map — a vector drawing where every seat is an element with a unique ID. The map is stored as structured data (rows, sections, seat coordinates) in Postgres, and the SVG is rendered client-side from that data.

The tricky part is concurrency. When 200 people are browsing the same event, they need to see accurate seat availability in real time. Our model:

  1. Seat states: available, locked, sold. Locked means "someone has this in their cart."
  2. Lock duration: 8 minutes. If you don't complete checkout in 8 minutes, the seat releases back to available. This is Redis TTL — the lock literally expires.
  3. Broadcast: When a seat state changes, a WebSocket message goes to all connected clients for that event. The seat map re-renders only the affected seats — no full page reload.
  4. Race condition handling: Two users click the same seat at the same time. The lock is a Redis SET NX (set if not exists) — atomic, first-writer-wins. The loser gets a "seat just taken" toast within 100ms.

Zero overselling incidents in six months of production. That's the metric that matters most.

Checkout flow

Seat map → cart summary → Stripe Checkout → confirmation page with QR codes.

The whole flow runs under 30 seconds for a returning user. Key decisions:

QR check-in

The staff-facing scanner is a separate page in the same app, behind auth. A staff member opens it on their phone, points the camera at a QR code, and gets:

Response time is under 200ms. The scanner works offline too — it caches the event's ticket list locally and syncs when connectivity returns. More on the offline architecture.

Staff throughput went from ~120 attendees/hour (manual check with paper lists) to ~500/hour per scanner. At a 3,000-seat event with 4 scanners, that's full check-in in 25 minutes instead of 2+ hours.

The numbers

Six months of production data:

MetricValue
Tickets sold12,400+
Uptime99.95%
Checkout completion (mobile)78%
Checkout time (median)24s
API p95 latency180ms
Overselling incidents0
Reseller fee saved~€18K/yr

The 78% mobile checkout completion is the headline — up from 60% on the old reseller platform. Still not 100%, but the remaining 22% is mostly price sensitivity and abandoned browsing, not UX friction.

The €18K/year saved on reseller fees paid for the entire build in the first year.

What surprised us

Seat map SVG performance on low-end Android. A 3,000-seat SVG has 3,000+ DOM nodes. On a Pixel 4a, initial render was 1.8 seconds and zoom was janky. We solved it with virtualization — only seats in the viewport are rendered as interactive elements. Out-of-viewport seats are a single <path> element showing the general layout. Interaction time dropped to 200ms.

Stripe webhook ordering. Webhooks don't arrive in order. We got checkout.session.completed before payment_intent.succeeded about 12% of the time. Our fulfillment logic initially depended on payment_intent.succeeded — which meant 12% of buyers waited up to 30 seconds for their tickets. Fixed by triggering fulfillment on checkout.session.completed and treating payment_intent.succeeded as a confirmation, not a trigger.

The 8-minute lock duration was the third attempt. Started at 15 minutes (too long — popular events had most seats "locked" by browsers who abandoned), tried 3 minutes (too short — slow typers on mobile didn't finish checkout), settled on 8 after A/B testing with real event traffic.

What I'd do differently

I'd use Stripe Embedded Checkout instead of hosted Checkout. At build time, Stripe's embedded option was new and under-documented. Six months later, it's stable and eliminates the redirect. Smoother UX, same PCI compliance. I'd switch on the next major update.

I'd build the admin dashboard as a separate app. Currently it's the same Next.js app with middleware-based role routing. It works, but the admin bundle includes seat map components that public users don't need, and vice versa. Two apps sharing a Prisma schema would have cleaner builds and better code splitting.

I'd add load testing earlier. We load-tested in week 11 and found the seat map WebSocket could handle ~400 concurrent connections before Redis pub/sub started lagging. Fine for current event sizes, but it took a day of optimization that would have been smoother earlier.

The business result

E7 now owns their ticketing stack. Customer data flows into their CRM. Marketing emails go to previous buyers (30% open rate on event announcements). Mobile checkout works. The reseller fee is gone.

The platform has handled 50+ events without a single overselling incident or missed webhook. Staff check-in went from a paper-list bottleneck to a 25-minute operation.

And critically: the platform is an asset, not a cost. E7 is now exploring white-labeling it for other promoters in the region — a revenue line that didn't exist before.


If you're paying a reseller 10–15% and have enough events to justify the math — building your own ticketing platform is a 12-week project, not a 12-month one. Let's talk about your numbers.