Multi-tenant SaaS spine . Next.js 16 . MIT

Shipyard

A production-grade multi-tenant SaaS starter. Tenant isolation enforced by a repository chokepoint, permission-based RBAC, an append-only audit log.

The opinionated spine you would let pay you on day one. Token-bucket rate limits and a billing scaffold with a real state machine, pinned by tests.

29
tests, all green
~230
lines in repository
6
isolated test suites
~1s
full test run
MIT
license
shipyard . the spine
29/29 green
chokepoint
organisationId stamped . predicate non-overridable
rbac
4 roles . 10 permissions . fail-closed
rate limit
60api/s
billing
trialing -> active
active -> past_due
past_due -> active
* -> canceled
shipyard | ctx acme | role owner | bucket 58/60 | plan pro

Six isolated test suites prove the guarantees in roughly one second.

Why this matters

Every B2B SaaS needs the same spine before it can ship product. The pieces are well understood and each one is independently easy to get subtly wrong: a query missing its tenant predicate, a role check living in the client, a webhook reactivating a cancelled plan. Shipyard is the version where the design decisions are made once, written down, and pinned by tests, so the guarantee of tenant isolation holds on commit one.

Why this exists

I have started the same B2B SaaS three times. Each time I spent the first fortnight rebuilding the same unglamorous spine before I could touch the actual product. Tenant isolation, sessions, a role model, an audit trail, rate limits, and a billing scaffold I would not be ashamed to demo.

None of it is hard in isolation. The danger is that it is all easy to get subtly wrong, and the failures are the kind you find in production. A query that forgot its tenant predicate. A role check that lived in the client. A webhook that reactivated a cancelled plan because nobody validated the transition.

So I built the spine once, with the isolation and authorisation guarantees pinned down by tests rather than by good intentions, and made it run with no external services so it installs and proves itself on any machine. Shipyard is opinionated about the hard parts and deliberately empty where your product lives.

The one test that has to pass

Before any feature, this is the test I write first on every B2B product. If it fails, nothing else matters.

it("an update cannot reach across tenants", () => {
  const { repo } = freshRepo();
  const auth = new AuthService(repo);
  const a = auth.signup({ email: "a@acme.test",   password: "pw-pw-pw-pw", organisationName: "Acme"   });
  const b = auth.signup({ email: "b@globex.test", password: "pw-pw-pw-pw", organisationName: "Globex" });

  const acmeMembership = repo.selectScoped(a.organisationId, "memberships")[0] as { userId: string };

  // Globex tries to demote an Acme member. The scoped update matches nothing.
  const changed = repo.updateScoped(
    b.organisationId,
    "memberships",
    { role: "viewer" },
    { userId: acmeMembership.userId },
  );

  expect(changed).toBe(0);
});

Globex holds Acme’s real userId, names the right table, and asks for a write it is allowed to make inside its own tenant. The update still touches zero rows. That single property is what Shipyard exists to give you on commit one.

What you get

Everything below is in the repo today, with tests that prove it.

One chokepoint for tenant data

Every tenant-scoped read and write goes through Repository in src/db/repository.ts. It stamps the tenant id onto inserts and ANDs it into reads and updates. A caller cannot construct a query that reaches another tenant, even with a foreign id in the payload.

Sessions you can leak

Opaque 32-byte random tokens. Only a SHA-256 hash is stored, the plaintext is the httpOnly cookie. A database dump does not hand out live sessions. scrypt for passwords, with cost parameters embedded in the hash so you can raise them without a migration.

Permission-based RBAC

Routes assert a permission (billing:manage), not a role. Roles are bundles of permissions in src/lib/rbac.ts. A forgotten check fails closed by throwing ForbiddenError, not by silently allowing. Four roles, ten permissions, every mapping tested.

Append-only audit log

Actor, tenant, action, JSON metadata, millisecond timestamp. Written through the scoped repository so an entry cannot be misattributed. No update or delete path is exposed. Routes that read it are gated by audit:read.

Token-bucket rate limiter

Two numbers per key: (tokens, lastRefill). Injectable clock for deterministic tests. Pluggable BucketStore so the same algorithm ports to Redis behind several instances. Tighter budget on auth than on the general API.

Billing scaffold that means it

Plan catalogue with per-metric budgets, a real subscription state machine that rejects illegal transitions, usage metering that returns 402 instead of overrunning, and a Stripe-shaped adapter whose HMAC-SHA256 webhook signature check is the real scheme.

node:sqlite in dev, Postgres in production

No compiled addon, no service to install. pnpm install is fast and pnpm test runs anywhere. The repository sits behind a typed interface so the same shape reimplements against Postgres, with RLS as defence in depth on top.

The guarantees are tests

tests/tenant-isolation, tests/rbac, tests/audit, tests/rate-limit, tests/billing, tests/stripe-webhook. Each suite gets a fresh in-memory database so there is no shared state to leak. 29 tests in roughly 470ms on an M3 Pro.

One request context

resolveContext reads the session, looks up the user, pins the active tenant, resolves the role from membership in that tenant. A user pointing a session at a tenant they have no membership in resolves with no role and is refused.

Empty where your product lives

Opinionated about the hard parts, deliberately empty everywhere else. A minimal settings dashboard proves the wiring, then gets out of the way. Bring your own UI kit, your own routes, your own data model on top.

Tech stack

Next.js 16TypeScriptnode:sqlitePostgres seamscrypt + SHA-256HMAC-SHA256VitestStripe-shaped adapter

How a request flows

Edge middleware is a cheap gate. The authoritative decision is server-side where the database lives.

rendering
Request flow: edge gate, context, rate limit, RBAC, service, tenant-scoped repository, database.

Try it in a minute

No services to install. node:sqlite is built in from Node 24, scrypt and HMAC are in the standard library.

git clone https://github.com/sarmakska/shipyard.git
cd shipyard
pnpm install
SHIPYARD_DB_PATH=shipyard.db pnpm seed   # two demo organisations
pnpm test                                # the guarantees, proved
SHIPYARD_DB_PATH=shipyard.db pnpm dev    # http://localhost:3000/login
# Sign in as the Acme owner
# owner@acme.test  /  password-acme-123
# Open /app/settings: members, billing, usage, audit trail.
# Switch to a Globex account and confirm none of Acme is visible.

Who this is for

What people actually clone this to do.

A B2B SaaS on day one

Organisations, members, roles, an audit trail and a plan picker exist before you write your first product route. You start where most B2B starters end.

A serious internal tool

Multi-team internal tooling needs the same spine as a public SaaS: tenant separation, audit, role checks. The minimal dashboard is enough to run an internal product on while you build the rest.

A teaching reference

About 230 lines of repository code, a 30-line RBAC guard, a 60-line limiter. Small enough to read top to bottom and reason about. The wiki explains every design decision in prose.

A migration target

You have a single-tenant app and you need to add organisations. Lift the repository pattern, the RBAC table and the audit writer across. The rest of your code does not need to know.

Honest limits

SQLite is for dev and tests, not production. The repository is built for the Postgres swap. The SQLite layer exists so the project installs and proves itself with nothing running.

The Stripe adapter is a seam, not a client. The webhook signature verification is real and tested. The customer and subscription calls throw with a pointer to the wiki until you pnpm add stripe and fill them in.

Single-instance rate limiting out of the box. The default bucket store is in-memory. Behind several instances the effective limit multiplies until you wire the Redis store. The interface is there for exactly that.

Not a single-tenant skeleton. If you are not building multi-tenant, the tenancy machinery is pure overhead. Start elsewhere.

No UI framework opinion. There is a minimal settings dashboard to prove the wiring. Bring your own design system.

MIT . zero services . tested in ~1s

Clone it. Read it. Ship on it.

Email-token invitations, a Postgres repository alongside the SQLite one, and a Redis BucketStore are on the roadmap. Pull requests welcome on the spine, not on the missing UI kit.

All open-source projects