Part 1: The Problem - Running Online Competitions Shouldn't Take a Spreadsheet Army#
Every online competition looks simple from the outside. Contestants sign up, the public votes, a winner is announced. Then you look under the hood of how most organizers actually run them, and it is a Google Form feeding a spreadsheet, votes tallied by hand, payments collected over a personal payment link, and one very stressed person refreshing everything at midnight when voting closes.
That works until it doesn't. It breaks the moment real money and real traffic show up: people vote twice, paid votes go uncounted, the spreadsheet corrupts during the final-hour rush, and there is no defensible audit trail when a contestant disputes the result.
Imperial Votes was commissioned to end that pattern. The brief was not "a voting form." It was a competition operating system: one platform where any organizer can launch their own branded competition, manage contestants, sell and count votes without fraud, and watch results update live, even when thousands of people vote in the same ten minutes.
Four requirements made this a real engineering project rather than a weekend script:
- Multi-tenancy. One codebase, many organizers, each with their own branded space and their data strictly walled off from everyone else's.
- Paid voting that is actually correct. Votes are money. A double-charge, an uncounted vote, or a lost payment is not a bug, it is a refund request and a trust problem.
- Traffic that arrives all at once. Nobody votes evenly. Load spikes hard in the final hours before a deadline, exactly when correctness matters most.
- Fraud resistance. Where there is a leaderboard and money, someone will try to game it.
Off-the-shelf form tools cannot do this. There is no clean way to bolt fraud-resistant paid voting, per-tenant branding, and live leaderboards onto a spreadsheet. So it was built custom, and the whole platform came in at $8,000 - a fraction of the $50,000-plus an agency typically quotes for a multi-tenant SaaS with payments. The rest of this article is how that was possible.
Part 2: The Architecture - One Codebase, Many Competitions#
The stack was deliberately boring, because boring is what ships on a budget and survives in production: Next.js (App Router) + Prisma + PostgreSQL + Stripe, deployed on managed hosting. No microservices, no Kubernetes, no bespoke infrastructure. The interesting decisions were all about shape, not exotic tools.
The Multi-Tenancy Decision#
There are three common ways to make a SaaS multi-tenant, and the choice sets your cost and complexity for the life of the product.
| Approach | Isolation | Ops cost | Right for Imperial Votes? |
|---|---|---|---|
| Database per tenant | Strongest | High (migrations × N databases) | No - overkill at this scale and budget |
| Schema per tenant | Strong | Medium, awkward with Prisma | No |
Shared DB, shared schema, tenantId on every row | Good, if enforced | Low | Yes |
The shared-schema model gets a bad reputation because the isolation is only as good as your discipline: forget one WHERE tenantId = … and you leak one organizer's contestants to another. So the entire design goal became making that mistake impossible to commit, not merely unlikely. More on that in Part 3.
How a Request Finds Its Tenant#
Each organizer gets a subdomain - organizer.imperialtechsuite.com. A single piece of Next.js middleware resolves the subdomain to a tenant on every request and forwards it to the rest of the app, so no page or API route ever has to guess who it is serving.
The Data Model#
Five core models carry the whole system. The important detail is not what they are, but that tenantId is on nearly every one of them and is the spine of every query.
Tenant- the organizer, keyed by their subdomain slug.Competition- a voting event with an open and close time.Contestant- an entry, carrying a runningvoteCounttally.VoteOrder- a paid batch of votes, tied to exactly one Stripe payment.ProcessedEvent- a one-row-per-webhook ledger that makes payment handling idempotent.
Designing Votes as Money, Not Clicks#
The single most important architectural stance was treating a vote as a financial transaction. That decision rippled outward into three rules that shaped the code:
- Votes are credited only after Stripe confirms payment - never optimistically on the client.
- Crediting is idempotent. Stripe guarantees at-least-once webhook delivery, so the same payment event can arrive twice; it must never count twice.
- Tallies update atomically. During a final-hour rush, hundreds of payments can credit the same contestant concurrently. A naive read-count-then-write loses votes to race conditions; the database has to do the increment.
Part 3: The Implementation - The Code That Made It Safe#
Step 1: The Tenant-Scoped Schema#
Here is the heart of the Prisma schema. Notice tenantId threaded through every model and the composite index on voteCount that makes leaderboard reads fast even with a large field.
model Tenant {
id String @id @default(cuid())
slug String @unique // vote.imperialtechsuite.com
name String
competitions Competition[]
contestants Contestant[]
}
model Competition {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
votingOpensAt DateTime
votingClosesAt DateTime
contestants Contestant[]
@@index([tenantId])
}
model Contestant {
id String @id @default(cuid())
tenantId String
competitionId String
tenant Tenant @relation(fields: [tenantId], references: [id])
competition Competition @relation(fields: [competitionId], references: [id])
name String
voteCount Int @default(0) // atomic tally
@@index([tenantId, competitionId])
@@index([competitionId, voteCount(sort: Desc)]) // fast leaderboard reads
}
model VoteOrder {
id String @id @default(cuid())
tenantId String
contestantId String
quantity Int
stripeEventId String @unique // one order per payment
createdAt DateTime @default(now())
@@index([tenantId])
}
model ProcessedEvent {
id String @id // the Stripe event id - our idempotency ledger
}Step 2: Resolving the Tenant at the Edge#
The middleware does one job: turn a hostname into a tenant slug and forward it. The actual parsing lives in a small, pure function, because anything security-adjacent should be unit-testable in isolation.
const RESERVED = new Set(["www", "app", "api", "admin"]);
/** Pure, testable: hostname -> tenant slug (or null for the marketing site). */
export function resolveTenantSlug(host: string, root: string): string | null {
const hostname = host.split(":")[0].toLowerCase(); // strip any port
if (!hostname.endsWith(`.${root}`)) return null; // not a tenant subdomain
const slug = hostname.slice(0, -(root.length + 1)); // trim ".root"
if (!slug || slug.includes(".")) return null; // no nested subdomains
if (RESERVED.has(slug)) return null; // reserved words aren't tenants
return slug;
}import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const slug = resolveTenantSlug(
req.headers.get("host") ?? "",
process.env.ROOT_DOMAIN!,
);
if (!slug) return NextResponse.next(); // marketing / app shell
// Forward the resolved tenant so server code never re-parses the host.
const headers = new Headers(req.headers);
headers.set("x-tenant", slug);
return NextResponse.next({ request: { headers } });
}
export const config = { matcher: ["/((?!_next|favicon.ico).*)"] };Step 3: Making Tenant Leakage Impossible#
Passing a tenantId by hand into every query is the failure mode of shared-schema multi-tenancy. Instead, the app uses a Prisma client extension that injects the current tenant into every read and write automatically. A developer cannot forget it, because they never write it.
import { PrismaClient } from "@prisma/client";
const base = new PrismaClient();
/** Returns a client where every query is pinned to one tenant. */
export function forTenant(tenantId: string) {
return base.$extends({
query: {
// Applies to every model and every operation.
$allModels: {
async $allOperations({ args, query }) {
args.where = { ...(args.where ?? {}), tenantId };
return query(args);
},
},
},
});
}As a second line of defense, PostgreSQL Row-Level Security backstops the application layer, so even a raw query outside Prisma stays inside its tenant. Application scoping for ergonomics, RLS for guarantees - belt and braces on the one bug you can never ship.
Step 4: Crediting Paid Votes Exactly Once#
This is where votes-are-money becomes code. The Stripe webhook credits votes only after payment, deduplicates on the event id, and increments the tally atomically inside a transaction. If anything fails, it returns a 5xx and lets Stripe retry - without double-counting.
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("missing signature", { status: 400 });
const raw = await req.text(); // raw body required for signature check
let event: Stripe.Event;
try {
// Async variant works on both Node and edge runtimes.
event = await stripe.webhooks.constructEventAsync(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("bad signature", { status: 400 });
}
if (event.type !== "checkout.session.completed") {
return new Response("ignored", { status: 200 });
}
const s = event.data.object as Stripe.Checkout.Session;
const { tenantId, contestantId, votes } = s.metadata as Record<string, string>;
try {
await prisma.$transaction(async (tx) => {
// Claim the event id. If it's already there, we've credited this before.
const claim = await tx.processedEvent.createMany({
data: [{ id: event.id }],
skipDuplicates: true,
});
if (claim.count === 0) return; // duplicate delivery - do nothing
// Atomic increment: no read-modify-write, so concurrent buyers
// crediting the same contestant never clobber each other.
await tx.contestant.updateMany({
where: { id: contestantId, tenantId },
data: { voteCount: { increment: Number(votes) } },
});
await tx.voteOrder.create({
data: { tenantId, contestantId, quantity: Number(votes), stripeEventId: event.id },
});
});
} catch (err) {
console.error("credit failed; Stripe will retry", event.id, err);
return new Response("retry", { status: 500 }); // any 5xx triggers redelivery
}
return new Response("ok", { status: 200 });
}Step 5: Live Leaderboards Without the Wobble#
Because tallies live in an indexed voteCount column, the leaderboard is a single fast query rather than a count over millions of vote rows. Ranking, including tie handling, is a pure function - the kind of logic you want covered by tests, not discovered live during a final.
interface Tally { contestantId: string; votes: number }
/** Standard competition ranking: ties share a rank, the next rank is skipped. */
export function rankLeaderboard(tallies: Tally[]) {
const sorted = [...tallies].sort((a, b) => b.votes - a.votes);
let rank = 0;
let prevVotes = Infinity;
return sorted.map((t, i) => {
if (t.votes < prevVotes) {
rank = i + 1; // new, lower score -> rank jumps to position
prevVotes = t.votes;
}
return { ...t, rank };
});
}Part 4: Results and the Pitfalls I Designed Around#
At launch, the platform did what the spreadsheet never could.
| Metric | Result |
|---|---|
| Organizers (tenants) onboarded | ~12 |
| Votes processed in the first season | ~80,000 |
| Peak concurrent voters (final hour) | ~500 |
| Disputed or double-counted votes | 0 |
| Uptime through deadline spikes | 99.9% |
The reason those numbers held up is that the hardest problems were designed out before launch, not patched after. Four in particular are where similar projects quietly fail:
Hot-row contention on the tally. The obvious way to count a vote - read voteCount, add one, write it back - silently loses votes under concurrency, because two requests read the same value and one overwrites the other. The atomic increment in Step 4 pushes that race down into PostgreSQL, which is built to handle it. This is the single most common correctness bug in voting and "like" systems.
Double-counted payments. Stripe delivers each webhook at least once, not exactly once. Without the ProcessedEvent ledger, a routine network retry would credit the same purchase twice, and you would find out from an angry contestant, not a log. Idempotency is not optional when the event moves money.
Tenant data leakage. The one bug you cannot ship on a multi-tenant platform is showing organizer A the data of organizer B. Enforcing tenantId through a Prisma extension plus RLS means isolation is a property of the system, not a thing each developer has to remember on every query.
The deadline stampede. Traffic is not uniform; it mobs the final hour. Serving leaderboards from an indexed tally column (not a live COUNT over every vote) and caching read-heavy pages is what keeps the site up at the exact moment the whole competition is watching.
A note on honesty, since this is a case study and not an advertisement: none of these techniques are exotic. They are standard senior-engineering practice. The value was not a secret trick - it was knowing which boring, correct pattern to reach for on each problem, and not learning them for the first time on someone's live event.
Part 5: Key Takeaways - and What This Means for Your Build#
If you are a founder weighing a similar platform - voting, marketplaces, booking, anything multi-tenant with payments - here is what this project distills to:
- Custom is justified when correctness is the product. For a simple form, use a form tool. The moment money, fraud, and concurrency enter, off-the-shelf stops being cheaper and starts being a liability.
- Decide multi-tenancy on day one. Retrofitting
tenantIdinto a live schema is painful and risky. Shared-schema with enforced scoping is the pragmatic default for an MVP budget. - Treat money-like events as money. Credit after confirmation, dedupe every webhook, and let the database do atomic updates. These three habits prevent an entire category of "where did my votes/orders/bookings go" bugs.
- Make the dangerous mistake impossible, not unlikely. A Prisma extension that injects the tenant is worth more than a code-review rule that says "remember the tenant filter."
- Boring stacks ship. Next.js, Prisma, Postgres, and Stripe carried a full multi-tenant SaaS with paid voting for $8,000. The savings did not come from cutting corners; they came from not over-engineering, and from reusing patterns instead of inventing them.
That $8k figure is the part most founders anchor on, so let me be direct about it. A multi-tenant SaaS with payments is at the upper, more ambitious end of what a lean MVP costs - most first builds land smaller. The reason it was not $50,000 is that it was scoped as a sharp, correct v1 and built by someone who had solved each of these problems before, rather than billed by the hour to learn them.
If you have an idea in this shape - a platform other people will run their own events, listings, or transactions on - and you want it built correctly without agency pricing, that is exactly the kind of work I take on. Tell me about your project and I'll give you an honest scope and a real number.
Share this technical insight with your network
Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.
Case Study: Building Imperial Votes: A Multi-Tenant Next.js & Prisma Competition Operating System
Imperial Votes is a custom online voting platform for beauty pageants, modeling competitions, and organizer-led public contests. I built the product from scratch, starting from a blank Next.js application and turning it into a full SaaS-style platform with public competition pages, contestant profiles, paid voting, leaderboards, organizer dashboards, super-admin controls, role-based permissions, contestant applications, entry fee payments, judging workflows, branding tools, analytics, reports, notifications, and leaderboard graphic generation. The basic product idea was simple: organizers create competitions, contestants compete, voters buy vote bundles, and the leaderboard updates. The actual product became much bigger than that. Imperial Votes needed to support real organizers, real money, real applicants, different countries and currencies, different payment gateways, sponsor visibility, custom competition branding, membership tiers, internal admin workflows, and marketing tools that help organizers promote their contests. My work covered the entire platform: database architecture, backend APIs, payment logic, frontend dashboards, public pages, security and permissions, operational tooling, reporting, exports, image handling, email flows, real-time notifications, and polished UI features such as canvas-based leaderboard graphics.
Related Technical Articles
View all articles →
How Much Does It Cost to Build an MVP in 2026?
A custom MVP does not have to cost $50K+. Here is what you can realistically build for $2K, $5K, $10K, and $20K in 2026.

Building AI Features in Laravel Without Turning Your Application Into a Mess
AI features can quickly turn a clean Laravel application into a maintenance nightmare. Here's how to integrate AI without sacrificing architecture.

How a Laravel Consultant Ensures Project Transparency and Security
Clients don't just need a Laravel developer who can write code. They need confidence that their project is progressing, risks are being managed, and sensitive data is protected. Here's how I approach transparency, communication, documentation, and security when building Laravel applications.

When No-Code Breaks: Migrating a Live Airtable App to Next.js & PostgreSQL Without Losing Users
No-code apps rarely die, they plateau: row ceilings, 8-second loads, a $700 monthly bill. Here is how to move a live one to Next.js and PostgreSQL safely.
Have a complex technical project in mind?
Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

