已接入 Rekey 的注册、登录、会话、套餐、托管结账、权益与积分流程
nextjs-starter — Next.js SaaS模板
作者 rekey-dev
基于 Next.js 的 SaaS 启动模板,已接入 Rekey 的认证与计费,包含注册、登录、会话、套餐、托管结账、权益与积分等模块。
看看你的网站会是什么样
浏览真实页面截图,比较内容排版与手机效果。点击下方缩略图切换,放大查看整页细节。
全部页面与视图 3
截图用于了解视觉与布局,交互效果请以实际项目为准。
值得关注的模板亮点
从已有项目资料中,快速了解它的特点。
认证与计费逻辑拆分为多个短文件,便于逐文件阅读和修改
提供密码、魔法链接、OAuth、邮箱验证、密码重置和 MFA 的示例实现
会话读取通过 getSession() 封装,避免渲染期间写 cookie 导致 500 错误
proxy.ts 在边缘做 cookie 存在性检查,页面自身再做实际鉴权
采用 MIT 许可,可免费使用、修改、商用和分发该版本代码
模板详细介绍
该模板是一个可直接运行的 Next.js 应用,将认证与计费流程接入 Rekey。README 说明它提供注册、登录、会话管理、套餐展示、托管结账、权益校验和积分消耗等基础能力,并围绕这些能力给出了具体的文件组织方式。
认证部分包含密码、魔法链接、OAuth、邮箱验证、密码重置和 MFA 等流程,相关逻辑分散在 app/actions 与 app/api 下的短文件中,便于逐文件阅读。会话读取推荐使用 lib/session.ts 的 getSession(),而不是直接调用 auth(),以避免在渲染期间写 cookie 导致 500 错误。proxy.ts 在边缘做 cookie 存在性检查,页面自身再用 getSession() 做实际鉴权。
计费部分包含套餐卡片、托管结账跳转、周期末取消和积分消耗示例。环境变量需要配置 Rekey 的密钥、API 地址、公开密钥以及应用自身可访问的 URL。OAuth 提供商列表无法通过 Application key 读取,需通过 REKEY_OAUTH_PROVIDERS 手动声明。模板采用 MIT 许可,但演示图片、字体、图标等第三方素材需单独核对许可,域名、托管、数据库、支付等外部服务可能另外收费。
适合你的项目吗?
适用场景
- 需要快速搭建带认证和计费的 SaaS 应用原型
- 希望参考 Rekey 认证与计费集成的具体文件组织方式
- 需要实现密码、魔法链接、OAuth 或 MFA 等多种登录方式
- 需要展示套餐、跳转托管结账、处理周期末取消和积分消耗
- 需要为 Next.js 应用增加会话读取与路由保护的基础结构
适合人群
- 使用 Next.js 开发 SaaS 产品的开发者
- 需要接入 Rekey 认证与计费服务的团队
- 希望阅读短文件示例来理解认证与计费流程的开发者
- 需要可自由修改和商用的 MIT 许可启动模板的项目
上手与定制指南
结合仓库当前版本的说明,按下面的资料检查内容、配置与运行要求。
- 1
需要先创建 Rekey Application,可注册 rekey.dev 或自行部署开源版 Rekey
- 2
复制 .env.example 为 .env.local,并填写 REKEY_SECRET、REKEY_URL、NEXT_PUBLIC_REKEY_PUBLIC_KEY、NEXT_PUBLIC_REKEY_URL、APP_URL 和 NEXT_PUBLIC_APP_URL
- 3
REKEY_SECRET 仅限服务端使用,不要提交到仓库或从客户端组件导入
- 4
OAuth 提供商需通过 REKEY_OAUTH_PROVIDERS 声明,并在提供商和面板中注册回调地址 {APP_URL}/api/auth/oauth/{provider}/callback
- 5
在非 localhost 的普通 HTTP 环境下可能需要设置 REKEY_COOKIE_SECURE,否则浏览器会拒绝会话 cookie
授权与来源资料
使用条件与限制
免费使用 · 保留许可声明本次收录的是公开仓库中采用 MIT 许可的免费版本;免费范围为该版本代码,不代表外部服务或第三方素材免费。
使用时需要遵守
- 允许免费使用、修改、商用和分发该版本代码。
- 分发代码副本或实质性部分时,保留原作者版权声明和完整 MIT 许可声明。
- 代码按现状提供;担保与责任限制以原许可证及适用法律为准。
额外费用与待核对事项
- 使用该模板必须拥有 Rekey Application:可注册 rekey.dev 托管服务,或自行部署开源版 Rekey。认证与计费功能依赖 Rekey 服务,无法脱离该外部服务独立运行。
- 需在 Rekey 面板创建 Application 并获取 API 密钥,填写 REKEY_SECRET、REKEY_URL、NEXT_PUBLIC_REKEY_PUBLIC_KEY、NEXT_PUBLIC_REKEY_URL 等环境变量后才能运行。
- 结账功能需在 Rekey 面板连接 Stripe、Razorpay、PayPal 或 Paddle 等支付提供商,否则无法跳转结账;这些外部支付服务可能另行收费。
- OAuth 登录需在第三方提供商和 Rekey 面板中注册回调地址 {APP_URL}/api/auth/oauth/{provider}/callback,并配置 REKEY_OAUTH_PROVIDERS 声明可用提供商。
- 需在 Rekey 面板的 Application 中登记所有使用来源(含 http://localhost:3000),否则浏览器端会话校验会被 CORS 拦截并显示为未登录。
- 在非 localhost 的普通 HTTP 环境下需设置 REKEY_COOKIE_SECURE,否则浏览器会拒绝会话 cookie,登录无法保持。
- 部署时需设置相同环境变量,将 APP_URL 指向真实来源,并在面板中把该来源加入允许来源列表。
- 演示图片、字体、图标及其他第三方素材需按各自许可核对,不能仅凭代码的 MIT 许可判断。
- 域名、托管、数据库、支付或 AI 等外部服务可能另外收费。
查看原文依据
Permission is hereby granted, free of charge
许可证原文
You need a Rekey Application. Either sign up at [rekey.dev](https://rekey.dev), or run the whole thing yourself with [the open source repo](https://github.com/rekey-dev/rekey).
README 原文
Fill in `.env.local` from **Panel → your Application → Developer → API keys**
README 原文
Connect a provider (Stripe, Razorpay, PayPal or Paddle) under Billing then Providers, or checkout will have nothing to redirect to.
README 原文
Register this redirect URI with the provider and in the panel:
README 原文
Add every origin you use, including `http://localhost:3000`, under your Application in the panel.
README 原文
`REKEY_COOKIE_SECURE` | Optional, and only for serving over plain HTTP on a non-localhost hostname.
README 原文
https://github.com/rekey-dev/nextjs-starter/blob/main/LICENSEhttps://github.com/rekey-dev/nextjs-starter/blob/main/README.mdSet the same environment variables, with `APP_URL` pointing at your real origin, and add that origin to the Application's allowed origins in the panel.
README 原文
DeepSeek 根据仓库许可证和 README 整理 ·2026-10-01。结果仅覆盖已读取资料,依赖、图片、字体及后续条款变更以各自原文为准。
项目核对记录与介绍依据
模板资料
- 来源平台
- GitHub
- 资料与维护参考分
- 资料较完整 · 85 / 100
- 技术栈
- Next.js · React
- 网站类型
- SaaS · 启动器
这个分数如何计算
本站按许可资料(最高30分)、演示链接(12分)、预览图(10分)、Star数(15分)、记录的维护时间(15分)、描述完整度(8分)、功能记录(5分)和分类资料(5分)计算。它不是代码、安全或性能测试,也不是 Google 评分;有链接不代表已实测可用。
这个项目需要核对什么
- 记录的仓库更新时间
- 2026-08-14
- 截图检查日期
- 2026-10-01
已收录演示链接。请用你的内容与屏幕尺寸检查;有演示不代表表单、认证或支付已经可用。
目录记录的主要技术栈是 Next.js。请通过依赖清单和锁文件核对实际版本与托管要求。这些说明基于收录资料,不代表代码审计或部署实测。
查看具体选型方法 →介绍的制作与核对状态
介绍由 AI 辅助整理目录与仓库资料。仓库作者不等于这篇介绍的审核者。
已完成服务器许可规则与 AI 来源核对;未经过人工编辑审核。
查看当时使用的来源资料
SHA-256: 1ffc5fce40b81f972d70bce531f526e9fe04ca1d5cd6fb8af327c8187b4a5a62
2026-10-01T01:11:05.182Z
来源文件 / 仓库# Next.js + Rekey (auth and billing)
A working Next.js app with authentication and billing already wired to
[Rekey](https://rekey.dev). Sign-up, sign-in, sessions, plans, hosted checkout,
entitlements and credits.
It is a starting point, not a framework. Every part of the integration is a
short file you can open and read, and there is nothing you have to keep.
- Next.js 16 (App Router, Turbopack, React 19)
- Tailwind CSS 4
- `@rekey.dev/nextjs`, `@rekey.dev/react`, `@rekey.dev/node`
## Getting it running
You need a Rekey Application. Either sign up at
[rekey.dev](https://rekey.dev), or run the whole thing yourself with
[the open source repo](https://github.com/rekey-dev/rekey). This starter does
not care which; only the API URL changes.
```bash
npx create-next-app@latest my-app --example https://github.com/rekey-dev/nextjs-starter
cd my-app
cp .env.example .env.local
```
Fill in `.env.local` from **Panel → your Application → Developer → API keys**:
| Variable | What it is |
| --- | --- |
| `REKEY_SECRET` | Server-only. Full API access for this Application. Never commit it, never import it from a client component. |
| `REKEY_URL` | Required, even on hosted Rekey: `https://api.rekey.dev`, or your own API if you self-host. |
| `NEXT_PUBLIC_REKEY_PUBLIC_KEY` | Safe in the browser. Identifies the Application; grants nothing on its own. |
| `NEXT_PUBLIC_REKEY_URL` | Same API, the browser-visible copy. |
| `APP_URL` | Where this app is reachable. Checkout returns the user here. Read at request time, so this is the one that matters in production. Leave it unset and checkout raises a named error rather than blaming the buyer's card. |
| `NEXT_PUBLIC_APP_URL` | The same value for local development. `NEXT_PUBLIC_*` is baked into the bundle at build, so on its own it freezes whatever the build machine had. |
| `REKEY_COOKIE_SECURE` | Optional, and only for serving over plain HTTP on a non-localhost hostname. Otherwise the browser refuses the session cookie and sign-in silently never sticks. |
Then:
```bash
npm run dev
```
Create an account at `/sign-up` and you are signed in. The billing pages stay
empty until you create a plan, which is the next section.
## What is where
| File | What it does |
| --- | --- |
| `lib/rekey.ts` | The server client. Holds the secret key. |
| `app/layout.tsx` | Reads the session on the server, hands it to the header and `<RekeyProvider>`. |
| `proxy.ts` | A cookie-presence gate at the edge, with the public routes listed. |
| `lib/session.ts` | `getSession()`. Use this in pages, not `auth()`. |
| `lib/safe-path.ts` | Reduces a `?next=` value to a path on this site. |
| `lib/app-url.ts` | Where this app is reachable, and why checkout resolves it before its try/catch. |
| `lib/form-state.ts` | What a server action hands back to its form. |
| `lib/auth-methods.ts` | Asks the Application which sign-in methods it offers. |
| `lib/auth-shared.ts` | The bits both server and browser need: cookie names, provider labels. |
| `lib/mfa-challenge.ts` | Where a half-finished two-factor sign-in is parked. |
| `lib/rekey-error.ts` | Turns a `RekeyError` into a sentence, `fix` included. |
| `app/actions/oauth.ts` | Starts an OAuth sign-in and issues the `state` guard. |
| `app/actions/magic-link.ts` | Emails a sign-in link. |
| `app/actions/password-reset.ts` | Request a reset, then spend the token. |
| `app/actions/verification.ts` | Re-send the verification email. |
| `app/api/auth/oauth/[provider]/callback` | Verifies `state`, exchanges the code, sets cookies. |
| `app/api/auth/magic-link` | Spends the emailed token and sets cookies. |
| `app/api/auth/verify-email` | Marks the address verified. Creates no session, on purpose. |
| `app/verify-banner.tsx` | Signed in, address unproven, with a resend button. |
| `app/api/session/refresh` | The one place allowed to write refreshed cookies. |
| `app/actions/auth.ts` | Sign up, sign in, sign out. |
| `app/actions/billing.ts` | Plan click to hosted checkout URL, then redirect. |
| `app/actions/billing-manage.ts` | Cancel at period end. |
| `app/actions/credits.ts` | A metered feature, done safely. |
| `app/site-header.tsx` | The header, rendered from the server session so it never flips after paint. |
| `app/header-nav.tsx` | Current-route marking, the user dropdown, the narrow-viewport menu. |
| `app/submit-button.tsx` | One submit button: disables itself and says what it is doing. |
| `app/sign-in`, `app/sign-up` | Plain forms plus the actions above. |
| `app/pricing` | A card per plan, plus the choice of who to pay. |
| `app/dashboard` | A page that guards itself and reads entitlements server-side. |
| `app/account` | Plan status, cancel, credit balance, spend a credit. |
## Auth
Three server actions are the entire integration. They set the session cookie
themselves, so there is nothing to store and nothing to thread through your app.
```ts
// app/actions/auth.ts
import { signIn, signUp, signOut } from '@rekey.dev/nextjs/server';
```
The forms in `app/sign-in` and `app/sign-up` are ordinary markup around those
actions, roughly fifty lines each. `@rekey.dev/react` also ships `<SignIn>` and
`<SignUp>` if you would rather drop in a prebuilt card; either way the actions
are the part that matters.
### Reading the session
```ts
import { getSession } from '@/lib/session';
const session = await getSession(); // { user, accessToken } | null
session?.user.email;
```
Use `getSession()` rather than `auth()` directly in anything that renders. This
is not stylistic. `auth()` refreshes an expired token by *writing* cookies, Next
forbids cookie writes during a render, and the resulting throw is not catchable
into a null: it becomes a 500 on every route, sign-in included, fifteen minutes
after a user signs in. `lib/session.ts` turns that into a trip through
`app/api/session/refresh`, which is allowed to write cookies. `proxy.ts` usually
gets there first.
Anything whose appearance depends on being signed in should read the session on
the server and take it as a prop. The header does exactly that: `app/layout.tsx`
calls `getSession()` once and passes it to `<SiteHeader>`, so the first paint is
already correct and a signed-in visitor never watches "Sign in" turn into their
own email.
`useUser()`, `<SignedIn>` and `<SignedOut>` are available for client components
that genuinely need the session in the browser, and `app/layout.tsx` seeds
`<RekeyProvider>` so they start with the right answer. One thing to know before
relying on them: the provider re-checks the session against the API from the
browser on mount, and **treats any failure as signed out**. If the origin you
are serving from is not in the Application's allowed origins, that request is
blocked by CORS and those components flip to their signed-out state a moment
after hydration. Add every origin you use, including
`http://localhost:3000`, under your Application in the panel.
### Protecting a route
Pages guard themselves:
```tsx
const session = await getSession();
if (!session) redirect('/sign-in');
```
Three lines at the top of the page, and the answer to "does this route need a
session" lives in the route.
`proxy.ts` also runs, and it does two things. It sends anyone holding a refresh
token but no access token through `/api/session/refresh` first, so a stale
session is repaired before a page renders rather than crashing it. And it checks
that a session cookie is **present**, redirecting everyone else to sign-in.
It never calls Rekey, so it costs nothing per request and it cannot know whether
a token is still valid. It is the doormat; `getSession()` in the page is the
lock. Keep both: the proxy means a page you forget to guard is protected by
default, and the page check catches a token that was revoked rather than merely
expired.
### The sign-in page builds itself
Nothing on `/sign-in` is hardcoded. `lib/auth-methods.ts` asks the Application
what it offers and the form renders that:
```ts
const { password, magicLink, oauthProviders } = await authMethods();
```
`password` and `magicLink` come from `authConfig.methods` via
`applications.me()`. Turn magic link on in the panel and a second tab appears;
turn password off and its fields disappear. An Application with no methods at
all says so instead of rendering a form that cannot work.
**OAuth providers are the exception, and it is a gap rather than a shortcut.**
The credentials live in `Application.oauthConfig`, and nothing readable with an
Application key returns which providers are configured: `applications.me()`
returns `authConfig` and `billingConfig` and stops there, and the only route
that reads `oauthConfig` needs an operator token. So the kit is told, via
`REKEY_OAUTH_PROVIDERS`. Unset means no buttons and no divider. If a discovery
endpoint appears, `lib/auth-methods.ts` is the single place to change.
### OAuth
Two calls and one piece of bookkeeping:
```ts
const { authorizationUrl } = await rekey().auth.startOAuth(provider, state); // redirect here
const outcome = await rekey().auth.completeOAuth(provider, code); // at the callback
await createSession(outcome); // same cookies as password
```
Register this redirect URI with the provider and in the panel:
```
{APP_URL}/api/auth/oauth/{provider}/callback
```
**`state` is not optional.** Without it, anyone can hand your callback a `code`
obtained in their own browser and your server will mint a session for *their*
account inside *your* user's browser. `app/actions/oauth.ts` puts a fresh random
value in an httpOnly cookie before redirecting, and the callback refuses a
missing cookie, a mismatched value, **and a provider in the URL that is not the
one the flow started with**. That last check matters because the provider
segment is caller-controlled: without it a code from a provider an attacker
controls can be redeemed against a state issued for one the user trusts.
The session lands in the same `rekey_access` / `rekey_refresh` cookies the
password flow uses, so `getSession()`, `proxy.ts` and the header cannot tell the
two apart and do not need to.
### Magic link
`/sign-in` grows an "Email link" tab when the Application has `magic_link`
enabled. The request is enumeration-safe, so the confirmation says "if that
address has an account" whether or not it does. Undoing that turns the form into
a way to test who your customers are.
The emailed link lands on `/api/auth/magic-link`, which spends the token and
sets the session cookies. Consuming it also proves the mailbox, so Rekey marks
the address verified as a side effect.
### Email verification
`sendVerificationEmailOnSignUp` is on for most Applications, so a brand new user
is signed in with an unverified address. `app/verify-banner.tsx` is that state
made visible, with a resend button, on `/dashboard`.
The resend uses `resendVerificationEmail({ email })`, not
`sendVerificationEmail(accessToken)`, and the difference is a dead end worth
knowing. If the Application has `requireEmailVerification` on, an unverified user
is refused the very session the token-based call needs, so the person most likely
to need another email is exactly the person who cannot ask for one with it. The
sessionless call always works.
The link lands on `/api/auth/verify-email`, which marks the address verified and
deliberately does **not** create a session: the link travels by email, and a
forwarded message should not be a way into the account.
### Forgot password
`/forgot-password` asks, `/reset-password` sets. Same enumeration-safe wording.
Resetting revokes every existing refresh token, which is the point, so the kit
redirects to sign-in rather than creating a session and quietly undoing the
"signed out everywhere" the user just triggered.
### MFA
A user with two-factor enabled can sign in. `signIn()` returns
`{ kind: 'mfa_required', mfaChallengeToken }` instead of a session, the form
swaps itself for a code field, and `mfaVerify({ mfaChallengeToken, code })` sets
the cookies exactly like `signIn` does. OAuth and magic-link sign-ins hit the
same challenge and land on the same step.
**The challenge token goes in an httpOnly cookie, not a hidden field.** It is
not a session, but it plus a phished code is a complete sign-in, and a hidden
field is readable by any script on the page. `lib/mfa-challenge.ts` also expires
the cookie exactly when the API expires the challenge, so an abandoned attempt
cannot be resumed later on a shared machine. `mfaVerifyAction` reads it
server-side rather than taking it as an argument, because every export of a
`'use server'` file is a public endpoint and a caller who can supply a challenge
token does not need this form.
**Enrolment is not built here.** Turning MFA on for an account needs
`mfaSetup()`, a QR code, and somewhere to show the ten backup codes once, which
is an account-settings screen rather than an auth flow. The SDK calls are
`mfaSetup(accessToken)`, `confirmMfaSetup(accessToken, code)` and
`disableMfa(accessToken)`. What this kit guarantees is that an account which
already has MFA on is not locked out of it.
## Forms
Every form that changes something follows the same three rules, because the
alternative is a page that looks broken while it is working.
**The button reports itself.** `app/submit-button.tsx` is a client component
that calls `useFormStatus()`, so it disables and relabels itself for as long as
the action is in flight. It has to be a separate component: `useFormStatus()`
reads the nearest `<form>` **above** it, so calling it in the component that
renders the form returns `pending: false` forever.
```tsx
<form action={formAction}>
<input type="hidden" name="planSlug" value={plan.slug} />
<SubmitButton block pendingLabel="Opening checkout…">Buy {plan.name}</SubmitButton>
</form>
```
Disabling also means the form cannot be submitted twice, including with the
Enter key, which needs an enabled submit button to work.
**Failures come back as values.** Actions return `{ error }` rather than
redirecting to `?error=...`, and the form renders it next to the fields. A
message in the query string survives a bookmark, replays on reload, and takes
the typed input with it when the page navigates.
**Kept input is kept deliberately.** React 19 resets an uncontrolled form after
every action submit. If a wrong password should not also clear the email, that
field has to live in React state rather than the DOM, which is what the sign-in
and sign-up forms do.
## Billing
### Create a plan first
Panel, then your Application, then Billing, then Plans. Give it a slug, a price
and an interval, then add entitlements: feature flags, numeric limits, or a
credit grant. Those entitlements are what your app reads later.
Connect a provider (Stripe, Razorpay, PayPal or Paddle) under Billing then
Providers, or checkout will have nothing to redirect to.
### Selling
`/pricing` reads plans from the API, so no prices are hardcoded here. Edit a
plan in the panel and the page follows.
```tsx
const plans = await rekey().billing.getPlans({ limit: 20 })
.then((r) => r.items.filter((p) => p.active));
{plans.map((p) => <PlanCard key={p.id} plan={p} current={p.slug === currentPlanSlug} />)}
```
Each card posts `planSlug` to your action, which creates the checkout session:
```ts
const origin = appUrl(); // resolved BEFORE the try, see below
const { url } = await rekey().billing.createCheckout(session.accessToken, {
planSlug,
successUrl: `${origin}/dashboard?checkout=done`,
cancelUrl: `${origin}/pricing?checkout=canceled`,
});
redirect(url);
```
**Read your configuration before the try/catch, not inside it.** `APP_URL` being
unset is your problem, not the buyer's declined card, and catching both in the
same place turns "APP_URL is not set" into "Could not start checkout" on a
customer's screen, destroying the one message that named the real cause. That
mistake is expensive to debug precisely because checkout looks broken end to end
when the only fault is a blank environment variable. `/pricing` also warns about
it in development, where the mistake is usually made.
`@rekey.dev/react` ships a `<PricingTable>` and a `<ProviderPicker>` if you
would rather not write the cards yourself.
### Letting the buyer choose who they pay
An Application can have Stripe, PayPal and Razorpay enabled at once. With no
choice offered, every buyer goes through whichever one the server's geo router
picks. That is a fine default and a bad answer in any market where one of the
others is the one people actually use.
`/pricing` fetches the enabled providers next to the plans and renders a picker:
```ts
const providers = await rekey().billing.getProviders().then((r) => r.providers);
```
They come back **already ordered by the geo router** for the caller's country.
Keep that order. Re-sorting it in your UI means the list quietly disagrees with
the provider checkout would have picked on its own. The selection defaults to
the first entry for the same reason: a buyer who never touches the picker
should get exactly what the auto-pick would have given them.
The choice rides along as `provider` in the same form that posts `planSlug`,
and the action passes it through **only when it is non-empty**:
```ts
const provider = String(formData.get('provider') ?? '').trim();
await rekey().billing.createCheckout(session.accessToken, {
planSlug,
...(provider ? { provider } : {}), // absent means "you route it"
successUrl, cancelUrl,
});
```
That emptiness check is load-bearing. Omitting `provider` hands routing to the
geo router, which is what "no preference" should mean. Posting an empty string
is a *value*, and one that matches no configured provider, so checkout is
refused with `BILLING_PROVIDER_NOT_AVAILABLE` for every buyer who never touched
the picker. A provider that was disabled since the page loaded fails the same
named way, which is the point: better than silently charging the buyer through
somebody they did not choose.
Three counts, three behaviours:
| Providers | What renders |
| --- | --- |
| Two or more | The picker, in server order, first one selected. |
| Exactly one | Nothing extra. A one-option radio group is a question with one answer, and the auto-pick lands on that provider anyway. |
| None | A notice, and the Buy buttons disabled rather than failing on click. |
A failed providers lookup is deliberately **not** the same as "none configured".
The server can still auto-pick, so the page says nothing and lets it, rather
than disabling checkout on a site that can take money perfectly well.
The subscription stays `PENDING` until the provider webhook confirms payment.
Rekey handles that webhook; you do not need an endpoint for it.
### Checking what someone is allowed to do
```ts
const { features, creditBalance } = await rekey().billing.getEntitlements(session.accessToken);
if (!features.export_csv) return notAllowed();
```
Do this on the server. A client-side check is a hint for your UI, not a gate.
One thing worth knowing before you price anything: where two subscriptions grant
the same numeric entitlement, the higher value wins, they are not added
together. So ten copies of a one-seat plan is not a ten-seat plan. Sell a
ten-seat plan.
### Credits
Check, do the work, then deduct, in that order, so a failure costs the user
nothing:
```ts
const { creditBalance } = await rekey().billing.getEntitlements(session.accessToken);
if (creditBalance < 1) return { ok: false, reason: 'no-credits' };
// ... the work ...
await rekey().credits.consume({ endUserId: session.user.id, amount: 1, idempotencyKey });
```
Pass something stable as `idempotencyKey` (a job id, a request id) and a retry
becomes a no-op instead of a double charge.
**Derive that key on your server.** Every export of a `'use server'` file is a
public endpoint, so a key accepted as a parameter is a key the caller controls:
send the same one every time and the first call deducts while every later call
is deduped, doing the work for free. `app/actions/credits.ts` builds it from the
unit of work instead.
The balance check is also not a lock. Two requests at balance 1 both pass it, so
the deduction is the real arbiter and its 402 has to be caught.
### Cancelling
`cancelSubscription()` asks for cancellation at period end, so the user keeps
what they paid for. A provider-backed subscription therefore stays `ACTIVE`
with `cancelAt` set, and the provider webhook is what eventually ends it. Read
`cancelAt` rather than waiting for `status` to flip.
`isCancelScheduled()` and `cancelEffect()` answer different questions, and
swapping them is a bug worth avoiding by name because this starter shipped with
it:
- **`isCancelScheduled(subscription)`** asks whether this is *already* scheduled
to end.
- **`cancelEffect(subscription)`** asks what cancelling *now* would do: does the
user keep the rest of the period (`'period-end'`), or does access stop on
click with no refund?
The second returns `'period-end'` for every healthy subscriber. Use it to word
the button and it reads
correctly; use it to mean "already ending" and the cancel button disappears for
everyone who could have used it, while a `PAST_DUE` subscriber gets a button
labelled "cancel at period end" that actually ends their access immediately.
## Deploying
Set the same environment variables, with `APP_URL` pointing at your real origin,
and add that origin to the Application's allowed origins in the panel.
`APP_URL` is read per request, so it can be injected at container start.
`NEXT_PUBLIC_*` cannot: those are substituted into the bundle when you build. An
image built in CI without them keeps whatever the build machine had, which is
how a buyer ends up returned to `localhost` after paying.
Anywhere that runs Next.js works; there is nothing platform-specific here.
## Notes
- On Next 15, rename `proxy.ts` back to `middleware.ts`. Same export, older file
convention.
- Organizations are supported but not used here. Turn them on if a company,
rather than a person, is the thing that buys your product. There is
[a guide](https://rekey.dev/blog/when-to-use-organizations).
## Licence
MIT. Take it apart.
看看同类模板
对比同技术栈与用途的模板,找到更适合的视觉与内容结构。

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 · 启动器
