TenancyJS
Set up your stack

Next.js + Sequelize setup

Wire fail-closed multi-tenancy into a Next.js 16 App Router + Sequelize app - a copy-pasteable setup-agent prompt, plus the manual walkthrough.

tenancy init doesn't scaffold Next.js yet, so this stack is a manual wire-up - a few files. This page gives you two ways to do it: hand the setup-agent prompt to your AI assistant, or follow the manual walkthrough. Either way, finish by running the two-tenant test - with fail-closed isolation you verify, you don't trust.

This is row-level on PostgreSQL, backed by forced RLS. The Sequelize adapter is a callback facade - you query the protected model, never the native Sequelize model - and PostgreSQL forces the policy underneath, so the database itself rejects a cross-tenant read even if the facade were bypassed.

Running MySQL? This page is PostgreSQL (row-level + forced RLS). MySQL has no schema-per-tenant and its row-level mode is experimental and facade-only - use database-per-tenant on MySQL instead.

Setup-agent prompt

Paste this into an assistant that can edit your repo and run commands (Claude Code, Cursor, …). It executes in order and verifies each step. It's pinned to the real API, so it won't invent factory names.

# TenancyJS Setup Agent - Next.js 16 + Sequelize (row-level, PostgreSQL)

You are a setup agent. Add fail-closed multi-tenancy to this Next.js App Router + Sequelize project with
TenancyJS. Execute every step in order. After each step, verify it applied before continuing. If a step
does not apply, say so and continue. Do NOT invent API names - if unsure, fetch the linked doc.

Source of truth (fetch if a detail is missing, do not guess):
- https://tenancyjs.pages.dev/docs/adapters/sequelize
- https://tenancyjs.pages.dev/docs/integrations/nextjs
- https://tenancyjs.pages.dev/docs/concepts/limitations
- https://tenancyjs.pages.dev/docs/guides/testing-isolation

## Pre-flight
- Run `node -v`; abort if it is not >= 24.
- Confirm `next` (v16) and `sequelize` are in package.json. Abort if this is not a Next.js App Router
  + Sequelize project.
- Confirm the database is PostgreSQL. Row-level here REQUIRES forced RLS; MySQL is out of scope.

## Step 1 - Install
Run:
```sh
npm install tenancyjs-core@beta tenancyjs-adapter-sequelize@beta tenancyjs-integration-next@beta tenancyjs-identifiers@beta sequelize pg
```
Verify all six appear in package.json dependencies.

## Step 2 - Models
In `lib/models.ts`, define a Sequelize model for each tenant-scoped table (e.g. `Post`) with a non-null
`tenantId` attribute mapped to a `tenant_id` column, plus a `Tenant` model (`id` primary key,
`status` not null default `'active'`). Export the `sequelize` instance and the models. Verify the file
compiles.

## Step 3 - Forced-RLS migration (do NOT skip)
This is the backstop that makes row-level safe. Write a migration run by an OWNER role that:
- Creates the tenant tables (owner owns them).
- Creates a NON-OWNER runtime role for the app connection - it must NOT be the table owner, must NOT
  have BYPASSRLS, and must NOT be a superuser (owners and superusers ignore RLS).
- Runs `ALTER TABLE posts ENABLE ROW LEVEL SECURITY;` and `ALTER TABLE posts FORCE ROW LEVEL SECURITY;`
  for every tenant table.
- Creates policy `posts_tenant_isolation` USING/WITH CHECK
  `tenant_id = current_setting('tenancyjs.tenant_id', true) OR current_setting('tenancyjs.is_central', true) = 'true'`.
- Grants the runtime role table privileges but not ownership.
Point `DATABASE_URL` at the NON-OWNER runtime role. Verify the role is non-owner and RLS is FORCED.

## Step 4 - The Sequelize adapter (ormTenancy)
Create `lib/db.ts`. It must:
- Construct ONE `TenancyManager<Tenant>` and export it.
- Build the `sequelize` instance from `DATABASE_URL` (the runtime role) with `dialect: "postgres"`.
- Create the adapter with `createSequelizeTenancy({ manager, sequelize, tenantModels: [{ model: Post,
  table: "posts", tenantAttribute: "tenantId", tenantColumn: "tenant_id" }] })` and export it as
  `ormTenancy`.
- Do NOT export the native Sequelize model. The app queries ONLY through `ormTenancy.run((client) =>
  client.model(Post)...)` - the protected facade, never the native model.

## Step 5 - Resolver + Next runtime (nextTenancy)
In `lib/tenancy.ts`:
- Build a `TenantResolutionChain` with `new HeaderTenantResolver({ headerName: "x-tenant-id" })` and a
  store whose `find(identifier)` looks the tenant up and returns `[{ tenant: { id }, status }]` (or `[]`).
- Create `createNextTenancy({ manager, resolver })` and export it as `nextTenancy`. Name it distinctly
  from `ormTenancy` - they are different objects.

## Step 6 - Scope the routes (Node runtime)
For every Route Handler / Server Action that touches tenant data:
- Wrap it: `export const GET = nextTenancy.withRouteHandler(async () =>
  ormTenancy.run((c) => c.model(Post).findAll({ status: "open" })))`. Server Actions use
  `nextTenancy.withServerAction`.
- Add `export const runtime = "nodejs"` - the adapter runs on Node, not the Edge.
- REMOVE any manual `where tenantId` filters; the scope + policy inject isolation.

## Step 7 - Edge hint (optional)
If you resolve identity from the host/subdomain, add `middleware.ts` using `withNextTenantHint` from
`tenancyjs-integration-next/edge` to carry identity across the Edge→Node boundary.

## Step 8 - Central (cross-tenant) work
For admin/cross-tenant reads, wrap the work in
`manager.runInCentralContext(() => ormTenancy.run((c) => c.model(Post).findAll({})))`. This sets
`tenancyjs.is_central`, which the policy honours. Unscoped tenant access must throw.

## Step 9 - Cache safety
Any Next cache (`fetch` cache, `unstable_cache`, route segment cache) that wraps tenant data MUST be
keyed by tenant id, or left uncached. A shared cache entry across tenants is a leak the policy cannot
catch.

## Step 10 - Validate + prove it
- Call `await ormTenancy.validate()` at boot; it confirms the runtime role is non-privileged and RLS
  is enabled + forced before serving. Abort if it throws.
- Create a two-tenant test: write a row under tenant A and tenant B with the SAME primary key, assert a
  scoped read as A never returns B's row, AND assert a RAW query as the runtime role with NO GUC set
  returns 0 rows (proof the policy - not just the facade - isolates). Follow
  https://tenancyjs.pages.dev/docs/guides/testing-isolation.
- `npm run build`, boot the app, run the test, report the result.

Report a summary of every file created/changed and any issue found.

AI wiring is a starting point, not proof. The only thing that demonstrates isolation is the two-tenant colliding-id test in Step 10 - including the raw-as-runtime-role read that must return 0 rows.

Manual walkthrough

Prefer to wire it yourself? The same steps, with the code.

Install

npm install tenancyjs-core@beta tenancyjs-adapter-sequelize@beta tenancyjs-integration-next@beta tenancyjs-identifiers@beta sequelize pg

Models - a non-null tenantId

Each tenant-scoped model keeps a non-null tenantId attribute mapped to the tenant_id column the policy reads.

lib/models.ts
import { Sequelize, DataTypes, Model } from "sequelize";

export const sequelize = new Sequelize(process.env.DATABASE_URL!, {
  dialect: "postgres",
});

export class Post extends Model {}
Post.init(
  {
    id: { type: DataTypes.TEXT, primaryKey: true },
    tenantId: { type: DataTypes.TEXT, field: "tenant_id", allowNull: false },
    title: { type: DataTypes.TEXT, allowNull: false },
    status: { type: DataTypes.TEXT, allowNull: false, defaultValue: "open" },
  },
  { sequelize, tableName: "posts", timestamps: false },
);

Force RLS in a migration (the backstop)

An owner role installs the tables and policy; the app connects as a non-owner runtime role that cannot bypass RLS. This is what makes the facade safe.

migrations/0001_force_rls.sql
-- Run as the OWNER role.
CREATE ROLE app_runtime LOGIN PASSWORD '...'; -- NOT owner, NOT superuser, NOT BYPASSRLS
GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO app_runtime;

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts FORCE  ROW LEVEL SECURITY;

CREATE POLICY posts_tenant_isolation ON posts
  USING (
    tenant_id = current_setting('tenancyjs.tenant_id', true)
    OR current_setting('tenancyjs.is_central', true) = 'true'
  )
  WITH CHECK (
    tenant_id = current_setting('tenancyjs.tenant_id', true)
    OR current_setting('tenancyjs.is_central', true) = 'true'
  );

Point DATABASE_URL at app_runtime, never the owner.

The Sequelize adapter - ormTenancy

The manager and the callback-facade adapter live here. The app never touches the native Sequelize model.

lib/db.ts
import { TenancyManager, type TenantRecord } from "tenancyjs-core";
import { createSequelizeTenancy } from "tenancyjs-adapter-sequelize";
import { sequelize, Post } from "./models";

interface Tenant extends TenantRecord {
  readonly id: string;
}

export const manager = new TenancyManager<Tenant>();

// Runtime role only. Query through the facade, never Post.findAll directly.
export const ormTenancy = createSequelizeTenancy({
  manager,
  sequelize,
  tenantModels: [
    {
      model: Post,
      table: "posts",
      tenantAttribute: "tenantId",
      tenantColumn: "tenant_id",
    },
  ],
});

// Fail closed at boot: proves non-privileged role + enabled & forced RLS + policy.
await ormTenancy.validate();

Resolver + Next runtime - nextTenancy

lib/tenancy.ts
import { createNextTenancy } from "tenancyjs-integration-next";
import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";
import { manager } from "./db";
import { lookupTenant } from "./tenants";

const resolver = new TenantResolutionChain({
  resolvers: [new HeaderTenantResolver({ headerName: "x-tenant-id" })],
  store: {
    async find(identifier) {
      const tenant = await lookupTenant(identifier.value);
      return tenant ? [{ tenant: { id: tenant.id }, status: tenant.status }] : [];
    },
  },
});

// Distinct from ormTenancy - different object, different job.
export const nextTenancy = createNextTenancy({ manager, resolver });

Scope your routes (Node runtime)

nextTenancy.withRouteHandler opens the scope; ormTenancy.run queries through the facade. No manual tenantId filter - the scope sets the GUC and the policy enforces it.

app/posts/route.ts
import { nextTenancy } from "@/lib/tenancy";
import { ormTenancy } from "@/lib/db";
import { Post } from "@/lib/models";

export const runtime = "nodejs"; // the adapter runs on Node, not the Edge

export const GET = nextTenancy.withRouteHandler(async () =>
  Response.json(
    await ormTenancy.run((client) => client.model(Post).findAll({ status: "open" })),
  ),
);

Server Actions wrap the same way with nextTenancy.withServerAction.

Edge hint (optional)

Resolving identity from the host/subdomain? Carry it across the Edge→Node boundary:

middleware.ts
import { NextResponse } from "next/server";
import { withNextTenantHint } from "tenancyjs-integration-next/edge";

export function middleware(request: Request) {
  const headers = withNextTenantHint(request);
  return NextResponse.next({ request: { headers } });
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Prove isolation

Write the two-tenant colliding-id test: same primary key under A and B, assert a scoped read as A never returns B's row, and assert a raw query as the runtime role with no GUC set returns 0 rows - that last one proves the policy, not just the facade, isolates.

Cross-tenant admin work runs in an explicit central scope - manager.runInCentralContext(() => ormTenancy.run((c) => c.model(Post).findAll({}))). Next caches around tenant data must be tenant-keyed or uncached; a shared entry is a leak the policy can't catch.

Full lifecycle and the security boundary: Next.js integration · Sequelize adapter · Limitations.

On this page