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:


| Dimension | Architectural Analysis |
|---|---|
| Problem | In a shared PostgreSQL database, pooled connections in Supabase PgBouncer risk cross-tenant data bleed. |
| Cause | Session-level GUC variables (SET app.gym_id = 'A') persist on the underlying TCP socket across recycled connections. |
| Impact | If Gym B acquires an unrecycled socket previously tagged with Gym A's ID, Gym B could view Gym A's sensitive members and revenue. |
| Solution | The 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. |
| Resilience | Automatic 3-attempt exponential backoff retry loop (100ms → 200ms → 400ms) prevents 500 errors during peak morning check-in spikes. |
export function withTenant<T>(
gymId: string,
fn: (tx: Prisma.TransactionClient) => Promise<T>
): Promise<T> {
return withDbContext({ kind: "tenant", gymId }, fn);
}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:


| Dimension | Architectural Analysis |
|---|---|
| Problem | Next.js 14 Middleware executes on Vercel's lightweight Edge runtime, which does not support Node.js dependencies like Prisma ORM or bcrypt. |
| Cause | Merging credentials authentication (bcrypt hashing + Prisma queries) with route guards in a single auth module causes Edge Middleware to fail compilation or crash at runtime. |
| Impact | Route guards break, session cookies cannot be checked at the edge, or staff accounts are left exposed. |
| Solution | Split 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 Enforcement | Server-side source of truth in permissions.ts. Client-side button hiding is purely UX; every mutation verifies role claims independently before execution. |
- 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:
| Capability | Owner | Admin | Staff |
|---|---|---|---|
| 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 | ✅ | ❌ | ❌ |
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:


| Dimension | Architectural Analysis |
|---|---|
| Problem | Gym front desks suffer from paper clipboard delays, orphaned database records on enrollment failure, and stale subscription statuses. |
| Cause | Traditional CRMs store subscription status as an enum updated by background cron jobs (which silently fail), and enroll members across fragmented queries. |
| Impact | Expired members slip through turnstiles; failed payment attempts create ghost member profiles; receptionists waste hours manually re-typing walk-in notes. |
| Solution | 1. 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. |
- 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).


| Dimension | Architectural Analysis |
|---|---|
| Problem | Rendering pixel-perfect branded PDFs in serverless functions crashes due to memory/disk limits, and missing branding templates cause checkout document errors. |
| Cause | Full Chrome binaries exceed 500MB (far above Vercel/AWS Lambda ephemeral limits), and gym owners often start invoicing before configuring logos or signatures. |
| Impact | Serverless timeouts, 500 errors on receipt downloads, and distorted invoice layouts that damage customer trust. |
| Solution | 1. 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. |
model InvoiceTemplate {
templateId Int // 1 or 2 (design layout)
branding Json? // { logoUrl, primaryColor }
defaultSettings Json? // { defaultTerms, signature, paymentInformation }
isDefault Boolean
}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:
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):
| Plan | Monthly Pricing | Active Members Limit | Staff Accounts |
|---|---|---|---|
| Starter | ₹2,999 / mo | 250 Members | 3 Accounts |
| Growth | ₹5,999 / mo | 1,000 Members | 10 Accounts |
| Scale | ₹9,999 / mo | 5,000 Members | 50 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


| Dimension | Architectural Analysis |
|---|---|
| Problem | Hardcoded plan checks (if (plan === 'GROWTH')) create fragile code, while database-stored feature flags suffer from cache staleness during plan changes. |
| Cause | Conflating commercial subscription plans with operational code capabilities, coupled with lacking a safe cross-tenant administrative control plane. |
| Impact | Pricing tier adjustments require code deployments; sales cannot grant temporary trial access without altering plans; platform admins have no safe mechanism to manage tenant databases. |
| Solution | 1. 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 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.”
