All articles
Next.js 14TypeScriptPostgreSQLPrismaAuth.jsMulti-TenancySystem Design

Building FitKalp CRM — A Gym Management System, End to End

A deep dive into every feature I built, every technical decision I made, and exactly why I made it.

Sep 2026
14 min read

Why I Built This

Managing a gym is surprisingly messy. Members join every week, subscriptions expire without anyone noticing, payments get collected but never properly tracked, and staff need different levels of access to the system. Most gym owners I spoke to were doing all of this with WhatsApp groups and Excel sheets.

I wanted to build something real and practical — not a bloated SaaS with features nobody uses, but a focused tool that a gym owner and their 2–3 staff members could pick up and use daily without training. The result is FitKalp CRM: a full-stack gym management platform handling members, subscriptions, payments, attendance, expenses, invoices, analytics, equipment, and more.

The Stack and Why I Chose It

Next.js 14 (App Router) — The gym dashboard involves a lot of data: active members, expiring subscriptions, revenue, expenses. With React Server Components, I fetch all of that on the server and stream the UI progressively — no extra client-side API round trips for the initial render. This made the dashboard feel fast without any manual caching tricks.

TypeScript — Non-negotiable. With roles, permissions, subscription statuses, and financial calculations, strict types are what keep bugs out of production.

PostgreSQL + Prisma — Relational data fits gym management perfectly. Prisma gives type-safe queries and migration history. More importantly, Postgres supports Row-Level Security (RLS), which I use for multi-tenancy.

Auth.js (NextAuth v5) — Credentials-based login with JWT sessions. Role and gym context live directly in the JWT token.

Tailwind CSS + shadcn/ui-style components — Fast to iterate, looks professional, easy to maintain.

Supabase — Postgres hosting and Storage for file uploads (logos, member photos).

Resend & Fast2SMS — Transactional email with PDF receipts attached, and SMS notifications for Indian numbers.

Puppeteer + @sparticuz/chromium — PDF generation for invoices and receipts within serverless limits.

Recharts — Responsive charts in the analytics and dashboard sections.

System Architecture & Visual Infographics

To clearly map out every architectural decision, operational engine, and security boundary across the system, I designed a suite of 5 visual-first Excalidraw infographics. Each diagram condenses complex full-stack concepts into clean visual flows (Problem → Cause → Impact → Solution):

  • 01-system-architecture-and-dual-auth: Dual-Runtime Authentication with Edge Middleware route guards vs Node.js Server Actions & decoupled HMAC member sessions.
  • 02-multi-tenancy-and-rls-isolation: Multi-Tenant Data Sovereignty eliminating PgBouncer connection pool bleed using transaction-scoped GUCs (SET LOCAL) and Postgres RLS.
  • 03-gym-operations-and-lifecycle: Operational Lifecycle with reception QR intake, atomic ACID enrollment, and derived anti-stale subscription status.
  • 04-finance-and-pdf-pipeline: Financial Ledger & Document Engine with native split payment math, 3-tier invoice branding fallback, and serverless Chromium PDF rendering.
  • 05-saas-tiering-and-entitlements: Commercial Tiering & Super Admin with 3 SaaS tiers, 14 granular entitlement flags, and pure-function in-memory resolvers.

Multi-Tenancy: How Multiple Gyms Share One Database

This is the most important architectural decision in the project.

FitKalp is a SaaS product — multiple gyms share the same database, but each gym must see only its own data. I implemented this using PostgreSQL Row-Level Security (RLS). Every gym-scoped table has a gymId column, and Postgres RLS policies at the database level enforce isolation.

The tricky part is Supabase's connection pooler (PgBouncer) running in transaction mode. Session-level GUC variables (SET app.gym_id) don't survive transaction boundaries and bleed into other pooled sockets. So if you set it once at the connection level, the next query from a different gym might slip through.

My solution is the withTenant function:

Infographic 02 — Multi-Tenancy & RLS Isolation
Multi-Tenant Data Sovereignty & RLS Isolation Diagram
Click to inspect
Eliminating PgBouncer connection pool bleed using transaction-scoped GUCs (SET LOCAL) and PostgreSQL Row-Level Security.
DimensionArchitectural Analysis
ProblemIn a shared PostgreSQL database, pooled connections in Supabase PgBouncer risk cross-tenant data bleed.
CauseSession-level GUC variables (SET app.gym_id = 'A') persist on the underlying TCP socket across recycled connections.
ImpactIf Gym B acquires an unrecycled socket previously tagged with Gym A's ID, Gym B could view Gym A's sensitive members and revenue.
SolutionThe withTenant(gymId, fn) wrapper executes SET LOCAL app.gym_id = $gymId inside an explicit Prisma transaction. PostgreSQL automatically drops the GUC upon commit/rollback, returning a pristine connection to the pool.
ResilienceAutomatic 3-attempt exponential backoff retry loop (100ms → 200ms → 400ms) prevents 500 errors during peak morning check-in spikes.
lib/db/context.ts
export function withTenant<T>(
  gymId: string,
  fn: (tx: Prisma.TransactionClient) => Promise<T>
): Promise<T> {
  return withDbContext({ kind: "tenant", gymId }, fn);
}
Key Architectural Takeaway
In FitKalp, gyms share one database isolated by Postgres RLS. Because PgBouncer uses transaction pooling, session variables persist across queries and leak data. We solved this with withTenant(), which wraps queries in a Prisma transaction, issues SET LOCAL app.gym_id, and enforces RLS at the kernel. The moment the transaction commits, Postgres automatically discards the variable, returning the connection clean to the pool.

Authentication: Two Configs for Two Runtimes

I use Auth.js v5 (NextAuth) with a credentials provider.

The challenge: Next.js middleware runs on the Edge runtime, which doesn't support Node.js modules like Prisma or bcrypt. If I put everything in one auth config file, the middleware would fail to import it.

The solution is to split auth into two files:

Infographic 01 — System Architecture & Dual-Runtime Auth
Dual-Runtime Authentication & System Architecture Diagram
Click to inspect
Edge Middleware route guards vs. Node.js Server Actions & decoupled HMAC member sessions.
DimensionArchitectural Analysis
ProblemNext.js 14 Middleware executes on Vercel's lightweight Edge runtime, which does not support Node.js dependencies like Prisma ORM or bcrypt.
CauseMerging credentials authentication (bcrypt hashing + Prisma queries) with route guards in a single auth module causes Edge Middleware to fail compilation or crash at runtime.
ImpactRoute guards break, session cookies cannot be checked at the edge, or staff accounts are left exposed.
SolutionSplit auth into two decoupled layers: auth.config.ts (edge-safe route guards, JWT payload parsing, anti-loop cookie purge) and auth.ts (full Node.js credentials validation for server actions). Gym member portal sessions are separately handled via standalone SHA-256 HMAC cookies.
RBAC EnforcementServer-side source of truth in permissions.ts. Client-side button hiding is purely UX; every mutation verifies role claims independently before execution.
Key Architectural Takeaway
FitKalp CRM uses Next.js 14 App Router with React Server Components. The core architectural challenge was splitting auth across runtimes: Edge Middleware cannot import bcrypt or Prisma without crashing. We separated auth into an edge-safe auth.config.ts for route protection and cookie cleanup, and a Node.js auth.ts for credentials validation. Member self-service uses completely independent HMAC-signed cookies, keeping member traffic separated from staff auth.
  • auth.config.ts — edge-safe. Contains route protection logic, JWT/session callbacks, no Prisma, no bcrypt. The middleware imports only this.
  • auth.ts — full Node.js. Contains the credentials provider that does the actual DB lookup and bcrypt comparison. Server actions import this.

Role-Based Access: Three Roles, Central Enforcement

There are three staff roles: Owner, Admin, and Staff. All permission logic is centralised in permissions.ts:

CapabilityOwnerAdminStaff
Add / Edit Members
Renew Subscriptions
Log Payments per Member
Create / Edit Packages
View Gym-Wide Revenue & Payments
Access Expenses & Financial Reports
Delete Members
Manage Staff Accounts
lib/auth/permissions.ts
export function canViewFinancials(role: Role): boolean {
  return role === "OWNER";
}
export function canManagePackages(role: Role): boolean {
  return role === "OWNER" || role === "ADMIN";
}
export function canManageMembers(role: Role): boolean {
  return role === "OWNER" || role === "ADMIN" || role === "STAFF";
}

The server is the source of truth. Every server action and API route checks permissions before executing. The UI mirrors these checks to hide buttons — but that's only UX, never security. A missing button on the client means nothing if the server doesn't also enforce it.

Member Management & Operations Lifecycle

Atomic Onboarding, Derived Statuses, and QR Check-ins

Adding a member creates three things at once: the member record, their first subscription (package + start/end dates), and optionally an initial payment. I handled this in a single server action wrapped in withTenant, so either everything commits or nothing does. No partial states.

Subscription status is computed dynamically at read time from the endDate:

Infographic 03 — Operational Lifecycle & State Engines
Gym Operations Lifecycle & State Engines Diagram
Click to inspect
Public QR intake, atomic ACID enrollment, anti-stale derived subscription status, and contactless QR attendance.
DimensionArchitectural Analysis
ProblemGym front desks suffer from paper clipboard delays, orphaned database records on enrollment failure, and stale subscription statuses.
CauseTraditional CRMs store subscription status as an enum updated by background cron jobs (which silently fail), and enroll members across fragmented queries.
ImpactExpired members slip through turnstiles; failed payment attempts create ghost member profiles; receptionists waste hours manually re-typing walk-in notes.
Solution1. Public QR Intake: Reception desk QR (/register/{token}) creates digital Visitor CRM leads with 1-click conversion to full member. 2. Atomic Enrollment: Member profile, Subscription package, and initial Payment commit in a single withTenant ACID transaction (all or nothing). 3. Anti-Stale Derived Status: Status is derived on every read from endDate: Active (>7d), Expiring Soon (0–7d with 1-click WhatsApp renewal), Expired (<0d). 100% accurate, zero cron jobs. 4. Resilient Omnichannel Egress: Attendance checks revokable UUID tokens; email and SMS alerts fire in parallel via Promise.all with isolated try/catch.
Key Architectural Takeaway
FitKalp's operational lifecycle is built on four core resilience patterns: reception QR codes capture walk-in leads directly into the CRM for 1-click enrollment; member onboarding runs in an atomic ACID transaction to eliminate orphaned records; subscription status is derived on every read from the end date to prevent cron job staleness; and contactless QR attendance with isolated notification boundaries ensures external messaging latency never blocks core check-ins.
  • Active (🟢) — more than 7 days remaining.
  • Expiring Soon (🟡) — within the next 7 days, with 1-click WhatsApp renewal action.
  • Expired (🔴) — end date has passed.

I deliberately never store a status field in the database. Stored status gets stale — if you store 'Active' and a cron job fails, members stay Active forever. By computing from endDate on every read, the status is mathematically guaranteed to be accurate.

For check-ins, each member receives a unique revokable token encoded into a QR code. When scanned at the front desk, the token is verified and attendance is recorded instantly with zero manual typing.

Finance Module, Custom Invoicing & Serverless PDF Pipeline

Payments are always linked to a subscription. You record amount, method (Cash, UPI, Card, Bank Transfer, Cheque), and an optional note. The system tracks priceAtPurchase, totalPaid, and derives pendingAmount — allowing split installments and partial payments naturally.

For invoices, gym owners need their custom logo, colors, signature, and payment terms on every document. I designed a 3-tier fallback template system with Prisma JSON columns and bundled Puppeteer with @sparticuz/chromium to generate PDFs inside serverless memory limits (<50MB stripped binary).

Infographic 04 — Financial Ledger & PDF Pipeline
Financial Ledger & Serverless PDF Pipeline Diagram
Click to inspect
Native split payment math, 3-tier invoice branding fallback, serverless Chromium PDF rendering, and composite 0–100 Gym Health Index.
DimensionArchitectural Analysis
ProblemRendering pixel-perfect branded PDFs in serverless functions crashes due to memory/disk limits, and missing branding templates cause checkout document errors.
CauseFull Chrome binaries exceed 500MB (far above Vercel/AWS Lambda ephemeral limits), and gym owners often start invoicing before configuring logos or signatures.
ImpactServerless timeouts, 500 errors on receipt downloads, and distorted invoice layouts that damage customer trust.
Solution1. Serverless Chromium: Use @sparticuz/chromium (<50MB stripped binary) with Puppeteer to render React Tailwind components into in-memory PDF buffers with sub-second cold starts. 2. 3-Tier Fallback Priority: Resolver checks isDefault = true ➔ Latest Gym Template ➔ Gym Profile Metadata. Invoices and receipts never crash, even on unconfigured accounts. 3. Ledger Math & Gym Health: Subscriptions track priceAtPurchase while payments accumulate into totalPaid (native split dues). 14 expense categories feed real-time P&L, while an algorithmic 0–100 Gym Health Score rates business vitality across 5 weighted pillars.
prisma/schema.prisma
model InvoiceTemplate {
  templateId      Int       // 1 or 2 (design layout)
  branding        Json?     // { logoUrl, primaryColor }
  defaultSettings Json?     // { defaultTerms, signature, paymentInformation }
  isDefault       Boolean
}
Key Architectural Takeaway
FitKalp's finance engine combines an immutable ledger with automated serverless document generation. Subscriptions track purchase price while payments accumulate, computing pending dues dynamically to support split installments. For invoicing, full Chrome binaries exceed serverless limits, so we bundle @sparticuz/chromium with Puppeteer to convert React components into in-memory PDFs in milliseconds. A 3-tier fallback ensures zero broken invoices, while a 0-100 Gym Health Score synthesizes retention, profit margin, collection rate, equipment uptime, and member feedback into an actionable metric.

Member Self-Service Portal & Decoupled Auth

The member portal (/member) uses a completely separate auth system from the staff area. Members don't have accounts in the User table — they authenticate with their member credentials (phone + password or member number + PIN).

I implemented a custom HMAC-signed cookie session with SHA-256 and verified with crypto.timingSafeEqual on every request to prevent timing attacks:

lib/auth/member-session.ts
export function createMemberToken(payload: MemberSessionPayload): string {
  const data = Buffer.from(JSON.stringify(payload)).toString("base64url");
  const signature = signPayload(data);  // SHA-256 HMAC
  return `${data}.${signature}`;
}

This guarantees that member traffic is cleanly isolated from staff JWT sessions, with zero risk of privilege escalation.

SaaS Tier System & Commercial Pricing

Different gyms get different features based on their subscription plan across three tiers (Starter, Growth, Scale):

PlanMonthly PricingActive Members LimitStaff Accounts
Starter₹2,999 / mo250 Members3 Accounts
Growth₹5,999 / mo1,000 Members10 Accounts
Scale₹9,999 / mo5,000 Members50 Accounts

Instead of checking plan names with fragile if (plan === 'GROWTH') statements, access is decomposed into 14 granular entitlement keys (members, staff_accounts, payments, attendance, visitor_crm, invoices, advanced_analytics, expense_tracking, notifications, advanced_finance, equipment, maintenance, custom_branding, public_portal).

The entitlement resolver is a pure in-memory function that evaluates gym account state, temporary manual overrides, and plan bundles with zero cache drift.

Architectural Decision: SaaS Entitlements, Pure Resolver & Super Admin

Infographic 05 — Commercial Tiering & Super Admin
Commercial Tiering & Super Admin Entitlements Diagram
Click to inspect
3 SaaS tiers, 14 granular entitlement flags, pure-function in-memory resolver, and platform Super Admin RLS bypass.
DimensionArchitectural Analysis
ProblemHardcoded plan checks (if (plan === 'GROWTH')) create fragile code, while database-stored feature flags suffer from cache staleness during plan changes.
CauseConflating commercial subscription plans with operational code capabilities, coupled with lacking a safe cross-tenant administrative control plane.
ImpactPricing tier adjustments require code deployments; sales cannot grant temporary trial access without altering plans; platform admins have no safe mechanism to manage tenant databases.
Solution1. 14 Granular Feature Keys: Pricing plans (Starter ₹2,999, Growth ₹5,999, Scale ₹9,999) simply bundle sets of 14 atomic feature flags. 2. Pure In-Memory Resolver: hasEntitlement(gymId, key) checks: Is Gym Active? ➔ Does a Manual Override exist (ENABLED, DISABLED, TEMPORARY_GRANT with expiry)? ➔ Does the Plan include the key? In-memory evaluation guarantees zero cache desync. 3. Super Admin RLS Bypass: /admin route is guarded at the edge. Database mutations run via withSuperAdmin(), executing SET LOCAL app.is_super_admin = true within a single Prisma transaction to provision gyms and adjust billing with zero connection pool bleed.
Key Architectural Takeaway
FitKalp operates a multi-tier SaaS model across three plans. Instead of checking plan names in code, we decomposed access into 14 atomic entitlement keys evaluated by an in-memory pure function. It checks gym account activity, then manual platform overrides for temporary trials or grace periods, and finally the plan bundle. Because the function is pure, there is zero cache drift. For multi-tenant administration, the /admin dashboard uses withSuperAdmin() to set a transaction-scoped RLS bypass, enabling cross-tenant gym provisioning and diagnostics without leaving residual permissions in the connection pool.

Key Technical Learnings & Closing

Design your data model before writing UI: Getting the subscription-payment relationship right took three iterations, but saved dozens of hours downstream.

Server Components eliminate frontend data-fetching boilerplate: The App Router fetches server data directly, eliminating useEffect loading spinners and client-side waterfall requests.

RLS requires strict transaction management: The withTenant wrapper solves connection pool bleed at the database kernel level.

Permission checks belong on the server: UI button hiding is UX; every server action must independently verify claims.

Notification failures must never roll back core transactions: Best-effort notifications in isolated error boundaries protect the primary database state.

FitKalp CRM is a complete, production-ready gym management system. It's not trying to do everything — it's trying to do the right things, with proper engineering reasoning behind each decision.
Himanshu Soni
Himanshu Soni
Software Engineer & Creator • Building full-stack systems, AI agents, and developer tools.