Skip to content

AdonisJS 7 · PostgreSQL · React · MCP

Building a Production-Ready Multi-Tenant SaaS Backend with AdonisJS 7 and PostgreSQL

Multi-tenancy, RBAC, CRUD generation, auditing and testing — from a blank AdonisJS project to a reusable SaaS backend foundation.

Private source access, early pricing, and launch updates only. No spam.

See the architecture →

Built once. Reused across every resource.

>230
tests green
29
migrations
3
surfaces, one core
0
type or lint errors

The problem

Every multi-tenant SaaS pays for the same mistakes twice.

The hard part is not building features. The hard part is making it difficult to leak data, grant too much access, or forget audit trail coverage when the next resource ships.

Forgotten tenant filter

One missing WHERE clause is enough to leak data across workspaces.

Self-granting admin

Permissions that are easy to assign manually become easy to abuse.

Audit trail with holes

If logging is optional, it will eventually be incomplete.

Late entitlement logic

Feature access bolted on after the fact tends to become inconsistent fast.

The goal is simple: make the safe path the only realistic path.

Architecture

One path through the stack, reused by every resource.

From database schema to UI, every layer is shaped around the same tenant-aware contract.

  1. migration
  2. schema
  3. model
  4. validator
  5. repository
  6. transformer
  7. policy
  8. controller
  9. route
  10. admin page

Tenant isolation starts in the repository layer.

Controllers and services do not query tenant-scoped tables directly. They go through a repository, and the repository applies the tenant filter before handing the query builder back — so scope is a property of the layer rather than of each call site.

app/repositories/tenant_scoped_repository.ts
export abstract class TenantScopedRepository<Model, Payload> {
  /** Every read enters here. Controllers never touch Model.query(). */
  query() {
    const query = this.model.query()

    if (TenantContext.isUnscoped()) {
      return query // the superadmin, deliberately
    }

    return query.where('tenant_id', TenantContext.currentOrFail().id)
  }
}

This is the base class every resource extends, not a one-off snippet written for the page.

Scaffolding

One command, and the new resource is already safe.

node ace make:resource generates the full resource stack with tenant scoping, validation, permissions, audit hooks, routes, pages, and the related wiring.

Models and migrations

The table, the columns, and the Lucid model over them.

Validators and policies

VineJS validation on input, per-record authorisation on access.

Repository and transformer

Tenant-scoped queries in, a whitelisted response shape out.

Controllers and routes

A REST controller and an Inertia one, both registered and gated.

React pages and admin wiring

List and form pages, with permissions reflected in the UI.

Permission entries and module registration

The new resource appears in the catalog and in the module registry.

The point is not speed alone. The point is removing the chance that the next resource ships half-finished.

Isolation

Tenant isolation you do not have to remember to write.

Isolation lives in the repository layer, so the client never decides tenant scope.

app/repositories/tenant_scoped_repository.ts
protected writeAttributes(payload: Payload, action: 'create' | 'update') {
  const attributes = { ...this.prepare(payload) }

  // Applied after the payload, never from it: a tenantId arriving in a
  // request must never decide which tenant a record lands in.
  if (action === 'create') {
    Object.assign(attributes, this.tenantAttributes())
  }

  return attributes
}

Reads are filtered by the layer. Writes are stamped by it: the active tenant is applied after the validated payload, never taken from it, so a tenantId supplied by a client does not determine where a record lands.

That does not make mistakes unthinkable — a repository can still be bypassed by someone determined to. It makes the safe path the default one, and the unsafe path something you have to go out of your way to write.

True by construction, not by discipline.

Permissions

Permissions the compiler can check.

The permission catalog is typed, centralized, and designed to prevent silent privilege creep.

app/permissions/catalog.ts
export const PERMISSION_CATALOG = {
  tenants: ['read', 'create', 'update', 'delete'],

  // assignRole is deliberately separate from update: editing a colleague's
  // name is a much smaller capability than changing which role they hold.
  users: ['read', 'create', 'update', 'delete', 'assignRole'],

  // Read-only by design. Entries are appended by the application, never
  // edited by hand.
  audit: ['read'],
} as const

router.get('/companies', [CompaniesController, 'index']).use(can('companies.read'))

Permission slugs are not free-form strings. They are part of the contract.

You cannot grant what you do not already hold.

Role editing is constrained by the permissions of the current actor, so the system refuses self-elevation by design. Assigning a role is its own permission, separate from editing a user, because handing someone a role can hand them everything that role carries.

Each workspace narrows its own roles without affecting any other workspace.

Audit

Audit logging comes for free with every future resource.

Create, update, and delete already emit audit entries from the shared base layer, together with the actor, the changed fields and where the request came from.

A resource added next year inherits traceability without anyone remembering to wire it up.

app/repositories/tenant_scoped_repository.ts
async store(payload: Payload) {
  const record = await this.model.create(this.writeAttributes(payload, 'create'))

  if (this.audited) {
    await audit.record({ action: 'created', resource: /* … */ })
  }

  await emitResourceEvent(this.auditResourceType, 'created', record)

  return record
}

Entitlements

A backend that already knows how to be sold.

Modules, entitlements, and feature visibility all come from one registry.

Core, catalog, and CRM modules can be active, read-only, or absent. That drives navigation, routes, permissions, and available actions from a single source of truth.

Active

Full read and write access.

Read-only

Visible, but constrained. Data can still be read and exported.

Absent

Hidden entirely from the product surface — routes, menu and permissions alike.

app/modules/definitions.ts
export const MODULES = [
  { slug: 'core', name: 'Core', core: true, resources: [/* … */] },
  {
    slug: 'crm',
    name: 'CRM',
    resources: [
      {
        slug: 'deals',
        label: 'Deals',
        customFields: true,
        published: true,
        menu: { href: '/deals', icon: 'kanban', order: 30, description: 'Your pipeline' },
      },
    ],
  },
] as const

This is what makes the foundation reusable for SaaS pricing tiers, internal editions, or client-specific deployments. A workspace gains or loses a module as a single record, and the surfaces follow.

A test reads the routes the application actually registered and holds them against this registry, so a module cannot claim a page that has no route, and a sold route cannot skip the entitlement check.

AI clients

An MCP server on the same permissions as everything else.

Claude, Cursor, and other MCP clients can access the CRM through the same tenant isolation, permissions, and audit rules used by the REST API.

~/.claude/mcp.json
{
  "mcpServers": {
    "acme-crm": {
      "type": "http",
      "url": "https://api.acme.com/mcp",
      "headers": { "Authorization": "Bearer oat_…" }
    }
  }
}

The result is an AI-ready backend that does not create a second, weaker security model just for tools. The tool list a client sees is derived from the modules that workspace holds, and a personal API token carries exactly the permissions of the person who created it.

Point Claude, ChatGPT or Cursor at the endpoint and the CRM becomes something a person can talk to — in whatever language they already work in.

  • Summarise our pipeline by stage and flag the deals that have gone quiet.

    get_deal_pipelinelist_activities

  • Add Acme Srl as a company, with Marco Bianchi as its contact.

    create_companycreate_contact

  • Move the Rossi deal to negotiation and log the call I just had.

    update_dealcreate_activity

  • Which products have not been attached to a deal this quarter?

    list_productslist_deals

The same request from a sales viewer returns the pipeline and refuses the two write calls, because the token carries their permissions and the tools check them one by one. Every write that does go through lands in the audit trail with the actor attached, exactly as it would from the admin panel.

One backend. One permission model.One audit trail.

Foundations

The unglamorous parts are where the trust comes from.

PostgreSQL-backed rate limiting

Survives restarts and does not disappear with process memory.

Webhook SSRF protection

Outbound requests are constrained instead of trusted by default.

Runtime custom fields

Custom data can be added without forcing a migration every time.

Transactional provisioning

Tenant setup does not depend on half-finished intermediate state.

Scope

What this is not.

  • Not a billing platform
  • Not a file storage product
  • Not a subdomain-routing demo
  • Not a generic starter with fake examples
  • Not a framework abstraction layer with no real domain logic

It is a real SaaS backend foundation with real modules, real constraints, and real generated resources. Concretely, that means file uploads, billing and subscriptions, teams within a tenant, and host-based subdomain routing are not part of it — the tenant slug is built to carry subdomains, but today the tenant resolves from the authenticated user.

Pricing

One payment, the whole foundation.

Buy the source, use it as the base for your own SaaS, and avoid rebuilding the same multi-tenant primitives from scratch.

Early access

$179$249

Regular price $249, paid once. Limited early access pricing for the first 50 buyers.

Included

  • Private GitHub source access
  • Full project architecture
  • Generator and scaffolding flow
  • Multi-tenant RBAC and audit system
  • React admin surfaces
  • MCP server integration
  • Test suite and migration history

Includes a commercial license for personal and client projects, with no resale of the source. Read the license.

FAQ

Questions worth answering first.

Can I inspect the code before buying?

The architecture write-up and this page quote the source directly, and every snippet here is real. Early buyers get repository access immediately on purchase.

Is this a starter kit or a real product foundation?

A foundation: generated resources, tenant isolation, RBAC, audit and module-driven entitlements, with a test suite that covers them.

Do I need to use the CRM part?

No. The CRM is the reference domain, included to show the foundation carrying real resources. The foundation is built to be reused for other products.

Will it save time?

Yes — it removes weeks of repeated infrastructure work, and lowers the odds of getting tenant isolation wrong while doing it.

Start from the month you were going to lose.

If you are building a B2B SaaS and do not want to re-learn multi-tenancy, RBAC, auditing, and generated CRUD the hard way, FisServer gives you the foundation.

Join the waitlist for launch updates and private source access.