# SaaS starter: sign-up, subscriptions and email on Next.js: build brief

Assembled by AgentGid's starter kit builder from recipe `saas-starter` v0.3 (https://agentgid.com/build/kits/saas-starter/). Data snapshot: 2026-10-12.
Constraints: commercial use; free parts only; crediting authors is fine.
Recipe status: test-built on 2026-10-12 (2 builds) with exactly these parts. The fixes from the last build are in this version, which has not been built again as a whole.
Licenses and prices change. Before you ship, open the linked AgentGid pages and confirm.

## What to build

A Next.js 16 app: sign-up and sign-in, a protected dashboard, a paid plan through Stripe Checkout with webhooks that switch the plan, and email for new accounts. Unit and end-to-end tests run without any real account or key.
Users, sessions and plans live in your own PostgreSQL through Better Auth and Drizzle: no auth vendor.

## Definition of done

- [ ] `npm run build`, `npm run lint` and `npm test` pass, and `npm run test:e2e` passes against the production build.
- [ ] The end-to-end test signs up a new user in a real browser and lands on `/dashboard` showing the free plan; opening `/dashboard` signed out redirects to `/sign-in`.
- [ ] The end-to-end test clicks Upgrade, is sent to a Stripe Checkout URL (stubbed in the test), then posts a signed `checkout.session.completed` webhook and sees the dashboard show the paid plan after a reload.
- [ ] The webhook route answers 400 to a wrong signature, processes the same event id only once, and a `customer.subscription.deleted` event moves the user back to free (unit tests).
- [ ] No real account or key is needed for any test: Stripe keys are dummy `sk_test_` and `whsec_` values, and email goes to `.outbox/` when `RESEND_API_KEY` is empty.
- [ ] Unit tests run the billing code on PGlite with the real migrations; the end-to-end test uses a local PostgreSQL from `DATABASE_URL`.
- [ ] `.env.example` lists every variable the app reads; secrets never use the `NEXT_PUBLIC_` prefix, and `.env` is git-ignored.
- [ ] The kit files are still there, and AGENTS.md still starts with this brief (the Next.js block that `next dev` appends at the end is fine).

Left for the person, because no check can do it:

- Create a Stripe account, stay in test mode, and create a product with a monthly price: its id goes into `STRIPE_PRICE_ID`. Pay once with Stripe's test card to watch the real flow.
- For a real webhook while developing, install the Stripe CLI and run `stripe listen --forward-to localhost:3000/api/stripe/webhook`; it prints the `whsec_` secret for `.env`.
- Create a Resend account and verify a sending domain before real users sign up; until then emails land in `.outbox/`.
- Choose where the app and its database run in production; switch Stripe to live mode only after a full test-mode run there.

## Before you start

These files are already in this folder. Keep them, and never let a scaffolder overwrite them:

- `AGENTS.md`: this brief.
- `CLAUDE.md`: one line that imports AGENTS.md for Claude Code.
- `.mcp.json`: the MCP servers listed under "Tools you have".
- `CREDITS.md`: the license record. Add to it as you add files.
- `kit.json`: this kit as data (parts, versions, checks). `install.txt`: the install commands below. `first-prompt.txt`: the message the person starts you with.

You may do these without asking:

- Install the packages in the Stack section.
- Read the documentation this brief links to.
- Run `npx create-next-app@16.4.0` and `npx auth@1.7.7`.
- Run `npx playwright install chromium` once to download the test browser.
- If Go or Docker is available, install and run `stripe-mock` for the Stripe client tests; otherwise mock the Stripe client in those tests and say so in PROGRESS.md.
- Start a local PostgreSQL for development and tests, bound to 127.0.0.1.
- Use `git`.

Stop and ask the person for:

- an account, a key or a payment;
- a runtime dependency, asset source or API that this brief does not list;
- a check that still fails after two real fixes (say what you tried).

## Stack

Use these parts and versions. Ask before swapping one.

| Part | Choice | Version | Kind |
|---|---|---|---|
| Runtime | Node.js 22.12 or newer | - | runtime |
| Framework | Next.js | 16.4.0 | dependency |
| UI library | React | 19.3.0 | dependency |
| Language | TypeScript 5.9 | 5.9.3 | dev dependency |
| Styling | Tailwind CSS | 4.3.3 | dev dependency |
| Sign-in | Better Auth | 1.7.7 | dependency |
| Database access | Drizzle ORM | 0.45.4 | dependency |
| Database | PostgreSQL 16 or newer | - | runtime |
| Payments | Stripe | - | hosted API |
| Email | Resend | - | hosted API |
| Unit tests | Vitest | 5.0.4 | dev dependency |
| End-to-end tests | Playwright (JavaScript) | 1.64.0 | dev dependency |
| Also | React DOM (same version as React) | 19.3.0 | dependency |
| Also | Stripe (JavaScript) (Checkout sessions and webhook signatures) | 23.0.0 | dependency |
| Also | Tailwind CSS Turbopack loader (Tailwind in Next's default bundler) | 4.3.3 | dev dependency |
| Also | @types/node 22 (Node types matching the runtime; Vitest 5 needs 22 or newer) | 22.20.5 | dev dependency |
| Also | PGlite (a real Postgres inside unit tests) | 0.5.9 | dev dependency |
| Also | Drizzle Kit (database migrations) | 0.31.11 | dev dependency |
| Also | node-postgres (pg) (the PostgreSQL driver) | 8.23.1 | dependency |
| Also | @types/pg (types for pg) | 8.23.1 | dev dependency |
| Also | Resend (Node.js) (sends email through Resend) | 6.32.1 | dependency |

Install exactly these versions (the same lines are in `install.txt`):

```sh
node --version   # must print v22.12 or newer
# scaffold first (Plan, step 1), then:
npm install --save-exact next@16.4.0 react@19.3.0 better-auth@1.7.7 drizzle-orm@0.45.4 react-dom@19.3.0 stripe@23.0.0 pg@8.23.1 resend@6.32.1
npm install -D --save-exact typescript@5.9.3 tailwindcss@4.3.3 vitest@5.0.4 @playwright/test@1.64.0 @tailwindcss/turbopack@4.3.3 @types/node@22.20.5 @electric-sql/pglite@0.5.9 drizzle-kit@0.31.11 @types/pg@8.23.1
```

System packages (use the OS package manager): postgresql (version 16 or newer, for development and end-to-end tests (on macOS without Homebrew: Postgres.app)).

Checks that apply to this kit:

- Next.js 16 changed several APIs that most examples online still use (`middleware.ts`, synchronous `cookies()`, `next lint`). Follow this brief and the docs bundled with the installed version.

## Rules for this project

- The server is the only place that decides a user's plan: the plan changes only in the webhook handler, after the signature check, never from a redirect back from Checkout.
- Secrets (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY`, `BETTER_AUTH_SECRET`, service keys) are read only in server code and never get the `NEXT_PUBLIC_` prefix.
- Create the Stripe and Resend clients lazily, when a request needs them: `new Resend()` without a key throws at once, which would break the build and every test.
- Keep billing logic in `src/lib/billing.ts` as plain functions that take the database and an event; the route only verifies the signature and calls them.

### Next.js, from its AgentGid page

- Read the docs for the Next.js version in the project's `package.json`, not the latest. Versioned copies live at `/docs/{version}/...`.
- Check whether the project uses the App Router (`/docs/app/`) or the Pages Router (`/docs/pages/`), and answer only from that section.
- Prefer nextjs.org pages over recalled knowledge for `next.config.js` options, file conventions, `next/*` APIs and CLI flags. The maintainers say older answers are frequently wrong.
- When an error links to `nextjs.org/docs/messages/...`, open that page. It explains the cause and the fix.
- Fetch docs as markdown with `Accept: text/markdown` or by appending `.md`, but cite the canonical URL, never the `.md` one.
- For upgrades, use `/docs/app/guides/upgrading` for migration guides and `/blog` for what changed in a release.
- The docs site has no REST API and no API keys. Do not ask the user to sign up for anything to read it.
- More: https://agentgid.com/tools/next/

### React, from its AgentGid page

- Start component names with a capital letter. HTML tags stay lowercase.
- Close every JSX tag (`<br />`). Wrap multiple returned tags in one parent or an empty `<>...</>` wrapper.
- Give each list item a `key` that is a string or number from the data, such as a database ID, unique among its siblings.
- Pass event handlers without calling them: `onClick={handleClick}`, with no parentheses at the end.
- Call Hooks such as `useState` only at the top of components or other Hooks.
- With React Compiler, rely on the compiler for memoization in new code. In existing code, leave `useMemo`/`useCallback` in place or test carefully before removing them.
- React does not do routing or data fetching. For a full app, use a framework such as Next.js or React Router.
- More: https://agentgid.com/tools/react/

### Playwright (JavaScript), from its AgentGid page

- Install with `npm i -D @playwright/test`, then run `npx playwright install` to download the browsers.
- Locate elements the way users see them: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByTestId`. Avoid CSS paths.
- Do not add artificial timeouts. Use `await expect(...)` web-first assertions, which retry until the condition is met.
- Save login state once with `page.context().storageState({ path: 'auth.json' })` and reuse it with `test.use({ storageState: 'auth.json' })`.
- Set `trace: 'on-first-retry'` in `playwright.config.ts` and open failures with `npx playwright show-trace trace.zip`.
- To drive a browser yourself, prefer `playwright-cli`: its output is concise and keeps tool schemas out of context. Act on the element refs from the latest snapshot.
- More: https://agentgid.com/tools/playwright-test/

### Docs for lookups

Not required reading. Each plan step names what to read first.

- llms.txt (index of the docs) for Next.js: https://nextjs.org/llms.txt
- llms.txt (index of the docs) for React: https://react.dev/llms.txt

## Tools you have

These MCP servers are in the config file listed under "Before you start".

- Context7: Up-to-date library docs for the agent
- AgentGid: Live data for choosing tools, assets and APIs while building

If a server's tools are missing from your session, either the person has not approved them yet (Claude Code holds the servers of a project `.mcp.json` as "Pending approval" until the person accepts them), or the config was added after the session started. Ask the person to approve the servers or restart the agent in this folder; until then, use the links in this brief.

## Plan

Work one step at a time. Do not start a step until the previous one meets its "Done when" lines.

### 1. Scaffold

- Check `node --version` (22.12 or newer).
- create-next-app refuses a folder that holds AGENTS.md, CLAUDE.md, .mcp.json, kit.json or README.md, and no flag overrides it. Scaffold next to this folder and copy in without overwriting:

  ```sh
  CI=1 npx create-next-app@16.4.0 ../app-scaffold --yes --src-dir --skip-install --disable-git
  node -e "require('fs').cpSync('../app-scaffold', '.', { recursive: true, force: false })"
  rm -rf ../app-scaffold
  npm pkg set name=my-saas
  ```

- `--src-dir` matters: without it `--yes` writes `app/` at the top and maps `@/*` to `./*`, while every later step uses `src/` and `@/db`. The scaffold name must not start with a dot (npm naming rules). The copy keeps every kit file byte for byte; the scaffold's own AGENTS.md is not copied.
- Run the install lines of the Stack section. They pin the versions; the scaffold's `@types/node` 20 must be replaced (Vitest 5 wants 22 or newer), which the install lines do.
- The scaffold turns on `cacheComponents` in `next.config.ts` and sets up Tailwind through `@tailwindcss/turbopack`. Keep both.
- When `next dev` runs under a coding agent, Next.js appends a block between `<!-- BEGIN:nextjs-agent-rules -->` markers to AGENTS.md that points at the docs bundled in `node_modules/next/dist/docs/`. Keep it and use those docs; `agentRules: false` in next.config removes it.
- Scripts: keep `dev`, `build`, `start`, `lint` (`eslint`; `next lint` no longer exists in Next 16); add `test` (`vitest run --passWithNoTests`) and `test:e2e` (`playwright test`).
- `git init`. The scaffold's `.gitignore` ignores `.env*`, which also hides `.env.example` from git: add `!.env.example`, plus `.outbox/` and `test-results/`. Write a README.md and a `.env.example`.
- Name the Vitest config `vitest.config.mts`: the scaffold's package.json has no `"type": "module"`, and Vitest 5 warns about ESM syntax in a `.ts` config.
- Read first: `https://nextjs.org/docs/app/guides/upgrading/version-16`
- Done when:
  - `npm run build` and `npm run lint` pass on the empty app.
  - The kit files are unchanged, apart from CREDITS.md rows you add and the block `next dev` appends to AGENTS.md.

### 2. Accounts

- Database: `src/db/index.ts` exports `drizzle(new Pool({ connectionString: process.env.DATABASE_URL }), { schema })` from `drizzle-orm/node-postgres`. `drizzle.config.ts` uses `dialect: 'postgresql'`.
- `src/lib/auth.ts`:

  ```ts
  import { betterAuth } from 'better-auth';
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
  import { nextCookies } from 'better-auth/next-js';
  import { db } from '@/db';
  import * as schema from '@/db/schema';   // re-exports auth-schema.ts and your own tables
  
  export const auth = betterAuth({
    database: drizzleAdapter(db, { provider: 'pg', schema }),
    emailAndPassword: { enabled: true, autoSignIn: true },
    user: { additionalFields: {
      plan: { type: 'string', required: false, defaultValue: 'free', input: false },
      stripeCustomerId: { type: 'string', required: false, input: false } } },
    plugins: [nextCookies()],   // keep it last
  });
  ```

- `input: false` stops a user from setting their own plan through the sign-up form.
- Generate the auth tables, then migrate with Drizzle: `npx auth@1.7.7 generate --config src/lib/auth.ts --output src/db/auth-schema.ts --yes`, then `npx drizzle-kit generate` and `npx drizzle-kit migrate`. The old `@better-auth/cli` package is deprecated, and `npx auth migrate` works only with Better Auth's built-in Kysely adapter. The first generate logs "Drizzle schema mismatch ... Missing tables" before it writes the file: that is expected.
- Import the adapter from `better-auth/adapters/drizzle`, as above: it ships inside better-auth 1.7.7, so the separate `@better-auth/drizzle-adapter` package that the Better Auth docs mention is not needed.
- `src/app/api/auth/[...all]/route.ts`: `export const { GET, POST } = toNextJsHandler(auth);` (from `better-auth/next-js`). The browser side uses `createAuthClient()` from `better-auth/react`: `authClient.signUp.email({ name, email, password })`.
- `BETTER_AUTH_SECRET` (32 characters or more; `openssl rand -base64 32`) and `BETTER_AUTH_URL` go into `.env`. In production Better Auth throws without the secret; in development it only warns. `BETTER_AUTH_URL` must be the exact origin the browser uses: with `http://localhost:3000` in `.env` and the browser on `127.0.0.1:3000`, sign-up fails with 403 "Invalid origin". The end-to-end run uses `127.0.0.1`, so give it `BETTER_AUTH_URL=http://127.0.0.1:3000`.
- Pages: `/sign-up`, `/sign-in`, and a sign-out button on the dashboard.
- Read first: `https://www.better-auth.com/docs/integrations/next`; `https://www.better-auth.com/docs/adapters/drizzle`
- Done when: A user can sign up, sign out and sign in again in the browser, and the user row has `plan = 'free'`.

### 3. Protected dashboard

- Guard `/dashboard` in `src/proxy.ts` (Next 16 renamed `middleware.ts` to `proxy.ts`, and the exported function must be named `proxy` or be the default export; having both files fails the build). The proxy runs on Node.js only.
- In the proxy, `getSessionCookie(request)` from `better-auth/cookies` is a quick check that a cookie exists; it does not validate it. The page itself checks the real session:

  ```tsx
  export default function DashboardPage() {
    return <Suspense fallback={<p>Loading…</p>}><Dashboard /></Suspense>;
  }
  async function Dashboard() {
    const session = await auth.api.getSession({ headers: await headers() });
    if (!session) redirect('/sign-in');
    return <p>Plan: {session.user.plan}</p>;
  }
  ```

- With `cacheComponents` on, any component that reads `cookies()`, `headers()` or the session must sit inside `<Suspense>`; otherwise `next build` fails with "Next.js encountered uncached or runtime data during prerendering".
- `cookies()` and `headers()` return promises in Next 16: always `await` them.
- Done when:
  - `npm run build` passes and lists `/dashboard` as partially prerendered.
  - Signed out, `/dashboard` redirects to `/sign-in`; signed in, it shows the plan.

### 4. Billing

- `src/lib/stripe.ts`: a function that creates the Stripe client on first use from `STRIPE_SECRET_KEY`. Do not pass `apiVersion`: stripe 23.0.0 pins `2026-09-30.endive`, and the event shapes in this plan follow it. When `STRIPE_API_BASE` is set (for example `http://127.0.0.1:12111`), pass its `host`, `port` and `protocol` to the client, so unit tests and the end-to-end server can talk to stripe-mock; empty means the real API.
- `POST /api/checkout`: for the signed-in user, `stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: STRIPE_PRICE_ID, quantity: 1 }], customer_email, client_reference_id: user.id, metadata: { userId }, subscription_data: { metadata: { userId } }, success_url, cancel_url })`, then redirect to `session.url` with status 303.
- `POST /api/stripe/webhook` verifies the signature on the raw body before anything else:

  ```ts
  export async function POST(req: Request) {
    const body = await req.text();   // the raw body; never req.json()
    const sig = req.headers.get('stripe-signature');
    if (!sig) return new Response('missing signature', { status: 400 });
    let event: Stripe.Event;
    try { event = stripe().webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); }
    catch { return new Response('bad signature', { status: 400 }); }
    const applied = await applyStripeEvent(db, event);
    return Response.json({ received: true, duplicate: !applied });
  }
  ```

- `applyStripeEvent` runs in one transaction: insert the event id into a `stripe_event` table with `onConflictDoNothing().returning()`; an empty result means the event was already handled, so return. Then: `checkout.session.completed` sets the user (from `client_reference_id`) to the paid plan and stores the customer id; `customer.subscription.updated` and `.deleted` set the plan from the status (paid while `active` or `trialing`, free otherwise).
- Stripe does not deliver events in order: a `customer.subscription.updated` can arrive before `checkout.session.completed`. Find the user by `metadata.userId` (set on the subscription at Checkout) or by the stored customer id, and upsert the subscription row by its id, so any order gives the same result.
- The billing period end is on the subscription item now: `subscription.items.data[0].current_period_end`, not on the subscription.
- A "Manage billing" button opens the Stripe customer portal (`stripe.billingPortal.sessions.create({ customer, return_url })`).
- Tests sign their own payloads, so no Stripe account is needed: `stripe.webhooks.generateTestHeaderString({ payload, secret })` with the same dummy `whsec_` value as in the test environment.
- Dummy keys must look like real test keys: `sk_test_` followed by letters and digits only. stripe-mock answers 401 to `sk_test_unit_dummy` (an extra underscore), and a test that falls back to a mocked client then hides it.
- For the client calls (Checkout, portal), point the Stripe client at `stripe-mock` when it runs (`GOBIN=$PWD/.tools/bin go install github.com/stripe/stripe-mock@v0.206.0`, with `.tools/` git-ignored; a plain `go install` puts it in `$(go env GOPATH)/bin`, usually not on the PATH. The test setups take the binary from `STRIPE_MOCK_BIN`, else `.tools/bin`, else the PATH. Then `stripe-mock -http-addr 127.0.0.1:12111 -https-addr 127.0.0.1:12112`; the client needs `{ host: '127.0.0.1', port: 12111, protocol: 'http' }`). Without `-https-addr` it also listens on all interfaces; without `protocol: 'http'` the client fails with "An error occurred with our connection to Stripe".
- Read first: `https://docs.stripe.com/billing/subscriptions/build-subscriptions`; `https://docs.stripe.com/webhooks`
- Done when: Unit tests: a signed `checkout.session.completed` makes the user paid; the same event twice changes nothing the second time; a bad signature gives 400; `customer.subscription.deleted` makes the user free again.

### 5. Email

- `src/lib/email.ts` exports `sendEmail({ to, subject, html })`. With `RESEND_API_KEY` set it creates the Resend client and sends; without it, it writes the message as a file to `.outbox/`.
- `resend.emails.send()` does not throw on failure: it resolves `{ data: null, error }`. Check `error` and log it without the key.
- Send the welcome email from Better Auth's `databaseHooks.user.create.after`, so every way of signing up sends it.
- Done when: Signing up in the end-to-end test leaves a welcome email in `.outbox/`.

### 6. End-to-end test

- `playwright.config.ts` has two `webServer` entries: stripe-mock on 127.0.0.1:12111 (or, if it is not installed, a small Node server that answers `POST /v1/checkout/sessions` and `POST /v1/billing_portal/sessions` with a fake `url`), and the app (`npm run build && npm run start` on 127.0.0.1:3000) with `STRIPE_API_BASE` pointing at it and test values for every variable.
- A global setup drops, recreates and migrates a separate test database: `TEST_DATABASE_URL`, by default the `DATABASE_URL` name plus `_test`. Refuse to drop any database whose name does not end in `_test`, so development data is never wiped.
- With `cacheComponents`, Next.js keeps up to three visited routes in the page, hidden inside React's `<Activity>`, so after sign-up then sign-in `getByLabel('Email')` finds two fields and fails Playwright's strict mode. Locate inputs with `getByRole('textbox', { name: 'Email' })`, which skips hidden elements. Password inputs have no textbox role: use `getByLabel('Password').filter({ visible: true })`.
- The test signs up a unique user, checks the free plan, clicks Upgrade with `page.route('https://checkout.stripe.com/**', ...)` stubbing the Stripe page, posts a signed `checkout.session.completed` for that user to the webhook (twice, plus once with a bad signature), reloads, and expects the paid plan.
- If the installed browser does not match the one Playwright expects, `npx playwright install chromium` fetches it.
- Done when: `npm run test:e2e` passes twice in a row on a fresh database.

### 7. Verify

- Commit, make a clean copy of the repository in a temporary folder (`git clone . /tmp/check`, or a git-ignored `.check/` inside the project if you may not write outside it) and follow only the README there: install, migrate, build, run every test. A `.check/` copy also needs `".check/**"` in `globalIgnores` in eslint.config.mjs and `".check"` in `exclude` in tsconfig.json: otherwise the project's own `npm run lint` checks the copy's `.next/` and fails with thousands of problems.
- Add to CREDITS.md the packages that ship with the app but are not in the Stack: the `package-lock.json` entries that are not marked `dev`, `devOptional` or `peer`, with their licenses (Vitest, Playwright, drizzle-kit and PGlite are marked `devOptional`, not `dev`; `optional` entries such as sharp's binaries do ship). Do not use `npm ls --omit=dev`: better-auth and next declare test tools as optional peers, so it lists Vitest and Playwright as shipped. Expect a few non-MIT licenses in the real list, such as LGPL-3.0 for sharp's libvips binaries and CC-BY-4.0 for caniuse-lite.
- Write into PROGRESS.md what was not verified with a real account: real Checkout and the customer portal, real webhooks through the Stripe CLI, real email delivery.
- Done when: Every line under Definition of done is true, and anything that could not be verified is named in PROGRESS.md.

## How to check your work

- After each step, run `npm run build` as well as the tests: Next 16 finds missing `<Suspense>` boundaries and wrong exports only at build time.
- Read the webhook handler's database writes after every change, not only the test status.

## Known traps

- create-next-app 16.4.0 refuses a folder that contains AGENTS.md, CLAUDE.md, .mcp.json, kit.json or README.md ("contains files that could conflict"), and it has no flag to override that. Scaffold into a sibling folder and copy without overwriting. (measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- Next 16 renamed `middleware.ts` to `proxy.ts`. The old name still works with a deprecation warning; both files together fail the build; a `proxy.ts` that exports a function named `middleware` fails with "Proxy is missing expected function export name". (Next.js 16 upgrade guide, and measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- Synchronous `cookies()` and `headers()` are gone in Next 16: `cookies().get` fails type checking with "Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'". (Next.js 16 upgrade guide, and measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- `next lint` was removed and `next build` no longer lints; `npx next lint` now reads "lint" as a project folder. Run `eslint`. (Next.js 16 upgrade guide, and measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- With `cacheComponents` (on in a fresh create-next-app 16.4.0 project), a page that reads the session outside `<Suspense>` fails `next build` with "encountered uncached or runtime data during prerendering". (measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- `@better-auth/cli` is stuck at 1.4.21 and deprecated; the CLI is now the `auth` package (`npx auth@1.7.7 generate`). `auth migrate` refuses the Drizzle adapter: generate the schema, then migrate with Drizzle Kit. (Better Auth docs, and measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- `getSessionCookie()` only checks that a session cookie exists; it does not validate it. Check the session again in the page or route with `auth.api.getSession()`. (Better Auth docs)
- Stripe's `constructEvent` needs the raw body: a parsed object fails with "Webhook payload must be provided as a string or a Buffer", and a re-serialized body with "No signatures found matching the expected signature". In a route handler, read it with `await req.text()`. (Stripe docs, and measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- Stripe may deliver the same event more than once and does not guarantee order: record processed event ids, and never let the handler depend on which event came first. (Stripe docs)
- `new Resend()` with an empty or missing key throws "Missing API key" at once, so a client created at import time breaks the build and every test; `emails.send()` reports failures in its result instead of throwing. (measured with resend 6.32.1, Oct 12, 2026)
- Drizzle's PostgreSQL getting-started page installs the `@rc` (1.0 release candidate) versions of drizzle-orm and drizzle-kit. Keep the pinned 0.45.4 and 0.31.11 from the Stack; Better Auth 1.7.7 supports both lines. (Drizzle docs and Better Auth's peer dependencies, Oct 12, 2026)
- With `cacheComponents`, up to three visited routes stay in the page, hidden inside React's `<Activity>`: a Playwright locator such as `getByLabel('Email')` can match a hidden copy and fail strict mode. `getByRole` skips hidden elements. (measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- create-next-app's `.gitignore` ignores `.env*`, which includes `.env.example`; a clean clone then has no example file. (measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)
- Vitest 5 wants `@types/node` 22 or newer as a peer; create-next-app installs `^20`, and `npm install vitest` on its own then fails with ERESOLVE until `@types/node` is upgraded. The dev install line in install.txt upgrades both together, so it does not hit this. (measured with next 16.4.0, better-auth 1.7.7, drizzle-orm 0.45.4, stripe 23.0.0 and Node 22.22, Oct 12, 2026)

## Warnings you can ignore

- "Drizzle schema mismatch ... Missing tables user, session, account, verification" on the first `npx auth generate`: it is logged before the schema file is written.
- "Base URL is not set" from Better Auth when `BETTER_AUTH_URL` is missing in a test: set it in the test environment.
- `[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../src/lib/auth.ts is not specified` from `npx auth generate`: harmless.
- npm audit notices for dev-only packages (through drizzle-kit and eslint-config-next). They do not reach the app; do not run `npm audit fix --force`.

## Limits

- You are the merchant: Stripe's terms, your refund policy and the tax rules where your customers live apply to you, not to the code.
- Store as little about users as the app needs, and write a privacy policy before real sign-ups: the app holds email addresses and payment history.
- Card details never touch your server: they go straight to Stripe Checkout.
- Never read or print the contents of `.env` files, and never commit keys.

## When you need something that is not listed

Do not pick a library, asset source or API from memory. Ask the AgentGid MCP server first:

- `find_tools(goal)` for libraries, APIs and agents that fit a task.
- `find_assets(category, query, free_commercial_only=true)` for media sources and their license terms. It knows sources such as Kenney or Pixabay, not single packs, models or fonts.
- `get_api(api)` for prices, free tiers and limits of entries in AgentGid's API catalog. Media sources are not in it: use `find_assets`.
- `check_status(names)` when a service seems down.

Then tell the person what you want to add and why, cite the AgentGid URL, and wait for a yes before you add a runtime dependency.

## When this brief is wrong

Keep a list of what in this brief was wrong, missing or out of date, with the line and what worked instead (in PROGRESS.md). When you finish, send it to AgentGid's editors with `report_kit_issue(recipe="saas-starter", version="0.3", text=...)` on the AgentGid MCP server: only they read it. Leave out keys, personal data and the person's code.

## Sources

- https://nodejs.org/en/download
- https://agentgid.com/tools/next/
- https://agentgid.com/tools/react/
- https://agentgid.com/tools/typescript/
- https://agentgid.com/tools/tailwindcss/
- https://www.npmjs.com/package/tailwindcss
- https://agentgid.com/tools/better-auth/
- https://www.better-auth.com/docs/integrations/next
- https://agentgid.com/tools/drizzle-orm/
- https://orm.drizzle.team/docs/get-started-postgresql
- https://www.postgresql.org/download/
- https://stripe.com/pricing
- https://agentgid.com/apis/resend/
- https://agentgid.com/tools/vitest/
- https://agentgid.com/tools/playwright-test/
- https://agentgid.com/mcp/servers/io-github-upstash-context7/
- https://agentgid.com/connect/
- https://nextjs.org/docs/app/guides/upgrading/version-16
- https://www.better-auth.com/docs/adapters/drizzle
- https://docs.stripe.com/billing/subscriptions/build-subscriptions
- https://docs.stripe.com/webhooks
