Tenant data access is centralized in a single Repository; inserts write the tenant id, and reads and updates include the tenant id in the conditions
shipyard — Next.js SaaS Template
By sarmakska
shipyard is a multi-tenant SaaS starter built on Next.js and TypeScript, providing tenant isolation, RBAC, audit logging, rate limiting and a billing skeleton.
Explore the design
Compare the page layouts and mobile experience. Open any screenshot for a closer look.
Actual screenshot captured by the server browser.
All pages & views 3
Screenshots show visual appearance; interactive behavior may differ.
What stands out
A quick read of the recorded project description.
Sessions use opaque random tokens, and the database stores only a SHA-256 hash
RBAC asserts permissions rather than roles; roles are sets of permissions, and a missing check fails closed by throwing
The audit log records actor, tenant, action and JSON metadata, and writes through a tenant-scoped repository
Token-bucket rate limiting applies by tenant and route, and returns standard X-RateLimit-* response headers
Billing includes a subscription state machine, usage budgets and a Stripe-shaped adapter, with webhooks using real HMAC signature verification
Template Details
shipyard is a multi-tenant SaaS starter written in Next.js and TypeScript, intended to provide the common underlying skeleton for B2B products from the start: tenant isolation, sessions, a role model, audit logging, rate limiting and billing scaffolding. The author notes that these parts are not difficult to implement individually, but are easy to get wrong in the details, so isolation and authorization guarantees are written as tests to verify them, and the project can be installed and self-verified without external services.
Tenant data access is centralized in the Repository in src/db/repository.ts: inserts write the tenant id, and reads and updates include the tenant id in the conditions. The test example given in the README shows that even if the caller holds a real userId from another tenant and specifies the correct table name, a cross-tenant update affects zero rows, because updateScoped injects the organisationId condition from the first argument. Sessions use opaque random tokens, and the database stores only a SHA-256 hash; RBAC asserts permissions rather than roles, roles are sets of permissions, and a missing check fails closed by throwing; the audit log records actor, tenant, action and JSON metadata, and writes through a tenant-scoped repository; rate limiting uses a token bucket with an injectable clock and pluggable storage, limits by tenant and route, and returns X-RateLimit-Limit, -Remaining and -Reset headers on protected responses; the billing part includes a subscription state machine, usage budgets (over-limit returns 402) and a Stripe-shaped adapter, where webhook signature verification uses a real HMAC scheme.
The data layer defaults to Node's built-in node:sqlite, and the README explicitly states that it is intended for development and testing, and should be replaced with Postgres in production; the repository interface is designed for that replacement. The Stripe adapter is only a seam rather than a complete client, and customer and subscription calls throw and point to the wiki until stripe is installed and the implementation is completed. The default rate-limit store is an in-memory implementation, so in multi-instance deployments the limits are multiplied by the number of instances, and a Redis store needs to be connected. The project does not prescribe a UI framework, and only has a minimal settings panel to verify the wiring. The README also states that it is not a single-tenant skeleton, and that if you are not doing multi-tenancy, this tenant machinery is extra overhead.
Regarding licensing, the repository uses the MIT license, which permits free use, modification, commercial use and distribution of this version of the code, provided the copyright notice and the full MIT license notice are retained when distributing; the code is provided as is. Third-party assets such as demo images, fonts and icons need to be checked against their respective licenses, and external services such as domains, hosting, databases, payments or AI may incur separate charges.
Is it right for your project?
Use Cases
- Projects that need to build a multi-tenant isolation and authorization skeleton for a B2B product
- Teams that want to verify cross-tenant reads and writes, RBAC, auditing, rate limiting and billing state transitions with tests
- Local development or CI scenarios that need to install and run tests without external services
- SaaS projects that plan to develop with SQLite first and replace it with Postgres later
Suitable For
- Developers starting a multi-tenant SaaS and wanting to lock down isolation and authorization guarantees first
- Teams that accept access control by permission rather than by role
- Projects willing to choose their own UI framework, payment SDK and production database
- Engineering teams that need a readable, auditable tenant data access path rather than ORM-generated queries
Getting started & customization
Use these recorded setup notes alongside the repository instructions for your chosen version.
- 1
Install dependencies with pnpm install, specify the database path via SHIPYARD_DB_PATH, then run pnpm seed to generate two demo organizations
- 2
Run pnpm test to verify guarantees such as tenant isolation, RBAC, auditing, rate limiting, billing and Stripe webhooks
- 3
Run pnpm dev and visit /login; you can log in with [email protected] / password-acme-123 and view /app/settings
- 4
The default SQLite is only for development and testing; in production it must be replaced with Postgres according to the repository interface
- 5
The Stripe adapter requires installing stripe yourself and completing the customer and subscription calls; multi-instance rate limiting requires connecting a Redis store
License & source material
Usage conditions & limitations
Free to use · keep license noticeThis entry covers the free version under the MIT license in the public repository; the free scope is the code of this version and does not mean that external services or third-party assets are free.
Must comply with when using
- Free use, modification, commercial use and distribution of this version of the code are permitted.
- When distributing copies or substantial portions of the code, retain the original author's copyright notice and the full MIT license notice.
- The code is provided as is; warranties and limitations of liability are governed by the original license and applicable law.
Additional costs & items to verify
- Demo images, fonts, icons and other third-party assets must be checked against their respective licenses and cannot be judged solely by the code's MIT license.
- External services such as domains, hosting, databases, payments or AI may incur separate charges.
- The default node:sqlite data layer is only for development and testing; the README explicitly requires that it not be used as a production database, and production must replace it with Postgres according to the repository interface.
- The Stripe adapter is only a seam rather than a complete client: customer and subscription calls throw and point to the wiki until you install stripe yourself and complete the implementation, so an additional payment SDK integration is required to use it.
- The default rate-limit store is an in-memory implementation suitable only for a single instance; in multi-instance deployments the effective limits are multiplied by the number of instances, and a Redis store must be connected for correct rate limiting.
- The project targets multi-tenant scenarios; if you are not doing multi-tenancy, this tenant machinery is extra overhead, and the README suggests using a different starting point.
- The project does not provide a UI framework solution, only a minimal settings panel for verifying the wiring, so you need to bring in a design system yourself.
View Original Text Basis
Permission is hereby granted, free of charge
Original License Text
**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. Do not ship it as your production database.
Original README
**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.
Original README
**Single-instance rate limiting out of the box.** The default bucket store is in-memory. Behind several instances the effective limit multiplies by the instance count until you wire the Redis store.
Original README
**Not a single-tenant skeleton.** If you are not building multi-tenant, the tenancy machinery is pure overhead and you should start elsewhere.
Original README
https://github.com/sarmakska/shipyard/blob/main/LICENSEhttps://github.com/sarmakska/shipyard/blob/main/README.md**No UI framework opinion.** There is a minimal settings dashboard to prove the wiring. Bring your own design system.
Original README
Compiled by DeepSeek based on repository license and README ·2026-10-04. Results only cover the materials read; dependencies, images, fonts, and subsequent term changes are subject to their respective original texts.
Project checks & description sources
Template Information
- Source platforms
- GitHub
- Documentation and maintenance reference score
- More documented · 85 / 100
- Technologies
- Next.js · React
- Website type
- SaaS · Starter
How this score is calculated
Our heuristic uses license records (up to 30), a demo link (12), a preview (10), stars (15), recorded maintenance (15), description completeness (8), feature records (5) and classification data (5). It is not a code, security or performance test, or a Google score. A link does not establish tested functionality.
What to verify for this project
- Repository update recorded
- 2026-06-07
- Screenshot last checked
- 2026-10-04
A demo link is listed. Check it with your own content and viewport sizes; its presence does not confirm that forms, authentication or payments work.
The catalog identifies Next.js as the primary stack. Check the actual dependency manifest and lockfile for versions and hosting requirements. These notes use recorded metadata, not a code audit or a deployment test.
Use a selection guide →How this description was prepared
AI-assisted description based on catalog records and repository material. Repository authors are not automatically the reviewers of this description.
Review declaration: [email protected] · 2026-10-04
Inspect the recorded source material
SHA-256: 8a7c926a3354f59428886c6a1e3e2240c9e068b273de7693eaf93c97d3236ad8
2026-10-04T07:36:37.653Z
Source file / repository<p align="center">A production-grade multi-tenant SaaS starter: organisations, RBAC, billing, audit log and rate limiting done properly.</p>
<p align="center">
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg"></a>
<a href="https://github.com/sarmakska/shipyard"><img alt="Language" src="https://img.shields.io/github/languages/top/sarmakska/shipyard"></a>
<a href="https://github.com/sarmakska/shipyard/commits"><img alt="Last commit" src="https://img.shields.io/github/last-commit/sarmakska/shipyard"></a>
</p>
## 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.
```ts
it("an update cannot reach across tenants", () => {
const { repo } = freshRepo();
const auth = new AuthService(repo);
const a = auth.signup({ email: "[email protected]", password: "pw-pw-pw-pw", organisationName: "Acme" });
const b = auth.signup({ email: "[email protected]", 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);
expect((repo.selectScoped(a.organisationId, "memberships")[0] as { role: string }).role).toBe("owner");
});
```
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. The reason is that `updateScoped` injects `organisationId = @organisationId` into the `WHERE` clause from the first argument, not from the caller's filter, so the statement reduces to "rows that are both Acme's membership and Globex's", which is the empty set. There is no second code path that skips this, because there is only one repository and it is the only way to reach tenant tables. The full walkthrough, including the smuggled-id insert case, is on the [Multi-Tenancy](https://github.com/sarmakska/shipyard/wiki/Multi-Tenancy) wiki page.
That single property is what shipyard exists to give you on commit one.
## Why I built this
I have started the same B2B SaaS three times, and 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.
## What you get
- **One chokepoint for tenant data.** Every tenant-scoped read and write goes through `Repository` in `src/db/repository.ts`, which stamps the tenant id onto inserts and ANDs it into reads and updates. A caller cannot construct a query that reaches another tenant, even by putting a foreign id in the payload.
- **Sessions you can leak.** Opaque random tokens, only a SHA-256 hash stored (`src/lib/auth.ts`). A database dump does not hand out live sessions. The plaintext token is the cookie; OAuth slots in at `createSession`.
- **Permission-based RBAC.** Routes assert a permission (`billing:manage`), not a role. Roles are bundles of permissions in `src/lib/rbac.ts`, and a missing check fails closed by throwing.
- **An append-only audit log.** Actor, tenant, action and JSON metadata for every privileged operation (`src/lib/audit.ts`), written through the scoped repository so an entry cannot be misattributed.
- **A token-bucket rate limiter** with an injected clock and a pluggable store (`src/lib/rate-limit.ts`), keyed by tenant and route. Every protected response carries the standard `X-RateLimit-Limit`, `-Remaining` and `-Reset` headers, so a client paces itself before it ever sees a 429.
- **A billing scaffold** with a real subscription state machine, usage budgets that return 402 instead of overrunning, and a Stripe-shaped adapter whose webhook signature check is the real HMAC scheme (`src/lib/billing/`).
It is Next.js 16 and TypeScript throughout. The data layer sits behind a typed repository on the built-in `node:sqlite`, and the same interface is what you reimplement against Postgres for production.
## Try it in a minute
```bash
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 `[email protected]` / `password-acme-123` and open `/app/settings`: members, billing, usage and the audit trail, all resolved from one request context and scoped to the active tenant.
## How a request flows
```mermaid
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#0d1117','primaryTextColor':'#f5f7fa','primaryBorderColor':'#38bdf8','lineColor':'#22d3ee','secondaryColor':'#0f172a','tertiaryColor':'#0d1117','fontFamily':'ui-monospace, monospace'}}}%%
flowchart TD
Client[Client] --> MW[Edge middleware<br/>session gate + request id]
MW --> Ctx[resolveContext<br/>session to user to tenant to role]
Ctx --> RL[Token-bucket rate limiter]
RL --> Guard[RBAC guard<br/>requirePermission]
Guard --> Svc[Service layer<br/>members / billing / audit]
Svc --> Repo[(Tenant-scoped repository)]
Repo --> DB[(node:sqlite in dev/test<br/>Postgres in production)]
Svc --> Audit[[Audit log]]
Audit --> Repo
```
Middleware is a cheap gate at the edge; the authoritative decision is made server-side in `resolveContext` where the database lives. See [Architecture](https://github.com/sarmakska/shipyard/wiki/Architecture).
## Design decisions
A few choices that shaped the project, including the ones I argued myself out of.
**`node:sqlite`, not better-sqlite3 or Prisma.** I wanted the test suite to be hermetic and the install to have no native build step. `better-sqlite3` is excellent but it is a compiled addon, which is exactly the thing that breaks in someone's CI on a Tuesday. Node's built-in `node:sqlite` (stable from Node 24) needs no compile and no service, so `pnpm install` is fast and `pnpm test` runs anywhere. I rejected an ORM for a different reason: the whole isolation argument rests on there being one narrow, auditable path to tenant data. A hand-written repository of about 230 lines is something I can read top to bottom and reason about; a generated query builder hides the `WHERE` clause that the entire guarantee depends on.
**A repository chokepoint, not Postgres RLS as the primary guard.** Row-level security is the obvious answer and it is genuinely good. I did not make it the primary mechanism because the project has to run and prove itself with zero services, and because an application-level guard fails loudly in a unit test on any database, whereas an RLS misconfiguration fails silently until production. So the repository is the guard you can test on commit one, and RLS is documented as the defence-in-depth backstop once you are on Postgres. Both, not one.
**Permissions at the call site, roles as bundles.** I considered checking roles directly (`if (role === "admin")`) because it is shorter. It rots: every new capability forces you to revisit every role comparison, and the meaning drifts. Asserting a permission (`requirePermission(role, "members:invite")`) keeps the route readable and lets the role table grow without touching call sites.
**Token bucket over a fixed window or a sliding-window log.** A fixed window double-rates at the boundary; a sliding-window log needs a timestamp list per key. The bucket is two numbers, `(tokens, lastRefill)`, which is also why it ports to a Redis Lua script unchanged. The reasoning is in [Rate Limiting](https://github.com/sarmakska/shipyard/wiki/Rate-Limiting).
## Limitations and non-goals
This is a spine, not a product, and being honest about the edges is part of the point.
- **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. Do not ship it as your production database.
- **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. I will not bundle a payment SDK into a starter.
- **Single-instance rate limiting out of the box.** The default bucket store is in-memory. Behind several instances the effective limit multiplies by the instance count 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 and you should start elsewhere.
- **No UI framework opinion.** There is a minimal settings dashboard to prove the wiring. Bring your own design system.
## What I measured
Real numbers from my machine, an **Apple M3 Pro running Node v25.9.0**, not estimates.
```text
$ pnpm test
Test Files 6 passed (6)
Tests 33 passed (33)
Duration 502ms
$ /usr/bin/time -p pnpm test # whole command, including process spin-up
real 1.18
```
Six fresh in-memory databases, one per suite, so there is no shared state to leak between cases. What each suite proves:
| Suite | What it proves |
| --- | --- |
| `tenant-isolation` | Cross-tenant reads return nothing; a smuggled tenant id is overwritten; cross-tenant updates change zero rows. |
| `rbac` | A viewer is refused privileged actions; a user with no membership in the active tenant fails closed. |
| `audit` | Signup and invitations write entries with the correct actor, tenant and metadata. |
| `rate-limit` | The bucket allows up to capacity, blocks past it, refills at the configured rate, never exceeds the ceiling, and reports a budget that maps to standard `X-RateLimit-*` headers. |
| `billing` | Subscribe and webhook transitions are validated; illegal transitions are rejected; plan budgets stop usage overrun. |
| `stripe-webhook` | A correctly signed payload is accepted and mapped; a tampered or unsigned payload is rejected. |
## Roadmap
What I intend to add, and what I will not.
- **Will:** invitations by email token (not just direct membership), a Postgres repository implementation alongside the SQLite one, and a Redis `BucketStore` so the rate limiter is correct multi-instance.
- **Maybe:** organisation-switching in the session, and per-tenant feature flags.
- **Will not:** an ORM, a bundled payment SDK, or a UI kit. Those are decisions your product should make, and bolting them on here would undo the reason the spine is small enough to trust.
## Documentation
The wiki is the long form: [Home](https://github.com/sarmakska/shipyard/wiki/Home), [Architecture](https://github.com/sarmakska/shipyard/wiki/Architecture), [Multi-Tenancy](https://github.com/sarmakska/shipyard/wiki/Multi-Tenancy), [Auth and RBAC](https://github.com/sarmakska/shipyard/wiki/Auth-and-RBAC), [Billing](https://github.com/sarmakska/shipyard/wiki/Billing), [Audit Log](https://github.com/sarmakska/shipyard/wiki/Audit-Log), [Rate Limiting](https://github.com/sarmakska/shipyard/wiki/Rate-Limiting), [Deployment](https://github.com/sarmakska/shipyard/wiki/Deployment), [Roadmap](https://github.com/sarmakska/shipyard/wiki/Roadmap) and [Troubleshooting](https://github.com/sarmakska/shipyard/wiki/Troubleshooting). It goes deeper too: the [Data Model](https://github.com/sarmakska/shipyard/wiki/Data-Model), the [Repository Reference](https://github.com/sarmakska/shipyard/wiki/Repository-Reference), the [API Reference](https://github.com/sarmakska/shipyard/wiki/API-Reference), [Configuration](https://github.com/sarmakska/shipyard/wiki/Configuration), [Performance](https://github.com/sarmakska/shipyard/wiki/Performance), the [Security Model](https://github.com/sarmakska/shipyard/wiki/Security-Model), the [Testing Strategy](https://github.com/sarmakska/shipyard/wiki/Testing-Strategy), [Design Decisions](https://github.com/sarmakska/shipyard/wiki/Design-Decisions), [Comparisons](https://github.com/sarmakska/shipyard/wiki/Comparisons), [Examples and Recipes](https://github.com/sarmakska/shipyard/wiki/Examples-and-Recipes), [Contributing](https://github.com/sarmakska/shipyard/wiki/Contributing) and an [FAQ](https://github.com/sarmakska/shipyard/wiki/FAQ). The same pages are mirrored in [`wiki/`](./wiki) so they always travel with the code.
## Licence
MIT. See [LICENSE](./LICENSE).
---
Built by Sarma. Part of the SarmaLinux open-source line.
Website: https://sarmalinux.com . GitHub: https://github.com/sarmakska
Explore similar templates
Compare previews and descriptions within the same stack and category.

Next.js SaaS Starter
The official Next.js SaaS starter template, integrating email/password authentication, Stripe Checkout subscriptions, a dashboard with user/team CRUD, basic Owner/Member RBAC, and activity logging.
Next.js · SaaS

Next.js Commerce
Vercel's officially maintained Next.js App Router ecommerce starter with deep Shopify integration, offering a complete product catalog, cart, and checkout flow. Core logic is modular for swapping backends, and community provider versions exist for BigCommerce, Medusa, and Saleor.
Next.js · E-commerce

Next-js-Boilerplate
A developer-first Next.js 16 starter with Tailwind CSS 4 and TypeScript, supporting both App Router and Page Router.
Next.js · Starter

tailwind-nextjs-starter-blog
A Next.js and Tailwind CSS blog starter template using Contentlayer for Markdown/MDX, with built-in SEO, code highlighting, and tags. It targets personal technical blogs and migration from Jekyll/Hugo to React, and supports one-click Vercel deployment.
Next.js · Blog

next-forge
next-forge is a Turborepo-based Next.js template maintained by Vercel, positioned as a starting point for SaaS products.
Next.js · Starter

SaaS-Boilerplate
SaaS-Boilerplate is an open-source SaaS starter template built with Next.js App Router, React 19, TypeScript, Tailwind CSS, and Shadcn UI.
Next.js · SaaS
