How I Built ERPX, a Multi-Tenant ERP SaaS
Multi-tenancy, double-entry accounting, field-level permissions, subscriptions and an AI copilot - a field report from architecting ERPX, my first SaaS ERP, with NestJS, PostgreSQL and React.
ERPX is the largest system I have built, and the first product I would genuinely call software-as-a-service. It is a multi-tenant enterprise ERP - think accounting, sales, procurement, inventory, CRM, HR and payroll living in one application - where many organizations run at the same time, each inside its own isolated workspace.
This post is the engineering story: the stack decisions, the multi-tenancy design, the modules that are actually real, and the lessons that only show up when you build something this wide.
Why an ERP at all
I came to development from a commerce background, which means I think about products as systems that move money and data around, not just screens. Most projects teach you one vertical slice. An ERP forces you to model an entire business - and every module has to agree with every other module.
A todo app teaches you CRUD. An ERP teaches you that one table you ignored will eventually become someone's balance sheet.
Choosing the stack
The stack was chosen for long-term maintainability over hype. A strongly-typed backend with a schema you can read, a frontend where routes and types are generated, and a database where relationships are first-class.
| Layer | Choice | Why |
|---|---|---|
| Frontend | React 19 + TanStack Router / Query | Type-safe file-based routes, server state, one framework for 76 screens |
| Backend | NestJS (Node.js) | Modular structure that scales across many domains |
| Data | PostgreSQL + Prisma | Relational integrity for ledgers and inventory; readable schema |
| Caching & limits | Redis | Rate limiting and AI semantic caching |
| Payments | Razorpay + Stripe | Two production gateways behind one interface |
| Delivery | Docker → Render | Multi-stage images, containerised production |
The hardest part: multi-tenancy
Multi-tenant means one codebase, one database, many organizations - and the single rule that no tenant can ever see another tenant's data. I enforce it in two layers. First, almost every table carries an organizationId and is indexed on it. Second, a global tenant-isolation interceptor compares the organization in the signed JWT with the organization in the URL, and returns 403 on any mismatch.
// Tenant isolation, enforced at the API boundary
if (user.org !== orgId && !isSuperAdmin(user.email)) {
throw new ForbiddenException('tenant mismatch');
}
// ...and every query stays scoped
return this.prisma.account.findMany({
where: { organizationId: orgId },
});Tenant isolation is only trustworthy if it happens automatically. Interceptors and guards that run on every request beat remembering to add a where clause in every service.
Real modules, not mockups
- Accounting - chart of accounts, account groups, cost centres, fiscal years, double-entry vouchers, journal entries, ledgers and statutory books.
- Sales & orders - customers, quotations, sales orders, invoices and payments that move records forward instead of re-entering them.
- Procurement - vendors, purchase orders with approvals, goods receipts, and vendor bills that post to the ledger when approved.
- Inventory - products, categories, units, warehouses, a stock ledger, adjustments, inter-warehouse transfers and low-stock alerts.
- CRM - leads, companies, contacts, pipelines and deals, plus activities and timelines.
- HR & payroll - employees, departments, teams, attendance, leave, salary structures and payroll runs.
- Reports - executive, sales, inventory, procurement, accounting and HR dashboards, plus GST reports and CSV/Excel export.
Accounting that actually balances
Accounting was the module that separated ERPX from a generic admin panel. Every money movement is a double-entry posting: debit and credit sides, ledger lines, and fiscal periods that can be opened, locked and closed. Sales invoices, vendor bills, payments and journal entries all flow into the same ledger, which is what makes the trial balance, profit and loss, balance sheet and cash-flow reports trustworthy.
Access control with field-level permissions
Beyond basic roles, ERPX models granular permissions - lead:create, invoice:void, payment:refund - plus field-level permissions and record scopes. A role can see a record but not edit certain fields, and the same model drives the UI: sidebar items, buttons and route guards all read from the same permission list the API enforces. There is no client-side-only security.
Auth, MFA and email OTP
Authentication is JWT access and refresh tokens with rotation and reuse detection, argon2 password hashing, database-backed sessions, and RFC 6238 TOTP multi-factor authentication with backup codes. Email verification, password reset and invitations all go through six-digit OTP codes sent with SMTP - deliberately stateless and simple to reason about.
Subscriptions and payments
Because ERPX is SaaS, it has a plan layer - FREE, STARTER, PRO and ENTERPRISE - with features, entitlements and usage counters, plus coupons. Checkout runs through Razorpay or Stripe behind a single provider interface, and subscription activation only happens after a signature-verified webhook, never from an unverified client redirect.
The AI layer
The product includes an AI copilot in the UI and insight cards on the dashboard. Underneath is a provider-agnostic AI gateway that can route to OpenAI, Gemini, Claude, Ollama, Azure OpenAI or AWS Bedrock, with domain agents, RAG knowledge indexing and a semantic cache. The AI never bypasses permissions - it answers over the same role-scoped data the user can already see.
Testing and observability
The backend carries hundreds of test specs across auth, billing, accounting, reporting and the AI subsystems, and the whole API is documented with Swagger. Redis-backed rate limiting, Helmet security headers, CSRF protection, pino logging and OpenTelemetry tracing were part of the build from day one - observability is not a feature you can add after launch.
What I would tell myself next time
- Design the schema like your life depends on it - 140 models later, migrations are expensive.
- Put tenancy and permissions in middleware, not in developer habits.
- Make money movement a single path through the ledger, even when modules disagree.
- Ship the boring core first - auth, tenants, RBAC - because everything else sits on it.
ERPX taught me more about architecture than any tutorial ever could. If you want the deep technical tour with the module breakdown and the system diagram, the full case study walks through every part of the build.