租户数据访问集中在单一 Repository,插入写入租户 id,读取与更新把租户 id 并入条件
shipyard — Next.js SaaS模板
作者 sarmakska
shipyard 是一个基于 Next.js 与 TypeScript 的多租户 SaaS 启动器,提供租户隔离、RBAC、审计日志、限流与计费骨架。
看看你的网站会是什么样
浏览真实页面截图,比较内容排版与手机效果。点击下方缩略图切换,放大查看整页细节。
全部页面与视图 3
截图用于了解视觉与布局,交互效果请以实际项目为准。
值得关注的模板亮点
从已有项目资料中,快速了解它的特点。
会话使用不透明随机令牌,数据库只保存 SHA-256 哈希
RBAC 按权限断言而非角色,角色是权限集合,缺少检查时抛错失败关闭
审计日志记录操作者、租户、动作与 JSON 元数据,并通过受租户约束的仓库写入
令牌桶限流按租户与路由生效,返回标准 X-RateLimit-* 响应头
计费包含订阅状态机、用量预算与 Stripe 形态适配器,webhook 使用真实 HMAC 签名校验
模板详细介绍
shipyard 是一个多租户 SaaS 启动器,用 Next.js 与 TypeScript 编写,目标是在项目初期就提供 B2B 产品常见的底层骨架:租户隔离、会话、角色模型、审计日志、限流与计费脚手架。作者强调这些部分单独实现并不困难,但容易在细节上出错,因此把隔离与授权保证写成测试来验证,并让项目不依赖外部服务即可安装和自证。
租户数据访问集中在 src/db/repository.ts 的 Repository 中:插入时写入租户 id,读取和更新时把租户 id 并入条件。README 给出的测试示例说明,即使调用方持有其他租户的真实 userId 并指定正确表名,跨租户更新也会影响零行,因为 updateScoped 会从第一个参数注入 organisationId 条件。会话使用不透明随机令牌,数据库只存 SHA-256 哈希;RBAC 按权限而非角色断言,角色是权限的集合,缺少检查会以抛错方式失败关闭;审计日志记录操作者、租户、动作与 JSON 元数据,并通过受租户约束的仓库写入;限流采用令牌桶,带可注入时钟与可插拔存储,按租户和路由限流,并在受保护响应中返回 X-RateLimit-Limit、-Remaining、-Reset 头;计费部分包含订阅状态机、用量预算(超限返回 402)以及 Stripe 形态的适配器,其中 webhook 签名校验使用真实 HMAC 方案。
数据层默认使用 Node 内置的 node:sqlite,README 明确说明它面向开发与测试,生产环境应替换为 Postgres;仓库接口就是为这种替换设计的。Stripe 适配器只是接缝而非完整客户端,客户与订阅相关调用在安装 stripe 并补齐实现前会抛出并指向 wiki。默认限流存储是内存实现,多实例部署时限额会按实例数放大,需要接入 Redis 存储。项目不提供 UI 框架意见,只有一个最小设置面板用于验证接线。README 还说明它不是单租户骨架,若不做多租户,这套租户机制是额外负担。
许可方面,仓库采用 MIT 许可,允许免费使用、修改、商用和分发该版本代码,分发时需保留版权声明与完整 MIT 许可声明;代码按现状提供。演示图片、字体、图标等第三方素材需按各自许可核对,域名、托管、数据库、支付或 AI 等外部服务可能另外收费。
适合你的项目吗?
适用场景
- 需要为 B2B 产品搭建多租户隔离与授权骨架的项目
- 希望用测试验证跨租户读写、RBAC、审计、限流与计费状态转换的团队
- 需要在无外部服务环境下安装并运行测试的本地开发或 CI 场景
- 计划先以 SQLite 开发、后续替换为 Postgres 的 SaaS 项目
适合人群
- 正在启动多租户 SaaS 并希望先固定隔离与授权保证的开发者
- 接受按权限而非角色做访问控制的团队
- 愿意自行选择 UI 框架、支付 SDK 与生产数据库的项目
- 需要可读、可审计的租户数据访问路径而非 ORM 生成查询的工程团队
上手与定制指南
结合仓库当前版本的说明,按下面的资料检查内容、配置与运行要求。
- 1
使用 pnpm install 安装依赖,通过 SHIPYARD_DB_PATH 指定数据库路径后运行 pnpm seed 生成两个演示组织
- 2
运行 pnpm test 验证租户隔离、RBAC、审计、限流、计费与 Stripe webhook 等保证
- 3
运行 pnpm dev 后访问 /login,可用 [email protected] / password-acme-123 登录并查看 /app/settings
- 4
默认 SQLite 仅用于开发与测试,生产环境需按仓库接口替换为 Postgres
- 5
Stripe 适配器需自行安装 stripe 并补齐客户与订阅调用;多实例限流需接入 Redis 存储
授权与来源资料
使用条件与限制
免费使用 · 保留许可声明本次收录的是公开仓库中采用 MIT 许可的免费版本;免费范围为该版本代码,不代表外部服务或第三方素材免费。
使用时需要遵守
- 允许免费使用、修改、商用和分发该版本代码。
- 分发代码副本或实质性部分时,保留原作者版权声明和完整 MIT 许可声明。
- 代码按现状提供;担保与责任限制以原许可证及适用法律为准。
额外费用与待核对事项
- 演示图片、字体、图标及其他第三方素材需按各自许可核对,不能仅凭代码的 MIT 许可判断。
- 域名、托管、数据库、支付或 AI 等外部服务可能另外收费。
- 默认的 node:sqlite 数据层仅用于开发与测试,README 明确要求不要将其作为生产数据库;生产环境需按仓库接口替换为 Postgres。
- Stripe 适配器只是接缝而非完整客户端:客户与订阅相关调用在自行安装 stripe 并补齐实现前会抛出并指向 wiki,需额外接入支付 SDK 才能使用。
- 默认限流存储为内存实现,仅适用于单实例;多实例部署时实际限额会按实例数放大,需自行接入 Redis 存储才能正确限流。
- 项目面向多租户场景;若不做多租户,这套租户机制属于额外负担,README 建议改用其他起点。
- 项目不提供 UI 框架方案,仅有一个用于验证接线的最小设置面板,需自行引入设计系统。
查看原文依据
Permission is hereby granted, free of charge
许可证原文
**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.
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.
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.
README 原文
**Not a single-tenant skeleton.** If you are not building multi-tenant, the tenancy machinery is pure overhead and you should start elsewhere.
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.
README 原文
DeepSeek 根据仓库许可证和 README 整理 ·2026-10-04。结果仅覆盖已读取资料,依赖、图片、字体及后续条款变更以各自原文为准。
项目核对记录与介绍依据
模板资料
- 来源平台
- GitHub
- 资料与维护参考分
- 资料较完整 · 85 / 100
- 技术栈
- Next.js · React
- 网站类型
- SaaS · 启动器
这个分数如何计算
本站按许可资料(最高30分)、演示链接(12分)、预览图(10分)、Star数(15分)、记录的维护时间(15分)、描述完整度(8分)、功能记录(5分)和分类资料(5分)计算。它不是代码、安全或性能测试,也不是 Google 评分;有链接不代表已实测可用。
这个项目需要核对什么
- 记录的仓库更新时间
- 2026-06-07
- 截图检查日期
- 2026-10-04
已收录演示链接。请用你的内容与屏幕尺寸检查;有演示不代表表单、认证或支付已经可用。
目录记录的主要技术栈是 Next.js。请通过依赖清单和锁文件核对实际版本与托管要求。这些说明基于收录资料,不代表代码审计或部署实测。
查看具体选型方法 →介绍的制作与核对状态
介绍由 AI 辅助整理目录与仓库资料。仓库作者不等于这篇介绍的审核者。
审核声明: [email protected] · 2026-10-04
查看当时使用的来源资料
SHA-256: 8a7c926a3354f59428886c6a1e3e2240c9e068b273de7693eaf93c97d3236ad8
2026-10-04T07:36:37.653Z
来源文件 / 仓库<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
看看同类模板
对比同技术栈与用途的模板,找到更适合的视觉与内容结构。

ai-website-cloner-template
Clone any website with one command using AI coding agents
Next.js · 启动器

Next.js SaaS Starter
Next.js SaaS Starter 是 Next.js 官方维护的 SaaS 起点模板,定位介于学习资源和可直接部署的脚手架之间。
Next.js · SaaS

Next.js Commerce
Next.js Commerce 是 Vercel 团队维护的电商启动模板,基于 Next.js App Router 构建,采用服务端渲染、React Server Components、Server Actions …
Next.js · 电商

Next-js-Boilerplate
Next-js-Boilerplate 是一个面向 Next.js 16、Tailwind CSS 4 和 TypeScript 的启动器,核心定位是开发者体验优先。
Next.js · 启动器

tailwind-nextjs-starter-blog
tailwind-nextjs-starter-blog 是一个面向个人技术博客的 Next.js 与 Tailwind CSS 启动模板,其 v2 版本基于 App Router 与 React Server Comp…
Next.js · 博客

next-forge
Production-grade Turborepo template for Next.js apps.
Next.js · 启动器
