Back to Blog
GeneralJUL 16, 20269 Min Read

Socio Kolab: Building a Multi-Tenant Club Management Platform

How I built a multi-tenant club management platform solo with Next.js and Postgres — and the trade-offs behind the stack, the theming engine, and the security plan I abandoned.

Socio Kolab: Building a Multi-Tenant Club Management Platform

Every student society I've seen runs on the same fragile stack: a WhatsApp group, a Google Form from two execs ago, and an Excel sheet that exactly one person understands. When that person graduates, the club's institutional memory graduates with them.

I decided to fix that for my own club — and to architect the fix so it could eventually serve any club on campus. This is the story of what I built, the decisions I made along the way, and the trade-offs behind them.

The problem

Club administration is deceptively hard, not because any single task is complex, but because everything is scattered. Nobody knows who is actually a member versus who just joined the group chat once. Dues collection lives in someone's head. Event turnout is a guess. And every year, leadership turns over and the whole system resets.

I scoped the first version around the three pain points that hurt my club the most: member management, dues tracking, and event management. Voting, resource libraries, and notifications were all tempting — and all deliberately cut. A portal that does three things well beats one that does ten things badly, and adoption was the real risk, not features.

Choosing the stack: boring on purpose

I'm building this solo and I wanted something so simple, that it would be easy for non-technical people to navigate their way through. That constraint drove almost every technical decision.

My first instinct was a Next.js frontend with a Django backend — partly because I wanted an excuse to learn Django. I talked myself out of it, and I'm glad I did. Two codebases means two deployments, an API contract to maintain, CORS, and duplicated auth logic. For a team, that separation buys you something. For one person, it roughly doubles the surface area where things can break, and the thing that actually kills student-built tools isn't traffic — it's maintainer burnout.

So the stack became deliberately boring: full-stack Next.js (App Router) with Server Actions, TypeScript, Prisma, and PostgreSQL, hosted on Vercel with a managed Postgres. One codebase, one deployment, server-side mutations without hand-rolling an API layer. If I wanted to learn Django, that could be its own project; this one had users waiting.

Trade-off: I gave up a clean frontend/backend separation and the learning experience of a second framework. In exchange, I ship faster, debug in one place, and the project stays maintainable by one person — which, for something meant to outlive my involvement, matters more than architectural purity.

The decision that paid for itself: multi-tenant-ready, single-tenant-deployed

From day one, I knew I wanted other clubs to eventually use this. But building full multi-tenancy upfront — onboarding flows, admin hierarchies, tenant isolation — before a single club had adopted the tool would have been building for a future that might never arrive.

The compromise: design for multi-tenancy, deploy for one club. Concretely, that meant four cheap decisions made early:

  1. Every table carries a clubId, even when there was only one club. Retrofitting a tenant column into a live database is miserable; carrying it from the start cost nothing.
  2. Nothing club-specific lives in code. Club name, logo, dues amount, academic period, departments — all configuration in a settings record.
  3. Auth identity and membership are separate models. A User is just credentials; everything club-related (role, status, department) lives on a Membership that joins a user to a club.
  4. Roles are club-scoped, attached to the membership rather than the user.

Days later, when I actually enabled multi-club support, this paid off exactly as hoped. The migration was additive: clubs gained a slug and a lifecycle status, a club-switcher page appeared after login, and — because of the user/membership split — a member of one club could apply to a second club without creating a second account. No rewrite, no data migration drama.

Trade-off: early on, every query carried a tenant filter that was, strictly speaking, unnecessary, and the data model was slightly more indirect than a single-club app needed. That small ongoing tax bought me a rewrite-free path to the feature that makes the project interesting.

URLs: the security theatre I almost built

When multi-club support arrived, I initially wanted opaque URLs. My plan was Snowflake IDs, encrypted before being sent to the frontend, so nobody could guess or enumerate club identifiers.

Working through it changed my mind, and the reasoning generalizes. First, my IDs were already unguessable — Prisma's cuids are long random strings, and ironically, Snowflakes (being time-ordered integers) would have been more predictable, which is why they'd then need the encryption layer at all. Second, and more fundamentally: ID obscurity is not access control. The thing that actually stops club B's exec from reading club A's data is server-side authorization — every query scoped by tenant, every action verifying the caller's membership. If that's done right, knowing a valid ID gets you a 404. If it's done wrong, no amount of ID encryption saves you. Meanwhile, the encryption layer would have brought real costs: key management, broken bookmarks on key rotation, and painful debugging.

What I shipped instead: human-readable slugs for clubs (/adrian-tech/dashboard) — which clubs genuinely love, because the URL feels like theirs — and plain cuids for internal resources. The security work went where it belongs: every resource fetch filters by both id and clubId in the same query, so a valid event ID from club A requested under club B's slug simply doesn't resolve. That compound-scoping sweep was its own audited commit, and the test suite includes adversarial checks — direct server-action calls with cross-tenant IDs, not just hidden buttons.

Trade-off: club names are visible in URLs. They were always going to be public anyway; I traded imaginary secrecy for real usability and put the engineering effort into actual isolation.

Theming: three colors in, a design system out

The feature I'm proudest of is the white-label theming engine. Each club picks exactly three colors — background, primary, accent — and the system derives everything else: hover and active states, tints for badges and selected navigation, borders, surfaces, and a full text hierarchy. Around twenty-five design tokens computed from three inputs.

The interesting engineering lives in the edge cases:

  • Dark themes come free. The engine computes the background's relative luminance; below a threshold, the neutral scale derivation flips. A club that picks red-on-black gets a coherent dark theme through the same code path as everyone else — no dark-mode fork.
  • Users can't break it. Contrast between the chosen colors is validated server-side against WCAG thresholds, and unreadable combinations are blocked at save time with a live preview showing execs what they're choosing.
  • Semantics stay fixed. Success-green and danger-red never change with branding. A club whose brand color is red still needs red to unambiguously mean "unpaid."
  • No flash of unstyled content. Tokens are rendered server-side as CSS variables in the club-scoped layout, so the first paint is already in the club's colors.

Trade-off: clubs can't fine-tune individual tokens — a club that wants a specific hover color is out of luck. But "pick three colors" is a promise a non-technical club president can actually keep, and constraining the input space is precisely what makes guaranteed-readable output possible.

Modelling decisions that only look small

Two data-modelling choices shaped the whole system more than any framework decision.

Dues are immutable period-scoped records, not flags. A payment is a row linking a membership to an academic period, with amount, method, and who recorded it — never a hasPaid boolean on the member. Rolling the club into a new year means changing one setting; every prior year's collection history stays intact and auditable. For an organization that reports to faculty advisors and hands over between treasurers annually, auditability is the feature.

Guests are attendees without memberships. When I added public event registration (anyone can register via a shareable link, no account required), the tempting move was a separate guest-registrations table. Instead, the existing attendance model gained a nullable membership reference plus guest name and email. One model means check-in lists, response counts, and CSV exports treat members and guests uniformly — a guest is just a row with a badge. The cost was an invariant Prisma can't express (a row is either member-linked or guest-identified), enforced in the action layer, with unique constraints as the backstop against races.

The event forms themselves taught a related lesson: responses are keyed by immutable field IDs, never labels. Execs build custom registration forms with a drag-and-drop builder; they rename and delete fields freely. Because every response is stored against a field's permanent ID, renames never corrupt history, and deleted fields' data survives, resurfacing in exports as "(removed field)." Choose your keys as if everything else will change — because it will.

Public forms also brought my first unauthenticated write endpoint, and with it, threats a members-only app never faces: the submission action validates every payload against the event's configured schema server-side (a select value outside the configured options is rejected, unknown keys are stripped), a honeypot field deters casual bots, and the CSV export guards against formula injection — because anonymous strangers type into those fields and the output lands in a treasurer's Excel.

What I'd tell another student building something like this

Let the pain pick the features. I built member management first not because it was interesting, but because it was what actually hurt.

Make the cheap future-proofing decisions; skip the expensive ones. A tenant column on every table costs nothing. A tenant onboarding flow before you have tenants costs weeks.

Interrogate your own security instincts. My encrypted-ID plan felt secure. The boring answer — authorization on every query — was the correct one. Obscurity is what security feels like; scoping is what it is.

Constraints are a gift to your users. Three colors, five field types, one primary button per screen. Every constraint I imposed made the product harder to misuse and easier to build.

Design for your own graduation. The real test of this project isn't whether it works this semester — it's whether the exec team after next can run it without me. Boring stack, documented decisions, seeded demo data, and a README that assumes I'm gone.

The portal now handles members, dues, events with custom public registration forms, per-club theming, and a club approval pipeline — and the next club that wants in needs three colors, a slug, and an approval click.

Toluwalase Akinyemi

Toluwalase Akinyemi

Software Engineer & Law Student

Share

Related Insights

View All Posts