Multi-tenancy
NotifyKit has first-class support for multi-tenant applications. Every operation can be scoped by tenantId (or its alias organizationId) and workspaceId. Data isolation is enforced at the framework level — not just by convention.
Pass tenantId / workspaceId with every send() call.
Return the scope from identify() in your handler — all queries filter automatically.
Cross-tenant access returns 403 or filters to empty. No shared state between orgs.
Data isolation
Every inbox, preference, and delivery is scoped by tenant. No shared state between organizations.
Workspace hierarchies
Two-level scoping (org + project) for apps with nested contexts like repos, channels, or boards.
Tenant-level defaults
Org admins control default channel states for their members. Users can still override individually.
Safe migration path
Move from single-tenant to multi-tenant without losing existing data or disrupting active users.
Scoping sends
Pass the tenant/workspace scope with every send:
await notify.send({
recipientId: user.id,
tenantId: org.id, // or organizationId: org.id
workspaceId: workspace.id, // optional
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/posts/42" },
})The scope is stored on the notification record, inbox items, and delivery rows. All subsequent reads are filtered by it.
Scoping the handler
In the client-facing handler, return the scope from identify():
createRouteHandler({
notifykit: notify,
identify: async (request) => {
const session = await auth(request)
if (!session) return null
return {
recipientId: session.user.id,
tenantId: session.organizationId,
workspaceId: session.workspaceId,
}
},
})The handler enforces these scopes on every operation. A user in org A cannot see inbox items or preferences belonging to org B. Cross-tenant requests return 403 or filter to an empty set.
Integration checklist
Every place tenantId must appear. Miss one and you get silent cross-tenant leaks:
| Where | How | If you miss it |
|---|---|---|
send() | Pass tenantId on every call | Records are unscoped — visible to all orgs or invisible to the correct one |
identify() | Return tenantId from session | Handler queries are unscoped — users see cross-org data |
upsertRecipient() | Include tenantId per-org | Preferences and inbox are shared across orgs for the same user |
preferences.update() | Include tenantId in scope | Preference changes in one org bleed into another |
| Unsubscribe config | Scope is encoded in the HMAC token automatically | N/A — handled by the engine if send() has the scope |
| Realtime (SSE) | Scoped via identify() return | Users receive events from other orgs in their stream |
tenantId on send(). The handler scopes reads via identify(), so the inbox appears to work in testing. But without scope on writes, production data lands in an unscoped bucket — invisible to the correctly-scoped handler.Tenant-level preference defaults
Different tenants may want different default channel states. Use tenantDefaults to override app-level defaults per tenant:
const notify = createNotifyKit({
// ...
defaults: {
channels: { inbox: true, email: true },
},
tenantDefaults: async (tenantId) => {
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, tenantId),
})
if (org?.plan === "free") {
return { email: false } // free plans don't get email by default
}
return null // use app defaults
},
})Preference resolution order
The tenant layer sits between notification defaults and user preferences. Tenant admins can override app defaults, but users can still override their tenant's choice:
| Layer | Set by | Example |
|---|---|---|
| 1. App default | Developer | Email on for all notifications |
| 2. Category/Notification | Developer | Marketing emails off by default |
| 3. Tenant | Tenant admin | Free plans: email off |
| 4. User preference | End user | User enables email anyway |
| 5. Required override | Developer | Password resets always deliver |
Mapping your app to tenancy
Use these questions to decide how many scope levels you need:
Can a user be in multiple organizations?
Yes → you need tenantId so each org gets its own inbox and preferences. No → you can skip tenancy entirely.
Are there sub-contexts with separate notification rules?
Yes (e.g. projects, channels, repos) → add workspaceId. No → tenantId alone is enough.
Should users manage preferences per sub-context?
Yes (e.g. mute one project but not another) → workspaceId is essential. No → keep it simple with just tenantId.
| Your app has | Map to | Example |
|---|---|---|
| Organizations/companies | tenantId | Slack workspaces, Linear teams |
| Projects within an org | workspaceId | Vercel projects, GitHub repos |
| Single-tenant (no orgs) | Omit both | Personal apps, single-team tools |
| Both orgs and projects | tenantId + workspaceId | Notion (team → workspace), Figma (org → project) |
Scoping recipients
Recipients can belong to a tenant. This enables the common pattern where one user belongs to multiple organizations with separate notification state per org:
// Same user, two orgs — separate inboxes and preferences
await notify.upsertRecipient({ id: user.id, tenantId: "org_acme", email: user.email })
await notify.upsertRecipient({ id: user.id, tenantId: "org_globex", email: user.email })tenantId from identify() controls which org's data she sees.Workspace scoping in practice
When your app has two levels of hierarchy (org → project, team → channel, company → repository), use workspaceId for the inner scope. This lets users mute one project without affecting their notifications elsewhere in the org.
// Project management app: user gets task notifications per-project
await notify.send({
recipientId: user.id,
tenantId: org.id, // "Acme Inc"
workspaceId: project.id, // "Backend Refactor"
notificationId: "task_assigned",
payload: { taskTitle: "Fix auth bug", assignerName: "Rey" },
})
// Same user, same org, different project:
await notify.send({
recipientId: user.id,
tenantId: org.id, // "Acme Inc"
workspaceId: anotherProject.id, // "Mobile App"
notificationId: "task_assigned",
payload: { taskTitle: "Add dark mode", assignerName: "Sam" },
})Per-workspace preferences
Users can mute notifications for a specific workspace without affecting their preferences in other workspaces within the same org:
// User mutes email for "Backend Refactor" but keeps it on elsewhere
await notify.preferences.update({
recipientId: user.id,
tenantId: org.id,
workspaceId: "proj_backend_refactor",
notificationId: "task_assigned",
channels: { email: false },
})
// "Mobile App" still sends email — workspace preferences are independent
const explanation = await notify.explain({
recipientId: user.id,
tenantId: org.id,
workspaceId: "proj_mobile_app",
notificationId: "task_assigned",
payload: { taskTitle: "Test", assignerName: "Test" },
})
// explanation.channels.email.outcome → "deliver"| Scope combination | Inbox shows | Preferences apply from |
|---|---|---|
tenantId only | All items in that org (all workspaces) | Org-level preferences |
tenantId + workspaceId | Only items in that specific workspace | Workspace-level preferences (falls through to org-level if unset) |
Building a "mute this project" UI
The most common workspace feature is a per-project mute toggle. Here's the pattern — a single button that suppresses all notifications from one workspace:
import { usePreferences } from "@notifykitjs/react"
function MuteProjectButton({ workspaceId, workspaceName }: {
workspaceId: string
workspaceName: string
}) {
const { isEnabled, update } = usePreferences()
const isMuted = !isEnabled("*", "inbox", { workspaceId })
return (
<button onClick={() => update({
notificationId: "*",
workspaceId,
channels: { inbox: !isMuted, email: !isMuted },
})}>
{isMuted ? `Unmute ${workspaceName}` : `Mute ${workspaceName}`}
</button>
)
}notificationId: "*" with a workspaceId disables all notifications for that workspace. The user can still override specific notifications back to "on" if they want — most-specific-wins resolution applies at the workspace level too.Dynamic tenant settings
The tenantDefaults example above uses a static plan check. Real B2B apps need org admins to control notification settings at runtime — without a code deploy. Back the function with a database lookup:
| Approach | When to use | Trade-off |
|---|---|---|
| Static function | Defaults differ by plan tier only | Simple but requires a deploy to change |
| Database-backed | Org admins toggle channels from a settings panel | Flexible but adds a DB query per send |
| Cached database | High-volume sends where the extra query matters | Best of both — stale for up to cache TTL |
const notify = createNotifyKit({
// ...
tenantDefaults: async (tenantId) => {
// Look up this org's admin-configured defaults
const settings = await db.query.tenantNotificationSettings.findMany({
where: eq(tenantNotificationSettings.tenantId, tenantId),
})
if (settings.length === 0) return null // fall through to app defaults
// Merge global ("*") and notification-specific settings
const global = settings.find(s => s.notificationId === "*")
return global?.channels ?? null
},
})Admin endpoint for org settings
Expose an API route where org admins toggle channels for their organization. Individual users can still override via their own preferences — most-specific-wins applies.
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { tenantNotificationSettings } from "@/lib/schema"
import { eq, and } from "drizzle-orm"
export async function POST(request: Request) {
const session = await auth(request)
if (!session?.isOrgAdmin) return Response.json({ error: "Forbidden" }, { status: 403 })
const { notificationId, channels } = await request.json()
await db
.insert(tenantNotificationSettings)
.values({
tenantId: session.organizationId,
notificationId, // "*" for org-wide, or specific ID
channels,
})
.onConflictDoUpdate({
target: [tenantNotificationSettings.tenantId, tenantNotificationSettings.notificationId],
set: { channels },
})
return Response.json({ ok: true })
}function OrgNotificationSettings({ notifications }) {
const orgId = useCurrentOrg().id
async function toggleChannel(notificationId: string, channel: string, enabled: boolean) {
await fetch("/api/admin/notification-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
notificationId,
channels: { [channel]: enabled },
}),
})
}
return (
<div>
<h2>Organization notification defaults</h2>
<p>These apply to all members unless they override in their own settings.</p>
<table>
<thead>
<tr><th>Notification</th><th>Inbox</th><th>Email</th></tr>
</thead>
<tbody>
{notifications.map(n => (
<tr key={n.id}>
<td>{n.description}</td>
<td><input type="checkbox" onChange={e => toggleChannel(n.id, "inbox", e.target.checked)} /></td>
<td><input type="checkbox" onChange={e => toggleChannel(n.id, "email", e.target.checked)} /></td>
</tr>
))}
</tbody>
</table>
</div>
)
}tenantDefaults function runs on every send(). For apps sending 100+ notifications/second, cache the result per tenant with a short TTL (30–60 seconds). Admin changes take effect after the cache expires — acceptable for default-level settings that change rarely.| What the admin controls | What users still control |
|---|---|
| Org-wide default: email on or off for each notification | Individual override: a user can still enable email even if the org turned it off |
| Which channels are available to org members | Per-notification and per-workspace toggles within the available set |
| Global org mute (all channels off during an incident) | Nothing — global off at the tenant layer suppresses everything except required |
email: true in their own preferences. Communicate this in your admin UI: "Members can still enable this individually."Common pitfalls
| Symptom | Root cause | Fix |
|---|---|---|
| User sees notifications from another org | identify() doesn't return tenantId | Always return the active org from the session — don't omit it even if nullable |
| Preferences reset when user switches org | Preferences were written without tenantId scope | Ensure tenantId is included in both send() and the handler's identify() |
| Unsubscribe link works for wrong org | Token signed without tenant scope | The token is bound to the scope at send time — verify your send() includes tenantId |
| User gets notifications for orgs they left | Recipient record still exists for that tenant | Delete or deactivate the scoped recipient when revoking org membership |
| SSE stream shows cross-org events | Realtime subscription not scoped | Confirm identify() returns the scope — the SSE handler uses it to filter events |
Testing tenant isolation
Verify isolation in your test suite before shipping. This pattern catches the most common multi-tenant bugs — cross-org data leaks, missing scope in handlers, and broken preference scoping:
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { createHandler } from "@notifykitjs/core"
const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
})
// Send to same user in two different orgs
await notify.upsertRecipient({ id: "alice", tenantId: "org_acme", email: "a@test.com" })
await notify.upsertRecipient({ id: "alice", tenantId: "org_globex", email: "a@test.com" })
await notify.send({
recipientId: "alice", tenantId: "org_acme",
notificationId: "comment_mentioned",
payload: { actorName: "Bob", postUrl: "/posts/1" },
})
// Handler scoped to org_globex should see nothing
const globexHandler = createHandler(notify, {
identify: async () => ({ recipientId: "alice", tenantId: "org_globex" }),
})
const res = await globexHandler(new Request("http://localhost/api/notifykit/inbox"))
const { data: items } = await res.json()
expect(items).toHaveLength(0) // ✓ Acme notification not visible
// Handler scoped to org_acme sees the item
const acmeHandler = createHandler(notify, {
identify: async () => ({ recipientId: "alice", tenantId: "org_acme" }),
})
const acmeRes = await acmeHandler(new Request("http://localhost/api/notifykit/inbox"))
const { data: acmeItems } = await acmeRes.json()
expect(acmeItems).toHaveLength(1) // ✓ correct isolation| What to assert | Why it matters | Catches |
|---|---|---|
| Inbox returns empty for wrong org | Proves data isolation at the query layer | Missing tenantId filter in adapter |
| Preferences don't bleed across orgs | User can have email off in Acme but on in Globex | Scope missing from preference writes |
| SSE events only fire for the scoped org | Realtime must filter by tenant before broadcasting | Unscoped pub/sub subscription |
| Unsubscribe token rejects wrong tenant | HMAC is bound to scope at sign time | Token forged or replayed across orgs |
Migrating from single-tenant to multi-tenant
If your app already has NotifyKit running without tenancy and you're adding organizations, you need to backfill existing records. The order below is critical — backfill data before deploying scoped handlers, or users will see empty inboxes until the migration catches up.
Call notify.drain() so queued sends land before you start migrating. Anything in-flight won't have a tenantId.
Assign a tenantId to all existing recipients, inbox items, and preferences. Query for tenantId IS NULL after — count must be zero.
Every send must include the scope. Deploy this first — new data goes out scoped while reads still work unscoped.
Deploy the scoped handler. Now reads filter by tenant — safe because all data already has a tenantId from steps 2–3.
Log in as two different orgs and confirm inbox, preferences, and SSE are fully separated.
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { isNull, eq } from "drizzle-orm"
const { recipients, inboxItems, preferences } = notifyKitSchema
// Map each user to their org (your app's logic)
const userOrgs = await db.select({ userId: users.id, orgId: users.organizationId }).from(users)
for (const { userId, orgId } of userOrgs) {
// Backfill recipients
await db.update(recipients)
.set({ tenantId: orgId })
.where(and(eq(recipients.id, userId), isNull(recipients.tenantId)))
// Backfill inbox items
await db.update(inboxItems)
.set({ tenantId: orgId })
.where(and(eq(inboxItems.recipientId, userId), isNull(inboxItems.tenantId)))
// Backfill preferences
await db.update(preferences)
.set({ tenantId: orgId })
.where(and(eq(preferences.recipientId, userId), isNull(preferences.tenantId)))
}| Risk | What goes wrong | Prevention |
|---|---|---|
| Deploy scoped reads before backfill | Users see empty inboxes — handler filters by tenantId but records have NULL | Complete step 2 (backfill) before step 4 (scoped handler). Follow the order exactly. |
| Unscoped records remain | Old inbox items visible to all orgs (or invisible to all) | Query for tenantId IS NULL after step 2 — count must be zero before proceeding |
| Users in multiple orgs | Backfill assigns only one org, orphaning the rest | Create scoped recipient records per org membership, not per user |
| In-flight sends unscoped | Sends queued before migration land without tenantId | Step 1: drain the queue before starting the backfill |
notify.drain() ensures the queue is empty before you start.