
Education SaaS
Problem
Quran schools and madrasas typically run on paper registers and spreadsheets: admissions in one file, attendance in another, tuition and fee waivers in a third. Records fragment, billing disputes multiply, and parents get no window into their child's progress. I built Madrassa LMS as one multi-tenant system that runs the whole school - and can run many schools at once without their data ever mixing.
My scope
End to end: the NestJS API, the React web app, the 61-table PostgreSQL data model, auth and roles, billing, reports and exports, the guardian portal, and deployment. A shared contracts package defines every request and response shape once, and the API validation, the web forms and the OpenAPI documentation all derive from it.
System architecture
A Bun monorepo with two apps. The API is NestJS 12 on Express with a Swagger/OpenAPI surface. The web app is a React 19 SPA on Vite 8 with TanStack Router and TanStack Query, Tailwind CSS v4 and shadcn/ui components. PostgreSQL stores everything through Drizzle ORM. Better Auth handles accounts with Redis-backed secondary storage, and BullMQ workers on Redis send the email (verification, invitations, receipts, reminders) through Nodemailer with Handlebars templates.
Feature coverage: admissions and a full student lifecycle (enroll, transfer, pause, withdraw, graduate, re-enroll), families and guardians, classes and programs, attendance, Hifz and learning progress, tuition billing (charges, plans, transactions, waivers), a waitlist, announcements and meetings, and reports with CSV, XLSX and PDF exports. Parents use a dedicated guardian portal; school roles are owner, admin and teacher.
Key decisions
- One contracts package, not duplicated types. Every Zod request, response and event shape lives in a shared package; the API, the web client and the forms all consume the same source of truth. It is the pattern I lay out in Zod as a contract.
- Tenant isolation as a platform rule. Every query is scoped to its school, and the platform admin who manages schools can see counts and identities only - never student, family or payment details.
- Student lifecycle as an explicit state machine. Transfers and status changes are auditable commands with effective dates, reasons and approver names, not free-form status edits. A transfer into a full class requires a written override reason.
- Email queued, not sent inline. Queueing through BullMQ keeps request latency flat and makes failed sends retryable instead of lost.
Stack
- Frontend - React 19, Vite 8, TypeScript, TanStack Router and Query, Tailwind CSS v4, shadcn/ui, Zustand, React Hook Form
- Backend - NestJS 12, Express, PostgreSQL with Drizzle ORM, Better Auth, BullMQ on Redis
- Contracts - Shared Zod schemas for API validation, OpenAPI and web forms
- Deploy - Docker, Dokploy
I recorded a full product tour of the system: watch it on YouTube.