npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tenantkit

v0.2.0

Published

Composable multi-tenancy primitives for modern TypeScript SaaS apps.

Readme

TenantKit

Small multi-tenancy primitives for TypeScript SaaS apps.

Resolve the tenant once, bind it to the current request, and make tenant-scoped data access harder to forget.

flowchart LR
  Request[Incoming request] --> Resolve[Resolve tenant]
  Resolve --> Context[Bind tenant context]
  Context --> Handler[Run app handler]
  Handler --> Data[Scope Drizzle queries]
  Data --> RLS[Optional Postgres RLS]

Why

Tenant checks are easy to miss when every handler carries its own tenantId condition.

Before:

const projects = await db.query.projects.findMany({
  where: and(eq(projects.tenantId, tenant.id), eq(projects.ownerId, userId)),
});

After:

const tenantDb = createTenantDb(db);

const projects = await tenantDb.query(projects).findMany({
  where: eq(projects.ownerId, userId),
});

tenantkit does not replace database constraints or authorization. It gives your app a small, typed path for tenant resolution, request context, and common tenant-scoped queries.

What Tenancy Means

Auth answers: who is this user?

Tenancy answers: which customer, workspace, org, or account is this request operating inside?

flowchart LR
  User[Signed-in user] --> Membership[Org membership]
  Membership --> Acme[Acme workspace]
  Membership --> Beta[Beta workspace]
  Acme --> AcmeData[Acme projects, billing, roles]
  Beta --> BetaData[Beta projects, billing, roles]

A simple auth-gated dashboard is enough when each user only owns their own data. Tenancy matters when the product boundary is bigger than a user: teams, workspaces, customer accounts, custom domains, per-account billing, tenant-specific roles, shared projects, API keys, audit logs, or webhooks.

That distinction shows up quickly:

// User-scoped app
const projects = await db.query.projects.findMany({
  where: eq(projects.ownerId, user.id),
});

// Tenant-scoped app
const projects = await tenantDb.query(projects).findMany({
  where: eq(projects.ownerId, user.id),
});

In the tenant-scoped version, ownerId is still useful. It narrows data inside the tenant. The tenant boundary stays separate and is applied first.

Use tenantkit when your app needs to consistently answer:

  • Which tenant did this request resolve to?
  • Is this tenant active, trialing, or suspended?
  • Which user and role are acting inside this tenant?
  • Which rows, jobs, files, webhooks, or API calls belong to this tenant?

If the app is truly one-user-one-dashboard, tenantkit is probably too much. If the app is B2B, workspace-based, org-based, or customer-domain-based, tenancy is core infrastructure.

Install

Install tenantkit from npm:

bun add tenantkit

For local package testing after a build:

bun pm pack
bun add ./tenantkit-0.1.0.tgz

Install only the optional peers you use:

bun add drizzle-orm hono

Additional adapters use their own peers:

bun add better-auth elysia next

What You Get

  • tenantkit: core tenant types, typed errors, resolvers, and request context.
  • tenantkit/drizzle: PostgreSQL-first helpers for scoped selects, inserts, and RLS context.
  • tenantkit/hono: Hono middleware with tenant variables and lifecycle cleanup.
  • tenantkit/elysia: Elysia plugin with scoped handler fields and lifecycle cleanup.
  • tenantkit/next: Next middleware header forwarding and server-side context helpers.
  • tenantkit/better-auth: Better Auth organization-to-tenant bridge.

Quickstart

1. Define Tenant-Aware Tables

tenantTable keeps tenant-owned tables visually explicit. You still define the tenantId column yourself.

import { text, uuid } from "drizzle-orm/pg-core";
import { tenantTable } from "tenantkit/drizzle";

export const projects = tenantTable("projects", {
  id: uuid("id").primaryKey().defaultRandom(),
  tenantId: uuid("tenant_id").notNull(),
  ownerId: text("owner_id").notNull(),
  name: text("name").notNull(),
});

2. Create A Resolver

Resolvers turn request signals into a Tenant.

import { chainResolvers, customDomainResolver, pathResolver, subdomainResolver } from "tenantkit";

const resolver = chainResolvers(
  customDomainResolver({ lookup: lookupTenantByHost }),
  subdomainResolver({
    rootDomain: "yourapp.com",
    lookup: lookupTenantBySlug,
  }),
  pathResolver({ lookup: lookupTenantBySlug }),
);

Resolvers throw typed tenancy errors for tenant failures. Lookup adapter errors pass through unchanged.

3. Bind Tenant Context

Use the adapter for your framework. Hono example:

import { Hono } from "hono";
import { TenancyError } from "tenantkit";
import { tenancyMiddleware } from "tenantkit/hono";

const app = new Hono();

app.use(
  "*",
  tenancyMiddleware({
    resolver,
    resolveUser: async (context) => ({
      userId: context.req.header("x-user-id"),
      role: "member",
    }),
    onError: (error, context) => {
      if (error instanceof TenancyError) {
        return context.json({ error: error.message, code: error.code }, error.status ?? 500);
      }

      throw error;
    },
  }),
);

Handlers can read tenant state from framework fields or from the current tenant context:

import { getTenant } from "tenantkit";

app.get("/projects", (context) => {
  const tenant = getTenant();

  return context.json({
    tenantId: tenant.id,
    tenantFromHono: context.get("tenant").id,
  });
});

4. Query Through createTenantDb

createTenantDb intentionally covers a narrow, high-value Drizzle surface: scoped findMany, scoped findFirst, and inserts that inject tenantId.

import { eq } from "drizzle-orm";
import { createTenantDb } from "tenantkit/drizzle";

const tenantDb = createTenantDb(db);

const projectList = await tenantDb.query(projects).findMany({
  where: eq(projects.ownerId, userId),
});

await tenantDb.insert(projects).values({
  ownerId: userId,
  name: "Launch",
});

For custom selects, updates, deletes, joins, and aggregates, use regular Drizzle with tenantFilter(table):

import { and, eq } from "drizzle-orm";
import { tenantFilter } from "tenantkit/drizzle";

await db
  .select()
  .from(projects)
  .where(and(tenantFilter(projects), eq(projects.ownerId, userId)));

Core API

import { getTenant, getTenantContext, getTenantOrNull, requireUser, withTenant } from "tenantkit";

interface AppUser {
  id: string;
  email: string;
}

await withTenant({ tenant }, async () => {
  const tenant = getTenant();
  const context = getTenantContext<AppUser>();
  const maybeContext = getTenantOrNull();
  const user = requireUser<AppUser>();
});

Suspended tenants are rejected by default. Trialing tenants are treated as active. Pass a custom tenant validator when an app needs recovery pages for suspended tenants.

Framework Adapters

Hono

tenancyMiddleware binds tenant context around the request handler and exposes Hono variables:

  • tenant
  • tenantContext
  • tenantId
  • user
  • userId
  • role

Tenant context is restored after successful handlers and thrown handler errors.

Elysia

import { Elysia } from "elysia";
import { getTenant } from "tenantkit";
import { tenancyPlugin } from "tenantkit/elysia";

const app = new Elysia()
  .use(
    tenancyPlugin({
      resolver,
      resolveUser: async (context) => ({
        userId: context.headers["x-user-id"],
        role: "member",
      }),
    }),
  )
  .get("/projects", ({ tenantId }) => ({
    tenantId,
    tenantFromContext: getTenant().id,
  }));

The plugin exposes tenant fields on Elysia handler context and restores tenant context on success and error paths.

Next.js

Next middleware/proxy and server code do not share the same request context. Middleware can resolve the tenant and forward trusted tenant headers:

import { nextTenancyMiddleware } from "tenantkit/next";

export default nextTenancyMiddleware({
  resolver,
  resolveUser: async (request) => ({
    userId: request.headers.get("x-user-id") ?? undefined,
    role: "member",
  }),
});

Bind tenant context again where server code runs:

import { getTenant } from "tenantkit";
import { withTenantRoute } from "tenantkit/next";

export async function GET(request: Request) {
  return withTenantRoute(request, { resolver }, () => {
    return Response.json({ tenantId: getTenant().id });
  });
}

withTenantRoute and withTenantHeaders bind tenant context only for their callback and restore it afterward.

Auth Recipes

resolveUser can return an opaque user object alongside userId and role. The auth provider still owns roles, permissions, and authorization.

Better Auth bridge:

import { betterAuthOrganizationResolver, resolveBetterAuthUser } from "tenantkit/better-auth";

const resolver = betterAuthOrganizationResolver({
  auth,
  lookup: lookupTenantByOrganizationId,
});

const resolveUser = resolveBetterAuthUser({ auth });

By default, resolveBetterAuthUser compares the session's active organization against tenant.metadata.organizationId, falling back to tenant.id. If your tenant stores the organization id elsewhere, pass getTenantOrganizationId.

Use requireUser<TUser>() in handlers that need an authenticated user. For providers without a first-party bridge, copy the resolveUser shape from examples/hono-clerk or examples/next-authjs.

Production Safety

App-level helpers reduce mistakes. They do not replace database isolation.

For Postgres, pair scoped helpers with row-level security so custom SQL and missed filters still fail closed.

import { withTenantRls } from "tenantkit/drizzle";

await withTenantRls(db, async (tx) => {
  return tx.select().from(projects);
});

withTenantRls opens a transaction and sets app.tenant_id from the current tenant context with Postgres set_config(..., true). Policies can then enforce tenant ownership:

tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid

See the Postgres docs for row security policies and set_config.

Runtime Contract

tenantkit uses AsyncLocalStorage from node:async_hooks for request context.

Use it in Node.js, Bun, or runtimes that provide compatible AsyncLocalStorage behavior. Do not assume edge compatibility yet. This matters most for Next middleware and Hono deployments on worker-style runtimes.

If your runtime cannot provide AsyncLocalStorage, use framework-local variables or forwarded headers as the source of truth instead of calling getTenant() inside that runtime.

Current Boundaries

Included:

  • Core tenant types, typed errors, tenant context, validators, and resolvers.
  • Generic TenantContext<TUser> and requireUser<TUser>().
  • PostgreSQL-first Drizzle helpers for scoped findMany, findFirst, and inserts.
  • tenantFilter(table) for unsupported Drizzle operations.
  • Hono middleware, Elysia plugin, Next.js helpers, and Better Auth bridge.
  • Optional Postgres RLS context helpers.

Not included:

  • Polar integration.
  • Prisma integration.
  • Plan enforcement helpers.
  • Core RBAC helpers such as requireRole or requirePermission.
  • Built-in caching.
  • Automatic update, delete, join, aggregate, or cross-dialect Drizzle scoping.
  • Guaranteed edge-runtime support.

Examples

  • examples/hono-drizzle: database-free Hono + Drizzle walkthrough.
  • examples/hono-drizzle-postgres: real Postgres example with RLS policies and seeded tenant data.
  • examples/hono-better-auth: runnable Hono + Better Auth bridge.
  • examples/hono-clerk: Clerk Organizations recipe.
  • examples/elysia-basic: small Elysia plugin example.
  • examples/elysia-drizzle-postgres: real Elysia + Drizzle + Postgres RLS example.
  • examples/next-basic: Next.js middleware and route-handler example.
  • examples/next-authjs: Auth.js session recipe.
  • examples/next-portless: Next.js + Portless demo for wildcard local tenant URLs.

License

Apache-2.0. See LICENSE.