Next.js + Prisma setup
Wire fail-closed multi-tenancy into a Next.js 16 App Router + Prisma 7 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. Prisma row-level is facade-enforced (the extension rewrites query arguments; there is no RLS backstop), so the one rule that matters most: only ever expose the extended client - never the base Prisma client.
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 + Prisma 7 (row-level, PostgreSQL)
You are a setup agent. Add fail-closed multi-tenancy to this Next.js App Router + Prisma 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/prisma
- 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, App Router) and `@prisma/client` (v7) are in package.json. Abort if this is not a
Next.js App Router + Prisma project.
- Confirm the database is PostgreSQL.
## Step 1 - Install
Run:
```sh
npm install tenancyjs-core@beta tenancyjs-adapter-prisma@beta tenancyjs-integration-next@beta tenancyjs-identifiers@beta @prisma/adapter-pg
```
Verify all five appear in package.json dependencies.
## Step 2 - Schema
In `prisma/schema.prisma`, ensure:
- A `Tenant` model with `id String @id` and `status String @default("active")`.
- At least one tenant-scoped model with a non-null `tenantId String` and an index on `tenantId`
(e.g. `Post`). If the app already has domain models, add `tenantId` to each tenant-owned one.
Then run `npx prisma generate` (and `npx prisma migrate dev` if you own migrations).
## Step 3 - lib/tenancy.ts (the single source of truth)
Create `lib/tenancy.ts`. It must:
- Construct ONE `TenancyManager<Tenant>` and export it.
- Construct the BASE Prisma client with the Postgres driver adapter:
`new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) })`.
- Create the EXTENDED client:
`basePrisma.$extends(createPrismaTenancyExtension({ manager, tenantModels, centralModels }))`.
Classify EVERY model: each tenant model as `{ tenantField: "tenantId", relationFields: [...] }`,
`Tenant` under `centralModels`. Unknown models are rejected at runtime.
- Build a `TenantResolutionChain` with `new HeaderTenantResolver()` and a store whose `find(identifier)`
looks the tenant up via the BASE client and returns `[{ tenant: { id }, status }]` (or `[]`).
- Export `manager`, `resolver`, and the EXTENDED client as `prisma`. Do NOT export the base client for
app use.
## Step 4 - lib/tenancy/server.ts (the Next runtime)
Create the Next runtime: `export const tenancy = createNextTenancy({ manager, resolver })` (import both
from `../tenancy`). This runs on the Node runtime; the wrapper resolves the tenant from Next's request
`headers()`.
## Step 5 - Scope Route Handlers and Server Actions
For every Route Handler that touches tenant data, wrap it:
`export const GET = tenancy.withRouteHandler(async () => { ... })`. Wrap Server Actions with
`tenancy.withServerAction(action)`. Inside a wrapped handler the extended `prisma` client is auto-scoped
- REMOVE any manual `where: { tenantId }` filters. Leave non-tenant/public routes unwrapped.
## Step 6 - Optional: edge middleware
If you want identity captured at the boundary, add `middleware.ts` using `withNextTenantHint` from
`tenancyjs-integration-next/edge` to encode host + tenant header into a hint the Node handlers read back.
This is optional; the wrapper also resolves from `headers()` directly.
## Step 7 - Central (cross-tenant) work
For admin/cross-tenant work, do NOT leave it unscoped. Wrap it in
`manager.runInCentralContext(() => ...)` explicitly. Unscoped tenant access must throw.
## Step 8 - Lock the boundary
- Ensure the base Prisma client is never imported outside `lib/tenancy.ts` - only the extended client.
- Only work AWAITED inside the wrapped handler is scoped. A streamed body produced after the handler
returns must not do tenant DB work. Any Next cache entry (`fetch`/`unstable_cache`/route cache) that
holds tenant data must be tenant-keyed or left uncached - a shared cache leaks across tenants.
- Do not use raw queries or nested relation writes on the extended client; they are rejected by the
facade. If you need them, that's a database-per-tenant concern (see the adapter doc), not row-level.
## Step 9 - Prove it
Create a two-tenant test (Vitest): create tenant A and tenant B, write a row under EACH with the SAME
primary key, assert querying as A never returns B's row, and assert that a tenant query with no active
scope THROWS. Follow https://tenancyjs.pages.dev/docs/guides/testing-isolation.
## Step 10 - Verify
- `npm run build` - report errors.
- Boot the app (`npm run dev`) and confirm a wrapped route serves the current tenant's rows only.
- Run the two-tenant test and 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 9 - run it before you trust the result.
Manual walkthrough
Prefer to wire it yourself? The same steps, with the code.
Install
npm install tenancyjs-core@beta tenancyjs-adapter-prisma@beta tenancyjs-integration-next@beta tenancyjs-identifiers@beta @prisma/adapter-pgOne lib/tenancy.ts
The manager, the extended Prisma client, and the resolver all live here - one source of truth.
import { TenancyManager, type TenantRecord } from "tenancyjs-core";
import { createPrismaTenancyExtension } from "tenancyjs-adapter-prisma";
import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
interface Tenant extends TenantRecord {
readonly id: string;
}
export const manager = new TenancyManager<Tenant>();
// Prisma 7 needs a driver adapter; keep the BASE client private to this module.
const basePrisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// The ONLY client the app may use. Classify every model.
export const prisma = basePrisma.$extends(
createPrismaTenancyExtension({
manager,
tenantModels: { Post: { tenantField: "tenantId", relationFields: [] } },
centralModels: { Tenant: { relationFields: [] } },
}),
);
export const resolver = new TenantResolutionChain({
resolvers: [new HeaderTenantResolver()], // X-Tenant-Id
store: {
async find(identifier) {
const tenant = await basePrisma.tenant.findUnique({
where: { id: identifier.value },
});
return tenant ? [{ tenant: { id: tenant.id }, status: tenant.status }] : [];
},
},
});The Next runtime
createNextTenancy returns the wrappers. It runs on the Node runtime and resolves the tenant from
Next's request headers().
import { createNextTenancy } from "tenancyjs-integration-next";
import { manager, resolver } from "../tenancy";
export const tenancy = createNextTenancy({ manager, resolver });Scope Route Handlers and Server Actions
Wrap the handler; the wrapper opens the scope and the extended client reads it - no manual tenantId
filter.
import { tenancy } from "@/lib/tenancy/server";
import { prisma } from "@/lib/tenancy";
export const GET = tenancy.withRouteHandler(async () => {
const posts = await prisma.post.findMany(); // scoped to the request's tenant
return Response.json(posts);
});"use server";
import { tenancy } from "@/lib/tenancy/server";
import { prisma } from "@/lib/tenancy";
export const createPost = tenancy.withServerAction(async (title: string) => {
await prisma.post.create({ data: { title } }); // tenantId injected on write
});Only work awaited inside the wrapper is scoped - a streamed body after return must not do tenant DB work, and any Next cache entry holding tenant data must be tenant-keyed or uncached.
Optional: capture identity at the edge
Next runs middleware on the Edge and your handlers on Node. This encodes identity once at the boundary;
the wrapper also resolves from headers() without it.
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 never reads B's row, and assert an unscoped tenant query throws.
Cross-tenant admin work runs in an explicit central scope - manager.runInCentralContext(() => …).
There is no request-controlled central mode; a resolution failure fails closed, never central.
Full lifecycle and the security boundary: Next.js integration · Prisma adapter · Limitations.
Express + Mongoose setup
Wire fail-closed multi-tenancy into an Express 5 + Mongoose (MongoDB) app - a copy-pasteable setup-agent prompt, plus the manual walkthrough.
Next.js + Knex setup
Wire fail-closed multi-tenancy into a Next.js 16 App Router + Knex app - a copy-pasteable setup-agent prompt, plus the manual walkthrough.