# ShipKit > SaaS boilerplate on TanStack Start + Cloudflare Workers: auth, Stripe/Creem payments, credits, admin console and i18n, built for Claude Code and Cursor. Pay once. ShipKit is a SaaS starter template sold as a one-time license (https://shipkit.sh/pricing): a private GitHub repository the buyer builds their own product on. The docs below describe the template. Every page also exists in Chinese under https://shipkit.sh/zh/. --- # Introduction Source: https://shipkit.sh/docs/introduction > What ShipKit is, what the template ships with, the stack it is built on and why, and how the repository is laid out. ShipKit is a SaaS starter: a working application with sign-in, payments, credits, an admin console, email, i18n and a marketing site, running on TanStack Start and Cloudflare Workers. You clone it, rename it, delete what you do not need and build your product in the space that is left. It is written around one rule: **every optional feature must be deletable in minutes**. Each module lives in its own folder, touches shared files only in marked places, and ships with a deletion recipe that CI runs on every pull request to prove the app still builds without it. The second design goal is that a coding agent can work in the repo without breaking it: the conventions are written down in `CLAUDE.md` and `AGENTS.md`, and the important ones are enforced by tests rather than trusted. ## What you get | Area | What ships | | --- | --- | | Sign-in | Passwordless, through better-auth: Google (with One Tap), GitHub when configured, and a six-digit code emailed to any address. No passwords, so no reset flow | | Payments | Stripe and Creem behind one provider-neutral event layer: subscriptions, one-time credit packs, checkout, customer portal, refunds. Stripe is the default | | Credits | A ledger with monthly grants per plan, purchasable packs, overdraft-proof spending and admin adjustments | | Admin console | `/admin`, in five sections: overview (KPIs and charts), people (users, waitlist, feedback), revenue (subscriptions, orders, credits, affiliates), developers (API keys, audit log, email log) and settings (operator settings, roles, email previews) | | Permissions | Role-based access with wildcard grants. Each feature declares its permissions; the same grant gates the page, the server function and the API | | API | Read-only JSON APIs with two kinds of API key: `/api/v1/admin/*` for your own analysis, `/api/v1/me/*` for a customer's own data | | Email and notifications | Transactional email through Resend, localized per recipient, with a delivery log; an in-app notification bell | | Files | Uploads to R2, streamed through the Worker; profile photos | | i18n | English and Chinese through Paraglide, English unprefixed and `/zh/...` for Chinese | | Content | MDX blog, docs and legal pages (terms, privacy, refunds, DMCA), compiled at build time | | Marketing site | A landing page assembled from a config array of blocks, a pricing page, four visual themes with light and dark modes | | More modules | Audit log, feedback, waitlist, affiliate program, PostHog analytics, and a public demo mode | | Account self-service | Profile, language, data export as JSON, and account deletion with a retention window | Each of these has its own page under Features in the sidebar. ## The stack, and why | Layer | Choice | Why | | --- | --- | --- | | Framework | TanStack Start + TanStack Router and Query | Type-safe file routes, server functions and loaders in one React app, built on Vite | | Runtime | Cloudflare Workers | One deploy target at the edge; the dev server runs your code in workerd, the same runtime as production | | Database | Cloudflare D1 (SQLite) + Drizzle | A binding, not a connection string: no pool, no credentials, local and remote behave the same | | Storage | Cloudflare R2 | Also a binding, so uploads need no S3 keys or presigned URLs | | Auth | better-auth | Sessions and OAuth in your own database, with plugins for One Tap, emailed codes, admin and API keys | | Payments | Stripe or Creem | Both wired; `PAYMENT_PROVIDER` picks which takes new checkouts. See [Payments](/docs/payments) | | i18n | Paraglide | Messages compile to tree-shakable functions; no runtime catalog | | Content | MDX via `@mdx-js/rollup` | Compiled to ES modules at build time, because workerd forbids the runtime evaluation most MDX layers rely on | | UI | shadcn/ui + Tailwind CSS v4 | Components you own and edit, styled with tokens | | Tooling | bun, Biome, Vitest, Playwright | One package manager, one linter and formatter, unit and end-to-end tests | A few limitations come with these choices, and it is better to know them up front: - TanStack Start is still a v1 release candidate. Versions are pinned exactly; read the changelog before you bump them. - D1 has no interactive transactions. Multi-step writes use a single conditional statement or `db.batch()`, as described in [Database](/docs/database). - The template targets Cloudflare only. Moving to another host means replacing the bindings layer, not changing a setting. - In-app plan changes, failed-renewal handling and provider-side customer redaction are Stripe only. ## How the repository is laid out ``` src/ ├── routes/ # thin route files: guards and composition │ ├── _marketing/ # public pages, prerendered at build │ ├── _auth/ # login │ ├── _app/ # signed-in shell: dashboard, billing, credits, │ │ # files, settings and the /admin console │ └── api/ # auth, webhooks, file streams, the /api/v1 APIs ├── features/ # vertical slices: auth, billing, credits, files, ... ├── core/ # shared kernel: db, server, payment, email, authz, ... ├── config/ # app-config, plans, landing page, themes └── components/ # shared UI, the sidebar, marketing blocks content/ # blog, docs and legal pages as MDX messages/ # UI copy, en.json and zh.json drizzle/ # SQL migrations scripts/ # seed, deletion recipes, sitemap, marketing captures ``` A feature folder such as `src/features/credits/` holds its own schema, server functions, components and queries. It reaches the rest of the app through small registries in `src/core/`, one import line per feature, so removing a feature means deleting its folder and a handful of marked lines. [Architecture](/docs/architecture) explains how the pieces connect. Every read and write from the UI follows the same path: route loader, then TanStack Query, then a server function, then Drizzle. API routes are kept for external callers: auth, webhooks, file streams and the `/api/v1` APIs. [Server functions](/docs/server-functions) covers the details. ## Where to go next - [Getting started](/docs/getting-started): run the app locally and make yourself an admin. - [Working with AI agents](/docs/ai-agents): the skills and guard-rail tests, if you build with Claude Code, Codex or Cursor. - [Configuration](/docs/configuration): the config files, environment variables and admin-editable settings. - [Deploy](/docs/deploy): put it on Cloudflare. - [Deleting features](/docs/deleting-features): strip the modules you do not need before you start building. --- # Getting started Source: https://shipkit.sh/docs/getting-started > From clone to a running local app you are an admin of, in the order that works — install, secrets, database, dev server, first sign-in. ## Prerequisites - [Bun](https://bun.sh), the only package manager the repo uses. The version it expects is pinned in `package.json` under `packageManager`. - [Node.js](https://nodejs.org) 22 or newer. Wrangler and Vite run on Node even when you start them through `bun run`. - `openssl`, or any other way to generate a random secret. - A Google Cloud project for OAuth credentials, when you want the Google button to work. You can start without one: see step 2. - A Cloudflare account, only when you deploy. Local development needs no account. Everything runs locally: the dev server executes your code in workerd, the same runtime Cloudflare uses in production, with a local D1 database and a local R2 bucket on disk under `.wrangler/state`. ## 1. Clone and install Clone the ShipKit repository your GitHub account was given access to, then install dependencies: ```bash git clone my-app cd my-app bun install bun run cf-typegen # generates worker-configuration.d.ts, the Env types ``` `worker-configuration.d.ts` is gitignored, so every fresh clone needs `cf-typegen` once for the editor and `bun run typecheck` to see the Worker's types. If you plan to pull future ShipKit releases into your project, read [Upgrading](/docs/upgrading) before you rewrite history or rename the remote. ## 2. Create `.dev.vars` Local secrets live in `.dev.vars`, which is gitignored. `.dev.vars.example` is the full key list with a comment on each one; copy it: ```bash cp .dev.vars.example .dev.vars openssl rand -base64 32 # paste the output as BETTER_AUTH_SECRET ``` Four values are required before the app will serve a request that touches auth. The environment is validated on first use, and a missing or malformed key stops the request with an error naming it. | Key | Local value | | --- | --- | | `APP_URL` | `http://localhost:3000` (already set) | | `BETTER_AUTH_SECRET` | The random string you generated; at least 16 characters | | `GOOGLE_CLIENT_ID` | Your Google OAuth client id | | `GOOGLE_CLIENT_SECRET` | Its secret | If you do not have Google credentials yet, put any non-empty placeholder in both Google keys. Validation passes, every page renders, and you can sign in with an emailed code; only the Google button fails until real credentials are in place. The redirect URI to register with Google is `http://localhost:3000/api/auth/callback/google`. For the One Tap prompt, the same client id also goes into `googleClientId` in `src/config/app-config.ts`. [Authentication](/docs/authentication) covers the Google setup in full. Everything else in the file is optional and can stay empty for now: GitHub sign-in, the payment provider keys, `RESEND_API_KEY`. The app runs without them; [Configuration](/docs/configuration) lists what each one switches on. Environment is read when the dev server boots. Restart it after editing `.dev.vars`. ## 3. Create the local database ```bash bun run db:migrate:local ``` This applies every migration in `drizzle/` to the local D1 database. There is nothing to provision: the database is a file under `.wrangler/state`, created on first use. Optionally, fill it with demo data so the admin console and its charts are not empty: ```bash bun run db:seed # 90 days of users, subscriptions, orders, credits bun run db:seed --reset # removes the seed rows again, and nothing else ``` Seed rows all have ids starting with `seed-` and use fake email domains, so they never mix with accounts you create yourself. The data is deterministic, and dates are relative to the day you run it. ## 4. Start the dev server ```bash bun run dev # http://localhost:3000 ``` Open `http://localhost:3000`. The landing page, `/pricing`, `/docs`, `/blog` and their Chinese versions under `/zh` should all render. `/dashboard` redirects to `/login` until you sign in. If port 3000 is taken, an old dev server is usually still running; stop it before starting a new one. ## 5. Sign in Go to `/login`. There are up to three ways in, and all of them create the account on first use; there is no separate sign-up. - **Emailed code** is always available. Without `RESEND_API_KEY` nothing is sent; the code is printed to the dev server's terminal instead: ``` [auth] no mail provider — code for you@example.com: 123456 ``` Enter it on the login page. Codes expire after ten minutes. - **Google** works once real credentials are in `.dev.vars`. - **GitHub** appears only when both `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are set. After signing in you land on `/dashboard`. ## 6. Make yourself an admin A new account has no role. Promote yours in the local database, using the address you signed in with: ```bash bunx wrangler d1 execute DB --local \ --command "UPDATE user SET role='admin' WHERE email='you@example.com';" ``` Reload the page and an **Admin** entry appears in the sidebar, leading to `/admin`. The built-in `admin` role holds every permission; other roles can be created in the console under Settings → Roles, as described in [Permissions](/docs/permissions). ## With Claude Code If you use Claude Code, the `/setup` skill runs these steps for you: it installs, creates `.dev.vars` with a generated secret, migrates, starts the dev server, checks that pages respond and promotes your account once you have signed in. The steps that need a browser have their own skills: `/google-oauth` for the Google credentials, `/stripe` or `/creem` for payments, `/deploy` for Cloudflare. [Working with AI agents](/docs/ai-agents) describes all of them. ## Next steps - Go through [Configuration](/docs/configuration) before anything else. The app name, canonical URL, support address and legal entity in `src/config/app-config.ts` are placeholders, and they reach the marketing pages, every transactional email and the Terms and Privacy pages. - Remove the modules you do not need with [Deleting features](/docs/deleting-features), while the codebase is still untouched. - Wire payments with [Payments](/docs/payments). Until a provider key is set, purchase buttons say "coming soon" and nothing can be charged. - When you are ready to go live, follow [Deploy](/docs/deploy). --- # Working with AI agents Source: https://shipkit.sh/docs/ai-agents > How ShipKit is set up for Claude Code, Codex and Cursor — the rule files, the skills for recurring jobs, and the tests that catch an agent's mistakes. ShipKit is written to be changed by a coding agent as much as by hand. The conventions are written down where an agent reads them, the recurring setup jobs are step-by-step procedures an agent can follow, and the rules that are easy to break are checked by tests rather than left to memory. None of this is required: it is all plain Markdown and TypeScript, and a human can read and follow it the same way. ## The rule files | File | Read by | What it holds | | --- | --- | --- | | `CLAUDE.md` | Claude Code, automatically | Commands, a map of the codebase, and the hard rules | | `src/CLAUDE.md` | Claude Code, when working under `src/` | The design language: colour, radius, buttons, borders | | `AGENTS.md` | Codex, Cursor, GitHub Copilot, Windsurf, opencode, Zed, Amp | A pointer to the two files above, plus the skill table | | `DESIGN.md` | Anyone who asks why | The reasoning behind the architecture | There is one set of rules, kept in `CLAUDE.md`. `AGENTS.md` does not repeat them; it tells other agents to read `CLAUDE.md` first, so the two never drift apart. The hard rules are the ones that cost the most when broken: authorization is a permission check, never a role check; all client data goes through one channel (route loader, query, server function, Drizzle); D1 has no interactive transactions; every feature is a deletable vertical slice; payments arrive as events; server functions never localize; money is integer cents. Each has its own page in these docs — start with [Architecture](/docs/architecture) and [Permissions](/docs/permissions). When you change a convention in your own project, change it in `CLAUDE.md` too. An agent follows what the file says, not what you meant. ## Skills The recurring jobs that involve more than code — accounts, dashboards, secrets — are written as skills under `.claude/skills//SKILL.md`. In Claude Code each one is a slash command. Any other agent can open the file and follow it; `AGENTS.md` tells them to. | Skill | What it does | | --- | --- | | `/setup` | Installs dependencies, creates `.dev.vars` with a generated secret, migrates the local database, starts the dev server and makes your first admin | | `/google-oauth` | Walks you through the Google Console and wires the credentials in (required: env validation fails without them) | | `/stripe` | Builds the Stripe catalog, sets keys and the webhook secret, fills in `src/config/plans.ts`, and runs a local purchase through the Stripe CLI | | `/creem` | The same for Creem, the alternate provider | | `/deploy` | Provisions D1 and R2, applies remote migrations, pushes secrets, deploys, and finishes the post-deploy wiring | | `/add-feature` | Scaffolds a new vertical slice with every registry line and marker in place | | `/delete-feature` | Removes an optional module with its machine-verified recipe | | `/seo-audit` | Runs Lighthouse and checks the SEO artefacts of a production build | | `/admin-api` | Queries the read-only admin data API and turns the JSON into analysis | The skills split the work honestly: the agent runs every command and edits every file, and you do what only a person can — clicking through the Google Console or the Stripe dashboard, pasting values back, confirming anything that costs money. `/admin-api` is portable on purpose. Copy `.claude/skills/admin-api/` into any other agent's skills directory, or point the agent at `/api/v1/admin/skill.md`, which your deployed app serves verbatim. See [API keys](/docs/api-keys). ## Guard rails An agent that misreads a rule usually produces code that looks right. These checks turn the common misreadings into a failing command: | Check | What it catches | | --- | --- | | `src/core/authz/registry.test.ts` | An admin route without `requirePermission`, an `adminFn` export without `assertPermission`, a feature's `permissions.ts` missing from the registry, a database helper added to the client-bundled `core/server/fn.ts` | | `src/core/settings/handlers.test.ts` | A feature's `settings.ts` that imports server-only code (it would drag `cloudflare:workers` into the browser) or is not registered | | `src/core/settings/i18n.test.ts` | An operator setting shipped without translated labels | | `src/config/private-paths.test.ts` | A new route outside the marketing pages that is missing from the private-path list, which feeds `robots.txt` | | `src/config/plans.test.ts` | Plan and credit-pack prices that break the pricing rules stated at the top of `plans.ts` | | `src/core/payment/money-path.test.ts` | Payment handlers that misbehave against a real in-memory D1 with every migration applied | | `bun run verify:deletion` | A feature whose wiring is no longer cleanly removable | | CI "Schema and migrations agree" | A schema change committed without its migration | Some rules are enforced by the build instead of a test. Importing `cloudflare:workers` anywhere except `src/core/server/cf.ts`, for example, breaks the client bundle, and a missing `m.*` message key fails typecheck. ## A workflow that works 1. **Describe the outcome, name the skill.** "Add a changelog feature with `/add-feature`" gives the agent the checklist; "add a changelog" leaves it to guess which registries to touch. 2. **Let the skill drive setup work.** For Google, Stripe, Creem and deploys, run the skill instead of asking for the steps. It verifies each stage with a command rather than trusting a log line. 3. **Make it prove the change.** Before you accept anything, have the agent run: ```bash bun run typecheck bun run check bun run test ``` After touching routes, navigation, auth guards or the app shell, add `bun run e2e`. After adding or wiring a feature, add `bun run verify:deletion `. 4. **Read the diff yourself.** Look in particular for secrets in committed files, a new `role !== 'admin'`, a `db.transaction()` call, and copy hard-coded in a component instead of `messages/en.json` and `messages/zh.json`. 5. **Migrations are yours to run.** An agent can generate a migration with `bun run db:generate`, but applying it to production (`bun run db:migrate:remote`) is a step to take deliberately. See [Database](/docs/database). ## Limits The tests check structure, not intent. They will tell you an admin function asks for a permission; they cannot tell you it asks for the right one. The design rules in `src/CLAUDE.md` are not tested at all, so review visual changes in the browser. And an agent's confidence is not evidence: "done" means the commands above passed, not that the agent said so. --- # Configuration Source: https://shipkit.sh/docs/configuration > Where each kind of setting lives — the config files in src/config, the environment variables, wrangler.jsonc, and the settings operators edit in the admin console. ShipKit keeps configuration in four places, and each one answers a different question: | Where | What goes there | Changed by | | --- | --- | --- | | `src/config/*.ts` | Product decisions: name, URLs, plans, landing page, theme | Editing code and deploying | | `.dev.vars` / Worker secrets | Credentials: auth secret, OAuth, payment and email keys | `.dev.vars` locally, `wrangler secret put` in production | | `wrangler.jsonc` | Cloudflare resources: Worker name, D1, R2, rate limits, cron | Editing the file and deploying | | Admin console, Settings | Operational numbers: retention windows, grace periods, limits | An operator, at runtime, no deploy | A secret is an environment variable; a value someone running the business should tune without a developer is an operator setting; the rest is code. ## Config files in `src/config` | File | What it holds | | --- | --- | | `app-config.ts` | App name, description, canonical URL, email addresses, legal entity, Google client id, analytics key, waitlist switch, media URL | | `plans.ts` | Subscription plans and credit packs: prices, monthly credit grants, the Stripe price ids and Creem product ids | | `landing.ts` | The landing page as an array of blocks. See [Landing page and themes](/docs/landing-themes) | | `themes.ts` | The four visual themes and `defaultTheme`, the one visitors see first | | `private-paths.ts` | Top-level paths that are not marketing pages. One list feeds the prerender filter and the `Disallow` lines in `robots.txt` | Components import these files directly rather than reading `import.meta.env`. Most user-facing copy is not here but in `messages/en.json` and `messages/zh.json` (see [i18n](/docs/i18n)); the app name reaches that copy as a parameter, so renaming the app in the UI is one edit. ### `app-config.ts` Everything in it ships as a placeholder. Go through it before the first deploy. | Field | Used for | | --- | --- | | `name`, `description` | The product name in the UI, emails and page titles; the tagline in the marketing footer | | `url` | The canonical production origin: canonical and hreflang links, the social card URL, links inside emails | | `emailFrom` | Sender of transactional email. Put it on a sending subdomain you have verified in Resend, such as `noreply@send.yourdomain.com` | | `supportEmail` | Reply-To on every email, the support link in Help and feedback, the marketing footer, the legal pages. Must reach a real inbox | | `googleClientId` | The Google OAuth client id, for One Tap in the browser. It must be the same client as `GOOGLE_CLIENT_ID`; the file has one value for dev and one for production | | `legalEntity` | Company name, jurisdiction, address and DMCA contact, printed on Terms, Privacy and the DMCA page | | `analytics.posthog` | PostHog project key and cluster hosts. An empty key turns analytics off | | `waitlist` | While `true`, new users land on `/waitlist` until an admin approves them. Build-time, so the prerendered pages show the matching call to action | | `mediaUrl` | Public origin for marketing images and clips. Empty means placeholders instead of broken images | `url` and the `APP_URL` environment variable both hold your origin but do different jobs: `url` is compiled into the pages, `APP_URL` is what the server uses for auth callbacks and checkout return URLs. In production both should be your domain. A few branding files sit outside `src/config`: `public/manifest.json`, the icons in `public/`, and `public/og.png`, the social card, which `bun run og` redraws from `scripts/og-card.html`. ### `plans.ts` The single source of truth for pricing: webhooks resolve an incoming Stripe price id or Creem product id against this file, so an unlisted id is not recognized. The ids ship as `REPLACE_ME` placeholders until the `/stripe` or `/creem` skill writes the real ones in. See [Payments](/docs/payments) and [Credits](/docs/credits). ## Environment variables Secrets are read from `.dev.vars` locally and from Worker secrets in production. `.dev.vars.example` is the list, and `src/core/env.ts` validates it with zod. Required keys are checked on first use and a missing one stops the request with an error naming it; optional keys are checked only by the code that needs them, so deleting a feature never breaks startup. Empty strings count as unset. | Variable | Required | What it does | | --- | --- | --- | | `APP_URL` | Yes | The app's origin, a full URL. `http://localhost:3000` locally | | `BETTER_AUTH_SECRET` | Yes | Signs session cookies and encrypts secret-type operator settings. At least 16 characters; generate with `openssl rand -base64 32` | | `GOOGLE_CLIENT_ID` | Yes | Google OAuth client id | | `GOOGLE_CLIENT_SECRET` | Yes | Google OAuth client secret | | `GITHUB_CLIENT_ID` | No | With its secret, adds "Continue with GitHub" to the login page | | `GITHUB_CLIENT_SECRET` | No | Set both GitHub keys or neither | | `PAYMENT_PROVIDER` | No | `stripe` or `creem`: who takes new checkouts. Empty means Stripe if `STRIPE_SECRET_KEY` is set, otherwise Creem | | `STRIPE_SECRET_KEY` | No | Stripe secret API key | | `STRIPE_WEBHOOK_SECRET` | No | Verifies `/api/webhooks/stripe`. Locally it comes from `stripe listen` | | `CREEM_API_KEY` | No | Creem API key | | `CREEM_WEBHOOK_SECRET` | No | Verifies `/api/webhooks/creem` | | `RESEND_API_KEY` | No | Sends email through Resend. Without it every send is a logged no-op and sign-in codes print to the dev log | | `DEMO_MODE` | No | `true` turns a deployment into the public demo. Never set it on a real deployment | Purchase buttons read the active provider's key: until it is set they show "coming soon" instead of starting a checkout, so the app is usable before payments exist. Environment is read at boot, so restart the dev server after editing `.dev.vars`. Changing `BETTER_AUTH_SECRET` signs everyone out and makes any stored secret-type setting unreadable, so treat it as permanent once you are live. [Deploy](/docs/deploy) covers pushing secrets to production. When you add a variable, add it to both `.dev.vars.example` and the schema in `src/core/env.ts`. D1 and R2 need no variables at all: they are bindings. ## `wrangler.jsonc` The Cloudflare side of the app. The fields you are most likely to touch: | Field | Default | Notes | | --- | --- | --- | | `name` | `shipkit` | The Worker's name, and its `workers.dev` subdomain | | `d1_databases[0].database_id` | `REPLACE_ME` | Filled in when you create the production database. Harmless locally | | `d1_databases[0].database_name` | `shipkit` | Must match the database you create | | `r2_buckets[0].bucket_name` | `shipkit-files` | Private file storage | | `ratelimits` | 5/min and 200/min | `PUBLIC_RATE_LIMIT` for public forms, `ANALYTICS_RATE_LIMIT` for the analytics proxy | | `triggers.crons` | `0 0 * * *` | One daily run that executes every registered job | Keep the binding names (`DB`, `BUCKET` and the rate limiter names): the code refers to them. After changing this file, run `bun run cf-typegen` to regenerate the `Env` types. ## Operator settings Some values belong to whoever runs the business, not to the codebase: how long to keep audit logs, how many days a subscription survives a missed webhook. These live at `/admin/settings` (Settings → Settings in the console) and change without a deploy. | Setting | Default | What it controls | | --- | --- | --- | | Deleted account retention | 30 days | How long a closed account is kept, restorable, before it is erased | | Subscription grace period | 3 days | How long past its period a subscription survives without a renewal event | | Low balance threshold | 50 credits | Below this, the header balance chip becomes a top-up prompt | | Webhook payload retention | 90 days | How long delivered payment webhooks are kept | | Audit log retention | 180 days | Older entries are purged nightly (minimum 7) | | Email log retention | 90 days | Older delivery records are purged nightly | | Commission rate | 2000 bps (20%) | Affiliate commission on referred orders; new orders only | | Refund holdback | 30 days | How long a commission is held before it can be withdrawn | | Minimum withdrawal | 5000 cents | Balance an affiliate needs to request a payout | | Attribution window | 30 days | How long a referral click counts | | Screenshots per report | 3 | Images allowed on one piece of feedback | | Screenshot size limit | 5 MB | Per image | How they behave: - **Defaults live in code.** The database stores only overrides. A key nobody has changed follows the default in the feature's code, so a new default you ship reaches every deployment that never touched it. Each row can be reset to its default. - **Changes take up to a minute.** Values are cached for 60 seconds per Worker isolate. - **Ranges are enforced.** Each numeric setting has a minimum and maximum, checked in the form and again on the server. - **Permissions apply.** Viewing needs `settings.read`, editing `settings.write`; see [Permissions](/docs/permissions). - **Deleted features take their settings with them.** Each group belongs to a feature and disappears when you delete it. ### Adding a setting A feature declares its settings in its own `settings.ts` with `registerSetting()`: a namespaced key such as `billing.grace_days`, a kind (`number`, `boolean`, `string` or `secret`), the code default, an optional range and unit, and a label and hint as message functions so the console localizes them. `src/core/settings/handlers.ts` imports that file with one line. `src/features/billing/settings.ts` is a short model to copy. Server code reads the value with `getNumberSetting('billing.grace_days')` (or `getSetting`, `getSecretSetting`) from `src/core/settings/store.ts`. A `secret` is encrypted at rest and never sent back to the browser; a setting marked `visibility: 'public'` is readable by any signed-in user, for limits the browser has to enforce. One constraint: a `settings.ts` file is loaded by the browser bundle too, so it must import nothing server-only. Keep the default in a client-safe `config.ts` beside it, as billing does. `src/core/settings/handlers.test.ts` fails if you get this wrong. [Adding features](/docs/adding-features) covers the other registries a new feature can plug into. --- # Deploy Source: https://shipkit.sh/docs/deploy > Put ShipKit on Cloudflare Workers — create D1 and R2, push secrets, migrate, deploy, attach a domain, and wire OAuth, webhooks and email for production. A ShipKit deployment is one Cloudflare Worker. It serves the prerendered marketing pages from its static assets and everything else from code, and it talks to three things Cloudflare provides as bindings in `wrangler.jsonc`: a D1 database (`DB`), an R2 bucket (`BUCKET`) and two rate limiters. A daily cron trigger runs the maintenance jobs. There is no other server to run. If you use Claude Code, `/deploy` does everything on this page and asks you only for production values. The steps below are the same ones. ## Before the first deploy Log in to Cloudflare: ```bash bunx wrangler login bunx wrangler whoami ``` Name the project. `wrangler.jsonc` says `shipkit` in three places — `name` (the Worker), `database_name` and `bucket_name` — and so does `name` in `package.json`. Change them to your project's name. Every script addresses the database by its binding, `DB`, so nothing else has to follow. Then work through `src/config/app-config.ts`, which is all placeholders: - `url` — your production origin. It drives canonical and `hreflang` links and the sitemap. - `emailFrom` and `supportEmail` — the sender address and the Reply-To on every email (see the checklist below). - `googleClientId` — the second branch of the ternary is the production id; One Tap needs it in the browser. - `legalEntity` — printed on the Terms and Privacy pages. See [Configuration](/docs/configuration) for the rest of `src/config/`. ## Provision D1 and R2 ```bash bunx wrangler d1 create ``` Copy the `database_id` it prints into `wrangler.jsonc`, replacing `REPLACE_ME`. Then: ```bash bunx wrangler r2 bucket create bun run cf-typegen bun run db:migrate:remote ``` Keep the R2 bucket even if you delete the files feature: profile photos are stored there too. ## Secrets Local development reads `.dev.vars`; the deployed Worker reads secrets you push with `wrangler secret put`. Nothing in `.dev.vars` reaches production. ```bash bunx wrangler secret put APP_URL bunx wrangler secret put BETTER_AUTH_SECRET # …one per key ``` If you prefer a file, keep the production values in `.prod.vars` (gitignored) and push them all at once with `bunx wrangler secret bulk .prod.vars`. Keep a copy in a password manager either way. | Secret | Needed | Notes | | --- | --- | --- | | `APP_URL` | Yes | `https://your-domain`, no trailing slash | | `BETTER_AUTH_SECRET` | Yes | A new one for production: `openssl rand -base64 32` | | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Yes | The app refuses to boot without them | | `PAYMENT_PROVIDER` | To sell | `stripe` or `creem`; empty keeps the purchase buttons closed | | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | With Stripe | The webhook secret of the production endpoint, not the one `stripe listen` prints | | `CREEM_API_KEY`, `CREEM_WEBHOOK_SECRET` | With Creem | | | `RESEND_API_KEY` | In practice | Without it no email is sent — including sign-in codes | | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Optional | Both or neither | | `DEMO_MODE` | Never | Only for a separate public demo deployment | The full list, with what each key does, is on [Configuration](/docs/configuration). Env is validated when the Worker first reads it: a missing or malformed required key throws an error that names the key, visible in the Worker's logs. ## Deploy and check ```bash bun run deploy ``` This builds, prerenders the marketing pages, writes the sitemap and runs `wrangler deploy`. Check the result from a terminal: ```bash curl -s -o /dev/null -w "%{http_code}" https://your-domain/ # 200 curl -s -o /dev/null -w "%{http_code}" https://your-domain/dashboard # 307, to /login curl -s https://your-domain/pricing | grep -c hreflang # 3 ``` ## Custom domain The Worker is reachable on its `workers.dev` subdomain as soon as it deploys, which is fine for a smoke test. For the real origin, uncomment the `routes` line in `wrangler.jsonc` and put your hostname in it: ```jsonc "routes": [{ "pattern": "your-domain.com", "custom_domain": true }], ``` The domain's zone must be in the same Cloudflare account. You can also attach the domain in the dashboard (Workers → your Worker → Settings → Domains & Routes). Google sign-in only works on the origin users actually visit, so do this before inviting anyone. For `www` → apex, a Cloudflare redirect rule is the simplest option. If you cannot add one, `infra/www-redirect/` is a tiny Worker that does only that: set your hostname in its `wrangler.jsonc` and run `bun run deploy:www`. ## Production checklist These live outside the repo and are the usual reason a fresh deployment half-works. **Google.** In the same OAuth client you use locally, add `https://your-domain` to Authorized JavaScript origins and `https://your-domain/api/auth/callback/google` to Authorized redirect URIs. One Tap checks the origins list; the redirect flow checks the redirect list. **GitHub** (if configured). Add `https://your-domain/api/auth/callback/github` as the OAuth app's callback. **Payments.** For Stripe, create a webhook endpoint at `https://your-domain/api/webhooks/stripe` subscribed to `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`, `charge.refunded`, `credit_note.created` and `charge.dispute.closed`, and push its signing secret. Going live means a live-mode catalog, so every price id in `src/config/plans.ts` changes too. For Creem, point the webhook at `https://your-domain/api/webhooks/creem` and switch to the production key and product ids. See [Payments](/docs/payments). **Email.** Verify your sending subdomain in Resend (the template assumes `send.`, so its SPF and DKIM records do not collide with your inbound mail), set `emailFrom` to an address on it, and make sure `supportEmail` reaches a real inbox — customers who reply to a receipt land there. Without `RESEND_API_KEY`, emailed sign-in codes are only written to the Worker's log, so users relying on them cannot sign in. See [Email and notifications](/docs/email-notifications). **First admin.** Sign in once, then: ```bash bunx wrangler d1 execute DB --remote \ --command "UPDATE user SET role='admin' WHERE email='you@example.com'" ``` ## Later deploys and migrations A deploy after a schema change is two steps, in this order: ```bash bun run db:migrate:remote bun run deploy ``` Migrate first. Code that expects a column the database does not have yet fails on every request that touches it. Migrations are always applied by hand; nothing in the repo runs them for you. See [Database](/docs/database). ## Deploying from CI `.github/workflows/ci.yml` runs typecheck, lint, unit tests, the build and the Playwright suite on every push and pull request. On a push to `main` it then deploys — but only once you give it credentials: add `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` as secrets of an environment named `production` in your GitHub repository. Without them the deploy job prints that it skipped and succeeds. The token needs to deploy Workers and read your D1 database. CI never migrates. Before deploying it lists remote migrations and refuses to continue if any are pending. Run `bun run db:migrate:remote` yourself, then re-run the workflow (`gh workflow run CI`, or the Run workflow button). Runtime secrets are never read from CI; they stay where `wrangler secret put` put them. CI builds with placeholder values. After a deploy, logs, cron jobs and rate limits are covered in [Operations](/docs/operations), and common failures in [Troubleshooting](/docs/troubleshooting). --- # Upgrading Source: https://shipkit.sh/docs/upgrading > Pull a new ShipKit release into your project with git — add the release repo as a remote, merge a version tag, resolve conflicts, and bring migrations along. Your project and ShipKit share code, so a new release arrives the way any upstream change does: as a git merge. You keep your history, the template's changes land on top of it, and git tells you exactly where the two touched the same lines. ## How releases are published After purchase your GitHub account is invited as a collaborator to the ShipKit release repo. Each version there is a single commit on `main`, tagged `v` (`v0.5.0`, `v0.6.0`, …), and each has release notes on the repo's Releases page listing new migrations, new or renamed env keys and breaking changes. Versions follow semver: a minor release may add migrations and optional env keys; a major one has breaking changes or a new required env key. Read the release notes before you merge. They tell you what to expect in the conflicts and what to do after. ## Add the remote once Use the SSH or HTTPS URL of the release repo you were invited to: ```bash git remote add shipkit git fetch shipkit --tags git tag -l 'v*' ``` If you created your project by cloning the release repo, `origin` already points at it. Rename it to `shipkit` and add your own repository as `origin`: ```bash git remote rename origin shipkit git remote add origin ``` To preview what a release changes before merging it: ```bash git diff v0.5.0 v0.6.0 --stat ``` ## Merge a release Start from a clean working tree on a branch of its own, so an upgrade that goes wrong costs nothing: ```bash git switch -c upgrade-v0.6.0 ``` How the first merge goes depends on how your project began. ### Your project started as a clone Your history already contains the template commit you started from, so git has a common ancestor and only shows you what changed since then: ```bash git merge v0.6.0 ``` Every later upgrade is the same command with the next tag. ### Your project started as a copy If you downloaded the files, or copied them into a fresh `git init`, your history and the template's have nothing in common, and a plain merge refuses with "refusing to merge unrelated histories". Allow it: ```bash git merge v0.6.0 --allow-unrelated-histories ``` Without a common ancestor, git cannot tell your edits from the template's changes, so every file that differs on the two sides becomes a conflict. There is a better route when you know which version you copied. Record that version as already merged, without changing any of your files, and then merge the new one: ```bash git merge v0.5.0 --allow-unrelated-histories -s ours -m "Record ShipKit v0.5.0 as merged" git merge v0.6.0 ``` The first command only links the histories (`-s ours` keeps your tree exactly as it is). The second is then a normal three-way merge that brings in only what changed between v0.5.0 and v0.6.0. After this first time, your project behaves like a clone: `git merge ` for every upgrade. ## Resolve conflicts Conflicts cluster where you customised the template. Some patterns: - **`src/config/*`** (`app-config.ts`, `plans.ts`, `landing.ts`) — you almost always want your values plus the template's new fields. Keep yours and add what the release introduced. - **`messages/en.json`, `messages/zh.json`** — both sides added keys. Keep both sets; watch for a missing comma. - **`bun.lock`** — do not edit it by hand. Take the template's version, then let bun re-add your own dependencies from `package.json`: ```bash git checkout --theirs bun.lock bun install ``` - **A module you deleted** — if the release changed files inside a feature you removed, git reports "deleted by us". Keep it deleted with `git rm`, then search for new `[feature: ]` blocks the release added to shared files and cut those out too. [Deleting features](/docs/deleting-features) lists what each module touches. - **`.dev.vars.example`** — take the template's version, then copy any new keys into your own `.dev.vars`. When every file is resolved, `git add` them and `git commit`. To give up midway, `git merge --abort` returns you to where you started. ## Migrations Migrations live in `drizzle/`, numbered, with a snapshot and a journal entry in `drizzle/meta/`. D1 remembers which migration files it has applied by name. **If you have no migrations of your own**, take the template's `drizzle/` directory as it comes and apply it: ```bash bun run db:migrate:local ``` **If you added your own migrations**, the release's new files will collide with yours: both sides claim the next number, and `drizzle/meta/_journal.json` conflicts. Your files are already applied to your databases, so keep them and regenerate the template's change as your next migration instead: 1. Keep your version of every file both sides changed in `drizzle/meta`: `git checkout --ours -- drizzle/meta/_journal.json`, and the same for any snapshot `git status` shows as "both added". 2. Find the release's new migration files — the `.sql` files in `drizzle/` and the snapshots in `drizzle/meta/` that only the release added (`git status` lists them as new files; the release notes name the migrations). Read the `.sql` ones, then remove all of them with `git rm`. 3. Make sure `src/` holds the merged schema — the feature `schema.ts` files are ordinary source, merged like everything else. 4. Run `bun run db:generate`. It compares the merged schema with your latest snapshot and writes the template's changes as a new migration after yours. 5. If a removed file contained data changes (an `UPDATE` or `INSERT` backfill), `db:generate` cannot see those. Create an empty migration with `bunx drizzle-kit generate --custom --name ` and paste the statements into it, in the order the release had them. Then `bun run db:migrate:local`. See [Database](/docs/database) for how migrations are generated and applied. ## Check before you ship Install, regenerate what is generated, and run the full suite: ```bash bun install bun run cf-typegen # if wrangler.jsonc changed bun run build # also generates src/paraglide, which typecheck needs bun run typecheck bun run check bun run test bun run e2e ``` Restart the dev server afterwards: env is read at startup. Click through the pages you customised — the tests cover the template's behaviour, not your changes to it. ## Deploy the upgrade Merge the branch into `main`, then: ```bash bunx wrangler secret put # for each new key in the release notes bun run db:migrate:remote bun run deploy ``` Migrate before deploying, as with any schema change. If CI deploys for you, it refuses to deploy while remote migrations are pending. See [Deploy](/docs/deploy). A coding agent handles most of this well. Point it at this page and the release notes, and have it stop at each conflict it is unsure about rather than guess. See [Working with AI agents](/docs/ai-agents). --- # Architecture Source: https://shipkit.sh/docs/architecture > How a request moves through ShipKit, how the code is split into core and feature slices, and the bundle rules that keep the site fast. ShipKit is one Cloudflare Worker. The same Worker serves the marketing site, the signed-in app, the admin console, the webhooks and the public API, and it runs the nightly cron. This page is the map: what runs first, where code lives, and the handful of rules that keep the pieces apart. ## The request path Every request enters through three files, in this order. | File | What it does | | --- | --- | | `src/server.ts` | The Worker entry. Refuses cross-site calls to server functions, wraps TanStack Start's handler in `paraglideMiddleware` so server code knows the request's locale, adds the security headers to every response, and exports `scheduled` for the cron. | | `src/start.ts` | Global TanStack Start config (`defaultSsr: true`). The place for global request or function middleware. | | `src/router.tsx` | Creates the router and wires React Query into SSR. Its `rewrite` strips the locale from the URL on the way in and adds it back on the way out, so `/zh/pricing` matches the same route file as `/pricing`. | Because of that rewrite, no route file ever deals with a locale segment. English is unprefixed and Chinese lives under `/zh`; see [Internationalization](/docs/i18n). `src/server.ts` also imports the settings, permission and job registries at the top, so they exist before anything asks for them, whether the Worker woke up for a page, a webhook or a cron. ## Route groups Routes are files under `src/routes/`. The underscore-prefixed folders are layout groups: they share a layout and guards but add nothing to the URL. | Group | URLs | Rendering | | --- | --- | --- | | `_marketing/` | `/`, `/pricing`, `/blog`, `/docs`, `/legal/*` | Server-rendered, and prerendered to static HTML at build time (the `prerender` option in `vite.config.ts`) | | `_auth/` | `/login` | Server-rendered | | `_app/` | `/dashboard`, `/billing`, `/credits`, `/files`, `/settings`, `/api-keys`, `/affiliate`, `/admin/*` | `ssr: 'data-only'`: loaders run on the server, the page renders in the browser | | `api/` | `/api/*` | Server routes only, no UI | The `_app` shell in `src/routes/_app.tsx` is the session guard: its `beforeLoad` loads the session and redirects to `/login` without one. The user's pages and the admin console share that shell; the sidebar switches between the two areas. The console is covered in [Admin console](/docs/admin-console). A few routes sit outside the groups: `/r/$code` (the affiliate link, a server route that sets a cookie and redirects), `/waitlist`, and the dev-only `/blocks` preview of every landing-page block. ## Core and features Code under `src/` is split into a shared kernel and a set of vertical slices. **`src/core/`** is what every feature relies on: the database client (`db/`), server plumbing (`server/`: bindings, server-function middleware, the external API helpers, rate limiting, security headers), the payment event layer, permissions (`authz/`), operator settings, email sending, and a set of registries described below. Core does not know which optional features exist. **`src/features//`** is one product capability, end to end. A feature owns its tables, its server code and its UI: ```text src/features/credits/ schema.ts Drizzle tables queries.ts React Query options the routes and components use permissions.ts the admin permissions it declares settings.ts operator-editable values it registers server/ server functions, event handlers, jobs, API queries components/ its UI ``` The bundled features are auth, billing, credits, files, content, audit, email-log, feedback, notifications, waitlist, analytics, affiliate and demo. Features do not import each other; anything they share goes through `core/`. That is what makes most of them deletable in minutes: see [Deleting features](/docs/deleting-features), and [Adding features](/docs/adding-features) for building a new one the same way. `src/config/` holds the things you edit to make the template yours: branding, plans, the landing page, themes. See [Configuration](/docs/configuration). ## Registries: how core calls features without importing them Core often needs to reach into features. A payment arrives and billing, credits and affiliate each want to react; the nightly cron must run every feature's maintenance job. Core cannot import those features, or deleting one would break core. The answer is the same small pattern throughout `src/core/`: - **`events.ts`** holds a registry and a function to add to it, such as `onPaymentEvent(handler)`, `registerJob(job)` or `onAudit(sink)`. - Each feature registers itself from its own file, for example `src/features/credits/server/events.ts` calls `onPaymentEvent(...)`. - **`handlers.ts`** in the core folder has one import line per feature, which runs those registrations. ```ts // src/core/jobs/handlers.ts import '@/core/payment/jobs' import '@/features/auth/server/jobs' import '@/features/billing/server/jobs' import '@/features/credits/server/jobs' // ... ``` Deleting a feature removes its line, and the registry has one fewer entry. The same shape is used for payment events, cron jobs, admin overview stats, account deletion and export hooks, audit sinks, email delivery logging, in-app notifications, product analytics, operator settings, permissions and the sections of the admin user page. Some registries load themselves. `audit()`, `notify()` and `track()` are called from many places, so each imports its own `handlers.ts` on first use; callers never import it, and with the feature deleted the call is a no-op. ## Bundle rules Two build facts shape how code is written here. **Only a route's `component` is code-split.** A route's `loader`, `validateSearch` and other options, plus every top-level import in the route file, end up in the main bundle that the landing page downloads. So a route that needs a registry imports it inside the loader: ```ts loader: async ({ context }) => { await import('@/core/authz/handlers') return context.queryClient.ensureQueryData(adminRolesQuery) }, ``` **Some files are bundled for the browser even though they look server-side.** A feature's `server/fns.ts` is one: server functions leave a client stub behind, and their input validators stay in the client bundle. The build only strips what sits inside a server function's handler. This leads to three rules: - `cloudflare:workers` is imported in exactly one file, `src/core/server/cf.ts`. Everything else gets bindings from `getBindings()`. An import anywhere else breaks the client bundle. - Constants that client code needs (statuses, defaults) live in a feature's `config.ts`, which imports nothing server-only, never in `schema.ts`. - A feature's `settings.ts` and `permissions.ts` are loaded by the browser too, so they must stay client-safe. A test (`src/core/settings/handlers.test.ts`) enforces it for settings. These rules are also written into `CLAUDE.md`, so an AI agent working in the repo follows them; see [AI agents](/docs/ai-agents). ## The data channel at a glance Pages read and write data one way: ```text route loader -> queryClient.ensureQueryData(query) -> server function -> Drizzle -> D1 ``` The loader warms the React Query cache, the component reads the same query, and mutations call a server function and invalidate what they changed. Server functions start from `authedFn` or `adminFn`, which do the session and access checks for you. API routes exist only for callers that are not your own pages: sign-in callbacks, payment webhooks, file streams and the external API. [Server functions](/docs/server-functions) covers this channel in detail, [Database](/docs/database) covers D1 and Drizzle, and [Permissions](/docs/permissions) covers who may call what. --- # Server functions Source: https://shipkit.sh/docs/server-functions > The one data channel from page to database — loaders, React Query, authedFn and adminFn, when to use an API route instead, and rate limiting. Every page in ShipKit reads and writes data the same way. Once you have followed one example end to end, you can follow all of them, and a new page is a copy of an existing one. ```text route loader -> queryClient.ensureQueryData(query) -> server function -> Drizzle -> D1 ``` ## A read, end to end The credits page is a good example. Start with the server function, in `src/features/credits/server/fns.ts`: ```ts import { authedFn } from '@/core/server/fn' export const getMyCreditsFn = authedFn.handler(async ({ context }) => { const { total: _total, ...rest } = await myCredits(context.session.user.id) return rest }) ``` `authedFn` has already checked the session before the handler runs. If there is none, the call redirects to `/login`; if there is, `context.session` holds the signed-in user. Next, a query in the feature's `queries.ts` gives that function a cache key: ```ts export const myCreditsQuery = queryOptions({ queryKey: ['credits'], queryFn: () => getMyCreditsFn(), }) ``` The route loader warms the cache before the page renders (`src/routes/_app/credits.tsx`): ```ts export const Route = createFileRoute('/_app/credits')({ loader: ({ context }) => context.queryClient.ensureQueryData(myCreditsQuery), component: CreditsPage, }) ``` Components then read the same query with `useQuery(myCreditsQuery)` or `useSuspenseQuery(myCreditsQuery)`. The data is already there, so nothing loads twice. On a hard page load the loader runs on the server and the result is sent along with the page; on client navigation the same function is called over the network. Loaded data counts as fresh for 30 seconds (`staleTime` in `src/integrations/tanstack-query/root-provider.tsx`), and hovering a link preloads its route. ## Writes and input validation A write is a server function with a validator, called from `useMutation`, followed by invalidating whatever it changed: ```ts export const adjustCreditsAdminFn = adminFn .validator( z.object({ userId: z.string().min(1).max(100), amount: z.number().int().min(-1_000_000).max(1_000_000), reason: z.string().trim().min(1).max(200), }), ) .handler(async ({ data, context }) => { await assertPermission(context.session, 'credits.write') // ...insert the ledger row, audit it, notify the user }) ``` ```tsx const adjust = useMutation({ mutationFn: () => adjustCreditsAdminFn({ data: { userId, amount, reason } }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId, 'credits'], }) }, }) ``` The validator is a zod schema. `data` inside the handler is typed and already checked, so a handler never parses its own input. ## authedFn and adminFn Both live in `src/core/server/fn.ts` and are the only two starting points for a server function. | Starting point | Checks | Use for | | --- | --- | --- | | `authedFn` | A signed-in session, else redirect to `/login` | Everything a user does with their own account | | `adminFn` | The above, plus the `admin.access` permission, else redirect to `/dashboard` | Anything in the admin console | `adminFn` only answers "may this person open the console at all". Each admin function must also name the specific permission it needs with `assertPermission(context.session, '')`, as above. A unit test, `src/core/authz/registry.test.ts`, fails for any admin function that forgets. Roles and permissions are covered in [Permissions](/docs/permissions). Do not check sessions by hand inside a handler, and do not wrap `createServerFn` in a factory of your own (a `permissionFn('credits.write')` helper, say). The build cannot see through the factory call and ends up shipping the whole module, database code included, to the browser. Both are `POST` functions. A `GET` server function can be triggered from a plain link, and because the session cookie is sent on top-level navigations from other sites, a hostile page could link a signed-in user straight into a write. On top of that, `src/server.ts` refuses any call to `/_serverFn/*` whose `Sec-Fetch-Site` (or `Origin`) header says it came from another site. ## Keeping server code out of the browser A feature's `server/fns.ts` is imported by client code (through `queries.ts`), so the build turns each server function into a small client stub and strips what the handlers use. What it cannot strip is anything outside a handler. In practice: - Put database helpers in their own server module (the credits feature has `server/credits.ts`, `server/me.ts`, `server/admin.ts`) and call them from inside handlers. - Never add a plain helper that touches the database to `src/core/server/fn.ts`. It is bundled for the browser, which is why `assertPermission` lives in `src/core/authz/assert.ts` instead. - Constants the UI shares with the server go in the feature's `config.ts`, not `schema.ts`. [Architecture](/docs/architecture) explains the bundle rules in more detail. ## Return keys, not translated text A server function is reached at `/_serverFn/...`, a URL with no locale in it. Inside one, Paraglide falls back to the cookie or the `Accept-Language` header, not the language of the page the user is looking at. Someone who followed a link to `/zh` would get English strings back. So server functions return data and machine-readable reasons, and the component picks the message. The affiliate payout request is a good example: ```ts // server: src/features/affiliate/server/fns.ts if (amount < minimum) { return { ok: false as const, reason: 'below_minimum' as const, minimum } } ``` ```tsx // client: src/routes/_app/affiliate.tsx toast.error( result.reason === 'below_minimum' ? m.affiliate_payout_below_minimum({ amount: money(result.minimum ?? minimum) }) : m.affiliate_payout_open_request(), ) ``` The exception is text that leaves the app: emails and in-app notifications are localized by the recipient's saved `user.locale`, never by the request. See [Internationalization](/docs/i18n) and [Email and notifications](/docs/email-notifications). ## When an API route is right API routes are files under `src/routes/api/` with `server.handlers` instead of a component. They are for callers that are not your own pages: | Route | Why it is not a server function | | --- | --- | | `api/auth.$.ts` | better-auth's endpoints and OAuth callbacks | | `api/webhooks.stripe.ts`, `api/webhooks.creem.ts` | Signed calls from the payment provider | | `api/files.ts`, `api/files.$fileId.ts`, `api/avatar*.ts`, `api/feedback-image*.ts` | Multipart uploads and streamed downloads | | `api/v1/admin/*`, `api/v1/me/*` | The external API, authenticated by API key | | `api/ph/$.ts` | A proxy for product analytics | If your own page needs data, write a server function. The external API has its own wrappers (`adminApi`, `userApi`, `userDoc`, `userAction` in `src/core/server/api.ts`) that handle key checks and the JSON envelope; see [API keys](/docs/api-keys). ## Rate limiting `isRateLimited` in `src/core/server/rate-limit.ts` counts requests against Cloudflare's rate-limiting bindings, declared under `ratelimits` in `wrangler.jsonc`: | Binding | Limit | Used for | | --- | --- | --- | | `PUBLIC_RATE_LIMIT` | 5 per minute | Writes that cost real resources: uploads, sign-in code requests | | `ANALYTICS_RATE_LIMIT` | 200 per minute | The analytics proxy, which the browser calls several times per page | ```ts if (await isRateLimited('files-upload', { headers: request.headers, key: session.user.id })) { return tooManyRequests() } ``` The first argument names the bucket. By default the count is per client IP; pass `key` to count per user or per email address instead. Inside a server function you can omit `headers`, because the request is implicit there. If the binding is missing the check fails open, so a configuration gap never takes an endpoint down. Most reads do not need it: they are cheap and already tied to a session, and better-auth and the API-key plugin throttle their own endpoints. Reach for it where a request costs something, such as an R2 write or an outbound call, or where no session stands in front of it. --- # Database Source: https://shipkit.sh/docs/database > Cloudflare D1 with Drizzle — where tables are defined, the migration workflow, seeding, and writing safely without interactive transactions. ShipKit stores everything in one Cloudflare D1 database, which is SQLite run by Cloudflare, and talks to it through Drizzle ORM. There is no connection string and no database password: the Worker reaches D1 through a binding named `DB`, declared in `wrangler.jsonc`. In development, `bun run dev` gives you a local copy of the same database, kept under `.wrangler/state` (gitignored). The trade-off is plain: D1 only exists inside Cloudflare Workers. Moving to another host means moving to another database. ## Querying Server code gets the Drizzle client from `getDb()`: ```ts import { desc, eq } from 'drizzle-orm' import { getDb } from '@/core/db' import { creditLedger } from '../schema' const rows = await getDb() .select() .from(creditLedger) .where(eq(creditLedger.userId, userId)) .orderBy(desc(creditLedger.createdAt)) .limit(20) ``` `getDb()` creates the client on first use, so the binding is only touched when a request is running, never during the build. Call it from server functions, API routes, event handlers and jobs; never from client code. Pages reach the database through [server functions](/docs/server-functions). ## Where tables live Each feature defines its own tables in its own `schema.ts`: credits in `src/features/credits/schema.ts`, billing in `src/features/billing/schema.ts`, and so on. `src/core/db/schema.ts` is only a barrel that re-exports them: ```ts export * from '@/core/authz/schema' export * from '@/core/payment/schema' export * from '@/features/auth/schema' export * from '@/features/billing/schema' export * from '@/features/credits/schema' // ...one line per feature ``` Drizzle reads this barrel to generate migrations. Deleting a feature means removing its line, after which `bun run db:generate` writes the migration that drops its tables. A new feature adds a line here; see [Adding features](/docs/adding-features). The auth tables are the exception to editing by hand. `src/features/auth/schema.ts` is generated from the better-auth config by `bun run auth:generate`; see [Authentication](/docs/authentication). ## Column conventions The existing tables follow the same conventions, and new ones should too. | Kind of value | How it is stored | | --- | --- | | Primary key | `text('id').primaryKey().$defaultFn(() => crypto.randomUUID())` | | Timestamp | `integer('created_at', { mode: 'timestamp_ms' })`, milliseconds; Drizzle gives you a `Date` | | Money | An integer in the smallest currency unit (cents), never a float | | Boolean | `integer('banned', { mode: 'boolean' })` | | JSON | `text('meta', { mode: 'json' }).$type<...>()`, since D1 has no JSON column type | A creation timestamp usually defaults in SQL, so a row gets one even when inserted by hand: ```ts createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), ``` Column names are snake_case in SQL and camelCase in TypeScript. Add an index for the queries a table serves; `credit_ledger` in `src/features/credits/schema.ts` has a commented example of an index built for its hottest query. ## Changing the schema Migrations are SQL files in `drizzle/`, generated by drizzle-kit and applied by wrangler. ```bash # 1. edit a feature's schema.ts, then generate the SQL bun run db:generate # 2. apply it to your local database bun run db:migrate:local # 3. before deploying code that needs it, apply it to production bun run db:migrate:remote ``` Commit the new file in `drizzle/` together with `drizzle/meta/`, which drizzle-kit uses to work out the next migration. CI regenerates the migrations and fails if the schema changed without one being committed. Production migrations are always run by hand, from your machine. CI never migrates; when it is set up to deploy, it first checks for unapplied migrations and refuses to deploy until you have run `bun run db:migrate:remote`. Run it before you push, and write migrations that the currently deployed code can survive, because for a short while the old code runs against the new schema. [Deploy](/docs/deploy) has the full order of steps. A data-only migration, such as a backfill, is a hand-written SQL file in `drizzle/`. Since it does not change the schema, it needs no snapshot change. ## Seeding and inspecting local data `bun run db:seed` fills the local database with 90 days of fake users, subscriptions, orders, credit ledger rows and audit entries, so the admin console and its charts have something to show: ```bash bun run db:seed # create or refresh the seed rows bun run db:seed --reset # remove them again ``` Every seed row's id starts with `seed-`, and seed rows only reference seed users, so `--reset` never touches data you created yourself. The data is deterministic and its dates are relative to today, so re-running it keeps the charts current. The script also takes `--remote`, which exists only for a public demo deployment (see [More modules](/docs/more-modules)); never point it at a real production database, because the fake orders would land in your revenue numbers. To run SQL against the local database directly, use wrangler: ```bash bunx wrangler d1 execute DB --local \ --command "UPDATE user SET role='admin' WHERE email='you@example.com'" ``` ## Writing without interactive transactions D1 does not support interactive transactions: `db.transaction()` throws. You cannot read a value, decide in JavaScript, and write back atomically. ShipKit uses two tools instead. ### Several writes that must succeed together: `db.batch()` `db.batch([...])` sends a list of statements that D1 runs as one unit: all of them apply, or none. Use it when one action writes to several tables. Account deletion in `src/features/auth/server/fns.ts` is an example: ```ts const db = getDb() await db.batch([ db.update(user).set({ deletedAt: now }).where(eq(user.id, me.id)), db.delete(session).where(eq(session.userId, me.id)), db.delete(apikey).where(eq(apikey.referenceId, me.id)), ]) ``` The statements are fixed before the batch runs, so a later statement cannot depend on what an earlier one read. ### A check and a write together: one conditional statement When a write depends on current data, such as "spend 10 credits only if the balance covers it", put the check inside the statement that writes. A single SQL statement is atomic in D1, so two concurrent requests cannot both pass the check. `spendCredits` in `src/features/credits/server/credits.ts` works this way: its `INSERT ... SELECT` only produces a row when the summed balance covers the amount, and it reports whether anything was written. Checking the balance first and inserting afterwards would let two parallel requests overdraw the account. ### Idempotency with unique keys Payment webhooks and cron jobs can run more than once, so writes they trigger are keyed. The credit ledger has a unique `ref_id`, and grants insert with `onConflictDoNothing()`: ```ts const result = await getDb() .insert(creditLedger) .values({ userId, delta, reason, refId: orderId }) .onConflictDoNothing() return result.meta.changes > 0 // false: this order was already credited ``` Webhook deduplication works the same way, through a unique index on the `webhook_events` table. Only do follow-up side effects, such as a notification, when the write actually happened. ## Testing against a real D1 Tests that must prove what a SQL statement does, above all the payment and credit paths, run against an in-memory D1 with every migration in `drizzle/` applied. `startD1()` in `src/test/d1.ts` sets it up; `src/core/payment/money-path.test.ts` is the main example. `bun run test` runs them with the rest of the unit tests. --- # Roles and permissions Source: https://shipkit.sh/docs/permissions > How ShipKit decides who can do what in the admin console — permissions, roles, the built-in admin, and how to guard a new page or server function. Access to the admin console is role-based. A **permission** is a dotted name such as `users.read` or `billing.write`. A **role** is a named set of permissions. Every user holds at most one role, stored as a name in the `user.role` column that better-auth's admin plugin already maintains. Code never asks "is this person an admin?". It asks "does this person hold `billing.read`?", and the same question is asked in every place that matters: | Where | How | What it decides | |---|---|---| | Sidebar | the row's `permission` in `adminNav` | whether the tab is shown | | Route | `requirePermission(queryClient, '')` in `beforeLoad` | whether the page renders | | Server function | `await assertPermission(context.session, '')` | whether the call runs | | Admin API | `adminApi(handler, '')` | whether the key gets data | The route guard runs in the browser too, so it is a convenience that keeps a page from half-rendering. The server function and the API check are the real boundary, and they re-check on every call. ## Built-in roles Two roles live in code rather than in the database: - **`admin`** holds `*`, which matches every permission. It appears on the roles page but cannot be edited or deleted. - **No role** (a null column, or the plain `user` value better-auth writes) holds nothing. That person uses the app normally and cannot open the console. They are built in so that no edit on the roles page can leave the database with nobody able to get back in. Everything between those two ends is a row in the `role` table (`src/core/authz/schema.ts`). A role name that no longer exists grants nothing, so a deleted role fails closed. To make yourself the first admin, set your own row by hand after signing in — see [Getting started](/docs/getting-started). ## Creating a role Open **Admin → Settings → Roles** (`/admin/roles`). The editor shows every declared permission as a checkbox, grouped by feature. Saving goes through `saveRoleAdminFn` in `src/core/authz/fns.ts`, which enforces a few rules: - The name is an identifier: lowercase, starting with a letter, then letters, digits, `_` or `-`, at most 40 characters. `admin` is reserved. - Every grant must name a permission some feature declared. A typo is refused instead of sitting in the table looking like access. - `*` is refused. Full access is the built-in `admin` role only. - A role that users still hold cannot be deleted. Saves and deletes are written to the audit log as `roles.saved` and `roles.deleted`, with the grants in the entry. Every role that can open the console needs `admin.access`, the floor that every console page and admin server function requires before anything more specific. A read-only support role might hold `admin.access`, `users.read` and `billing.read`. Treat `roles.write` as equivalent to full access: anyone who can edit roles can give themselves any permission. ### Wildcards A grant can end in `.*` to cover a whole area: `billing.*` covers `billing.read` and `billing.write`. The wildcard is only ever a whole trailing segment, so `use*` matches nothing (`src/core/authz/match.ts`). The matcher and the save validation accept wildcards, but the editor itself works with individual checkboxes. The public demo's `demo` role is an example of a role built in code: `admin.access` plus every `*.read` permission, derived from the registry (`src/features/demo/config.ts`). ## Assigning a role Open a user from any console table (it opens the user drawer) and pick a role from the select next to **Role**. The list is the built-in `admin` plus every row on the roles page. The change is written to the audit log as `admin.role_set`. One limitation: changing a role and banning a user go through better-auth's admin plugin endpoints, and that plugin, in its default configuration, only lets the built-in `admin` role call them. A custom role holding `users.write` sees the controls but the change is refused. Give role changes and bans to admins only, or configure the plugin's own access control in `src/features/auth/server/auth.ts` if you need to delegate them. ## Checking a permission A console route: ```tsx export const Route = createFileRoute('/_app/admin/reports')({ beforeLoad: async ({ context }) => { await requirePermission(context.queryClient, 'reports.read') }, component: ReportsPage, }) ``` A server function starts from `adminFn` (which already requires `admin.access`) and narrows inside the handler: ```ts import { assertPermission } from '@/core/authz/assert' import { adminFn } from '@/core/server/fn' export const listReportsAdminFn = adminFn.handler(async ({ context }) => { await assertPermission(context.session, 'reports.read') // …query and return }) ``` Both redirect to `/dashboard` rather than showing a 403, so someone whose role changed lands on a page they can use. The admin API returns a real `403` instead — see [API keys](/docs/api-keys). Inside a component, `useCan('')` from `src/core/authz/queries.ts` decides whether to show a control. It returns `false` while loading. Use it to hide buttons, never as the only check. Two things not to do. Do not put a helper that touches the database in `src/core/server/fn.ts`: that file is bundled for the client too, and the build only strips what sits inside a `.server()` callback. That is why `assertPermission` lives in `src/core/authz/assert.ts`. And do not wrap `createServerFn` in a factory such as `permissionFn(perm)`: the build cannot see through the call and ships the whole module to the browser. ## Adding a permission Permissions are declared by the feature that enforces them, in a client-safe `src/features//permissions.ts`: ```ts import { registerPermission } from '@/core/authz/events' import { m } from '@/paraglide/messages' registerPermission({ key: 'reports.read', group: 'reports', groupLabel: () => m.admin_reports_title(), label: () => m.perm_read(), }) ``` Then add one import line to `src/core/authz/handlers.ts`. The role editor picks it up with no other change, and deleting the feature removes its permissions from the editor along with that line. Rules for a declaration: the key is `.` in lowercase, it may not contain `*`, and the file imports nothing server-only (no `./server/`, no `@/core/db`), because the role editor renders the registry in the browser. Reads conventionally use `.read` and changes `.write`. The core permissions, which exist whatever features you keep, are declared in `src/core/authz/permissions.ts`: `admin.access`, `users.read`, `users.write`, `roles.write`, `settings.read`, `settings.write`, `api_keys.manage` and `email.read`. ## Changes take up to a minute Role grants are read on every guarded request, so they are cached per Worker isolate for 60 seconds (`src/core/authz/store.ts`). A role edit applies at once on the isolate that made it and within a minute everywhere else. The user's role name itself is read fresh with the session on each call, so moving someone to no role, or banning them, takes effect at once. It is editing what a role grants that can lag. ## The tests that enforce it `src/core/authz/registry.test.ts` runs with `bun run test` and fails when: - a route file under `src/routes/_app/admin/` does not call `requirePermission(`, or still contains a `role !== 'admin'` check; - an `export const … = adminFn` anywhere in `src/` does not call `await assertPermission(context.session, …)`; - a `features/*/permissions.ts` is missing from `src/core/authz/handlers.ts`, or imports something server-only; - a key is malformed, duplicated or unlocalized; - `src/core/server/fn.ts` exports a plain function or touches the database outside a `.server()` callback. These catch the usual mistakes, from a person or an agent, before they ship. To add a console page end to end, see [Admin console](/docs/admin-console). --- # Authentication Source: https://shipkit.sh/docs/authentication > Passwordless sign-in with better-auth — Google and One Tap, optional GitHub, emailed codes — plus sessions, the auth config rule, and account deletion and export. ShipKit signs people in with [better-auth](https://www.better-auth.com) and has no passwords anywhere: no reset flow, no register/login split, nothing to leak. There are three ways in, and the first sign-in by any of them creates the account. | Method | Required? | What it needs | | --- | --- | --- | | Google (redirect + One Tap) | Yes | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and the client id in `appConfig.googleClientId` | | GitHub | No | `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` — both, or neither | | Emailed sign-in code | Always on | `RESEND_API_KEY` to actually send; without it the code is printed to the server log | The server config is `src/features/auth/server/auth.ts`. The client is `src/features/auth/client.ts`, and the sign-in panel shared by `/login` and the in-page login dialog is `src/features/auth/components/login-panel.tsx`. ## Google and One Tap Google is the baseline method, so its two env vars are required: `src/core/env.ts` refuses to boot without them. The `/google-oauth` skill walks through the Google Cloud console. The short version: - Create an OAuth client of type **Web application**. - Redirect URI: `/api/auth/callback/google`. - JavaScript origins: ``. For local dev add both `http://localhost:3000` and `http://localhost` — One Tap needs the portless entry. - Put the id and secret in `.dev.vars`, and the same client id in `googleClientId` in `src/config/app-config.ts` (it has a dev and a production value). One Tap is the Google prompt that appears in the corner of the page for signed-out visitors. It is mounted on the marketing layout and on the login panel (`src/features/auth/components/one-tap.tsx`) and signs the visitor in without leaving the page. It validates JavaScript origins rather than redirect URIs, so a working Google button with a silent One Tap almost always means a missing origin. Google also suppresses the prompt for a while after someone dismisses it, so an absent prompt is not necessarily a bug. ## GitHub Create an OAuth app at GitHub with the callback URL `/api/auth/callback/github`, then set both vars. With both set, a "Continue with GitHub" button appears next to Google; with neither, it is not rendered at all. The login UI asks the server which providers are configured (`getAuthProvidersFn` in `src/features/auth/server/fns.ts`), so the button and the provider can never disagree. ## Emailed sign-in codes The email form sends a six-digit code that is valid for ten minutes. The code is stored hashed and burned after three wrong guesses. Sending a code is throttled in front of better-auth (`src/routes/api/auth.$.ts`) against the `PUBLIC_RATE_LIMIT` binding twice — per IP and per address — so rotating IPs cannot flood one inbox. Without `RESEND_API_KEY` the send is a no-op and the code is printed to the dev server's log instead: ``` [auth] no mail provider — code for you@example.com: 123456 ``` That makes the method usable in a fresh clone. In production, set up Resend first — see [Email and notifications](/docs/email-notifications). ## Sessions Sessions are cookie-based, valid for seven days, and refreshed at most once a day. Nothing in the app hand-rolls a session check: - **Pages** under the `_app` route group are guarded in `src/routes/_app.tsx`: no session, redirect to `/login?redirect=`. After signing in the visitor lands back on that page. The redirect only honours same-origin paths (`src/features/auth/redirect.ts`). - **Server functions** start from `authedFn` or `adminFn` in `src/core/server/fn.ts`, which put a typed `session` in the handler's context. ```ts export const myThingFn = authedFn.handler(async ({ context }) => { const userId = context.session.user.id // … }) ``` See [Server functions](/docs/server-functions) for the full data channel and [Permissions](/docs/permissions) for what `adminFn` does and does not guarantee. A nightly job (`auth.purge-expired`) deletes expired sessions, verification rows and API keys, because better-auth only expires them logically. Sign-ins, sign-outs, profile updates, role changes, bans and API-key changes are written to the audit log by `src/features/auth/server/audit-hook.ts`. ## Changing the auth config The better-auth CLI runs in Node and cannot import `cloudflare:workers`, so the config lives in two files: 1. `src/features/auth/server/auth.ts` — the real one the Worker runs. 2. `auth.cli-config.ts` at the repo root — a mirror with the same plugins and user fields, used only to generate the schema. When you add a plugin or a user field, change both, then regenerate the tables and a migration: ```bash bun run auth:generate # rewrites src/features/auth/schema.ts bun run db:generate # drizzle migration for the change bun run db:migrate:local ``` Social providers add no columns, so adding one only touches `auth.ts` (and `src/core/env.ts` plus `.dev.vars.example` for its secrets). See [Database](/docs/database) for migrations. ## Each user's locale Every user row has a `locale` column. It is stamped at sign-up from the visitor's language cookie and changed from **Settings → Preferences**. Emails are sent in that language — see [Internationalization](/docs/i18n). ## Deleting an account Deletion from **Settings** happens in two steps. 1. **Close.** The account is stamped `deletedAt`, every session and API key is removed, and an "account closed" email goes out. Nothing else is deleted. A closed account cannot sign back in by any method. The request is refused if the session is more than a day old (there is no password to re-enter, so a recent sign-in is the confirmation), or if a feature guard objects — for example a live subscription. 2. **Purge.** A nightly job erases the account once the retention window has passed (30 days by default, editable in the admin console under **Settings**, minimum 7). A reminder email goes out three days before. Rows cascade with the user; things outside the database, such as the uploaded avatar in R2, are removed by handlers. Until the purge an admin can restore the account from the user's page in the console. The user then signs in again as normal; revoked sessions and keys stay revoked. Features plug into this through `src/core/account/events.ts`: `onAccountDeletionGuard` to refuse, `onBeforeAccountDelete` and `onAccountDeleted` for cleanup, and `onAccountCreated` for work a new account needs. Each feature registers with one import line in `src/core/account/handlers.ts`. ## Exporting data **Settings → Your data** downloads everything held about the account as JSON: the user row, sessions, linked providers, and one key per feature. A feature adds its slice with `onAccountExport('', handler)` — the email log, for example, contributes the templates and subjects of mail sent to the user. Each export is audited. --- # Payments Source: https://shipkit.sh/docs/payments > Stripe and Creem are both wired in. How checkout, webhooks, plan changes and refunds work, how to pick a provider, and how to test locally. ShipKit sells two kinds of things: **subscriptions** (a plan, billed monthly or yearly) and **one-time purchases** (credit packs). Both run through the same pipeline, and both providers are already implemented, so the provider is a setting rather than a rewrite. ## How a purchase flows 1. A signed-in user clicks a buy button. A server function (`createSubscriptionCheckoutFn` in `src/features/billing/server/fns.ts`, `createCreditCheckoutFn` in `src/features/credits/server/fns.ts`) asks the active provider for a hosted checkout page, with the user id and what was bought (`kind`, `planId` or `packId`) in the checkout metadata. 2. The buyer pays on the provider's page and is sent back to `/billing` or `/credits`, in the language they left in. 3. The provider posts a webhook to `/api/webhooks/stripe` or `/api/webhooks/creem`. The route does three things only: **verify** the signature, **dedupe** on the event id (a unique row in `webhook_events`), and **translate** the payload into provider-neutral `PaymentEvent`s. 4. Each feature that cares about money handles those events: billing keeps `subscriptions` and `orders` in sync and sends receipts, credits grants the balance, affiliate records commissions, audit and analytics log it. Step 2 also settles the purchase on its own. The return URL carries the checkout id, and the account dialog calls `reconcileCheckoutFn` (`src/core/payment/fns.ts`), which asks the provider what happened, runs the answer through the same translator and dispatches the same events. Every handler keys on the external order or subscription id, so whichever of the webhook or the return gets there first does the work and the other is a no-op. A webhook that never arrives becomes a delay, not a lost payment. A user who already has a live subscription never gets a second checkout: the buy button sends them to `/billing`, where the change is a plan switch with a quote. ## Stripe or Creem | | Stripe | Creem | | --------------------------- | --------------------------------------------------------- | ------------------------------------------------------- | | Role | Payment processor; you are the merchant of record | Merchant of record; they sell, you get paid | | Sales tax and VAT | Yours to handle, or enable Stripe Tax | Collected and remitted for you | | Plan changes in the app | Upgrade now for the difference, downgrade at period end | Not supported; the switch is disabled, the subscriber cancels and re-subscribes | | Full refund of an upgrade | Undoes it: old price back, the credit top-up taken back | Not applicable | | Failed renewal | Past-due banner and one warning email | Nothing is sent; the period lapses | | Account erasure | The Stripe customer is redacted through the API | Remove the customer in the Creem dashboard by hand | | Local webhook test | `bun run stripe:listen` through the Stripe CLI | A signed payload from the `/creem` skill, or a tunnel such as ngrok | | Test mode | A `sk_test_` key | A `creem_test_` key, which targets Creem's test API | | Catalog ids in `plans.ts` | `stripePriceId`, one per tier and interval | `creemProductId`, one per tier and interval | | The switch | `PAYMENT_PROVIDER=stripe` | `PAYMENT_PROVIDER=creem` | Everything else works the same on both: checkout, renewals, cancellation, full and partial refunds, and the credit grants that hang off each event. ### Choosing the active provider `PAYMENT_PROVIDER` decides who takes **new** checkouts. An existing subscription keeps renewing, and its portal and cancel buttons keep working, with whichever provider it started on, read off its row. You can switch later without stranding anyone, and both providers can be configured at once. If `PAYMENT_PROVIDER` is empty, ShipKit uses Stripe when `STRIPE_SECRET_KEY` is set, otherwise Creem. Nothing can be bought until the active provider has its key: every purchase button checks this first and shows "coming soon" instead of a checkout that would fail. The free plan's monthly credits keep the app usable in the meantime, which matters because a merchant account can take weeks to approve. The keys are `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` and `CREEM_API_KEY` / `CREEM_WEBHOOK_SECRET`. Env is read at boot, so restart the dev server after editing `.dev.vars`. See [Configuration](/docs/configuration) for the full list and [Deploy](/docs/deploy) for production secrets. ## Plans and packs: `src/config/plans.ts` This file is the single source of truth. Each plan has a display price in cents per interval, a `monthlyCredits` grant, and one provider id per interval; each credit pack has a credit amount, a price and one id per provider. The webhook translators resolve the charged price or product id back to a plan here, so an id that is not in this file is not something the app knows how to fulfil. The ids that ship are placeholders (`price_REPLACE_ME_…`). Run the `/stripe` or `/creem` skill: it creates the catalog, writes the real ids in, and checks the webhook end to end. The display prices must match what the provider charges; the provider's price is always what is actually billed. A subscription in ShipKit is a standing credit grant, not a feature flag. See [Credits](/docs/credits) for the pricing constants at the top of the file and for what to do if your product gates features instead. ## Webhook events | Event | What it means | | ------------------------------ | ------------------------------------------------------- | | `subscription.activated` | First payment of a subscription | | `subscription.updated` | A renewal, or a change to plan, period or cancel flag | | `subscription.payment_failed` | A renewal charge failed and the provider will retry (Stripe) | | `subscription.canceled` | The subscription has ended | | `order.paid` | Money arrived: a first payment, a renewal, a pack, an upgrade | | `order.refunded` | Money went back, fully or partly, or a dispute was lost | For Stripe, the endpoint must subscribe to `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`, `charge.refunded`, `credit_note.created` and `charge.dispute.closed`. The `/stripe` skill lists them too. If any handler throws, the webhook route deletes its dedupe row and answers 500, so the provider's retry gets a full second attempt instead of being dropped as a duplicate. Delivered payloads are kept for 90 days by default (`payment.webhook_retention_days` in the admin settings), then pruned by the nightly job. To react to payments in your own feature, register a handler and add one import line to `src/core/payment/handlers.ts`: ```ts // src/features//server/events.ts import { onPaymentEvent } from '@/core/payment/events' onPaymentEvent(async (event) => { if (event.type !== 'order.paid' || event.meta.kind !== 'my_thing') return // Key every write on event.externalOrderId: webhooks are redelivered, // and the checkout return dispatches the same event. }) ``` Features never import a Stripe or Creem client. New checkouts, portals and cancellations go through `src/core/payment/provider.ts`. ## Plan changes (Stripe only) The rules live in `src/features/billing/plan-change.ts`, and the preview the buyer confirms is computed by the same code that charges them. - **Upgrade, same interval**: happens now and keeps the renewal date. The buyer pays the price difference (on a yearly plan, for the months left in the year, counting the current one). This month's credit allowance is topped up by the allowance difference, whatever was already spent. - **Upgrade, monthly to yearly**: a new yearly period starts today, less the unused days of the monthly period. - **Downgrade**: any move to a lower tier, and any yearly-to-monthly move, takes effect at the end of the paid period through a Stripe subscription schedule. Until then the pending change shows in the UI and can be called off. A conditional update on the subscription row acts as a short lock, so a double click cannot charge an upgrade twice. Cancellation from the app sets `cancelAtPeriodEnd`; the subscription stays active until the provider reports it ended. If a renewal or cancel webhook never arrives, the nightly `billing.expire-stale-subscriptions` job expires a subscription 3 days after its period ended (`billing.grace_days`, editable in the admin console). ## Refunds Refund in the provider's dashboard; the app follows the webhook. Only a **full** refund undoes what an order bought: - a credit pack's credits are taken back (the balance may go negative if they were already spent); - a subscription payment (the latest one) ends the paid credit allowance it bought: this month's lot drops back to the free amount. The subscription row itself does not change; cancel the subscription in the provider if the refund means the customer is leaving; - an upgrade's price difference reverts the upgrade: old price back, same period, the month's top-up taken back; - a pending affiliate commission on the order is voided. A partial refund is recorded and treated as goodwill: nothing is clawed back. If you want to take credits back after one, use an admin adjustment (see [Credits](/docs/credits)). ## Testing locally **Stripe.** Install the Stripe CLI, put a test key in `.dev.vars`, then: ```bash bun run stripe:listen # prints the whsec_… value for STRIPE_WEBHOOK_SECRET ``` It forwards webhooks to `localhost:3000/api/webhooks/stripe`. Buy through the UI with card `4242 4242 4242 4242`, then check `webhook_events`, `orders`, `subscriptions` and `credit_ledger` in the local D1. `stripe trigger checkout.session.completed` proves the signature and dedupe path, but its fixture has no user id, so it grants nothing. **Creem.** Creem cannot reach `localhost` and has no forwarding CLI. The `/creem` skill posts a signed fake `checkout.completed` to the local endpoint, which proves the whole pipeline without a real purchase. For real test-mode webhooks, expose port 3000 through a tunnel and point the Creem webhook at `https:///api/webhooks/creem`; the tunnel must pass the body through untouched, because the signature covers the exact bytes. For production webhook URLs and live keys, see [Deploy](/docs/deploy). --- # Credits and metered usage Source: https://shipkit.sh/docs/credits > The credit ledger, where credits come from (plans, packs, admins), how to charge for work safely on D1, and the worked example of a metered API endpoint. Credits are how ShipKit turns money into usage. A subscription grants a monthly allowance, a credit pack adds a fixed amount that never expires, and your code charges credits for the work it does. If your product sells access to features rather than usage, you can remove the whole module (see the end of this page). ## The ledger Everything lives in one table, `credit_ledger` (`src/features/credits/schema.ts`). There is no balance column: the balance is the sum of a user's rows, so it cannot drift from the history that explains it. A positive `delta` is a grant, a negative one is a spend, and `reason` says why (`monthly`, `plan-topup`, `purchase`, `refund`, `api-run`, or any string your code writes). Two details matter when you write code against it: - **Rows are stored in micro-credits** (1 credit = 1,000,000). This lets you charge a fiftieth of a credit for a cheap operation without rounding it up to a whole one. The functions in `src/features/credits/server/credits.ts` take and return whole credits; only the metered helpers take micro-credits. - **`refId` is unique.** Every grant that comes from an external event carries the order id, so a redelivered webhook cannot credit twice. ### Two pools A balance has two parts with different rules, and the UI shows both: | Pool | Filled by | Expires | | ---------- | ------------------------------------------- | ---------------------------------------------- | | Monthly | The plan's allowance and upgrade top-ups | 00:00 UTC on the 1st of the next month | | Permanent | Credit packs and admin adjustments | Never | Spending always drains the monthly pool first. The reverse order would burn credits the user paid for outright while letting the rented ones expire unused. ## Where credits come from **The monthly allowance.** Every account gets its plan's `monthlyCredits` once per calendar month (UTC). The nightly cron runs the grant every day, but the row id is derived from the user and the month, so only the first run in a month writes anything and a missed day is corrected by the next. A new account gets its allowance at signup instead of waiting for the cron. The payment event does not grant the monthly credits itself. It records an entitlement (how many credits, until when), and the monthly job reads it. That is what makes a yearly plan grant every month rather than once a year. When a subscription stops renewing, its entitlement goes stale and the user falls back to the free plan's allowance with nothing to cancel. A fresh subscription or an upgrade tops the current month up immediately. **Credit packs.** A paid `order.paid` with `kind: 'credit_pack'` grants the pack's credits to the permanent pool. Buy buttons live on `/credits` and in the account dialog. **Admin adjustments.** See [below](#admin-tools). ### Pricing constants The top of `src/config/plans.ts` holds three numbers the rest derive from: - `CREDIT_LIST_CENTS`: the list price of one credit (1 cent by default). Packs sell at it; subscriptions sell below it. - `CREDIT_COST_CENTS`: what one credit's worth of work costs you. Measure it. - `CREDITS_PER_ACTION`: what your cheapest headline action costs, so plan cards can say "about N actions a month". `src/config/plans.test.ts` fails the test run if any plan's rate falls below 1.15 times your cost, or a pack sells below cost. Changing that floor is a decision about your margin, so it is an edit to the test, not a side effect of a price change. See [Payments](/docs/payments) for the rest of the file. ## Spending credits D1 has no interactive transactions, so "read the balance, then write a debit" is a race: two requests read the same balance and both pass. Every spending function here does the check inside the statement that writes (see [Database](/docs/database)). Pick the helper by when you know the cost: | Cost known | Use | Can overdraw? | | ----------------- | ------------------------------------- | ------------- | | Before the work | `spendCredits` (`server/credits.ts`) | No | | After the work, with a ceiling up front | `holdCredits`, then `settleHold` or `releaseHold` (`server/usage.ts`) | No | | After the work, already done | `debitUsage` (`server/usage.ts`) | Yes, on purpose | The simple case, inside a server function: ```ts import { spendCredits } from '@/features/credits/server/credits' const ok = await spendCredits({ userId: context.session.user.id, amount: 3, reason: 'report', }) if (!ok) throw new Error('Not enough credits') // nothing was written ``` `spendCredits` returns `false` and writes nothing when the balance is short, so you can turn that into a "top up" prompt. For work whose cost is only known afterwards (a model call billed by tokens, a render billed by seconds), use **hold, then settle**: 1. `holdCredits` debits the most the call could cost, guarded. If the balance cannot cover the worst case, it writes nothing and returns `false`. 2. Do the work. 3. `settleHold` shrinks the hold to the actual cost. If the work failed, `releaseHold` removes it so the same key can be retried. If the Worker dies between steps 1 and 3, the hold stays at the ceiling. That errs in your favour; if your work runs long enough for it to matter, sweep for stale holds. `debitUsage` is for recording consumption that already happened with no gate in front of it. It may push the balance negative, because the alternative is losing the record of work you were billed for. Give every kind of charge its own `reason`, and add a label for it in `src/features/credits/reasons.ts` plus a `credits_reason_*` message, or the user's history shows the raw slug. ## A metered API endpoint `POST /api/v1/me/run` (`src/routes/api/v1/me.run.ts`) is the worked example: an endpoint authenticated with a user API key that does work and charges for it. Replace `doTheWork` with your real work and keep the rest. - It holds the caller's ceiling (`maxUnits`), does the work, and settles the real cost. One unit costs 0.02 credits. - A caller who cannot afford the worst case gets `402 insufficient_credits`. - The `Idempotency-Key` header becomes the hold's `refId`, scoped to the caller. A second request with a key that was already charged gets `409 idempotency_key_reused` rather than a second run. A retry after a failed attempt goes through, because the failure released the hold. - The response includes the units used, the credits charged and the remaining balance. See [API keys](/docs/api-keys) for the user key and the other `/api/v1/me/*` endpoints, including `GET /api/v1/me/credits` (balance and ledger). ## Refunds A full refund of a credit pack takes its credits back, even if that sends the balance negative: spent credits were spent, and a refund should not make them free. A full refund of the latest subscription payment drops the month back to the free allowance, and a full refund of an upgrade takes back its top-up. A partial refund takes nothing; if you want to claw some back, use an admin adjustment. The details are in [Payments](/docs/payments#refunds). ## Admin tools - **Revenue → Credits** (`/admin/credits`, permission `credits.read`): every user's balance, searchable and sortable. - **The user drawer or page**: balance and latest ledger rows, plus an adjustment form (permission `credits.write`). A positive amount grants, a negative one deducts and may push the balance below zero. The reason you type is shown to the user, who also gets an in-app notification, and the change is written to the audit log as `credits.adjusted`. - **Settings → Credits**: `credits.low_balance` (default 50). Below it, the balance chip at the bottom of the sidebar turns into a top-up nudge. - **Admin API**: `/api/v1/admin/credits` (the ledger) and `/api/v1/admin/credits/balances`. See [Admin console](/docs/admin-console) and [Permissions](/docs/permissions). ## Not using credits If your plans unlock features rather than grant usage, set `monthlyCredits` to `0` on every plan (the billing code still expects the field) and run `/delete-feature credits`. Billing and subscriptions keep working; they share nothing with credits but the payment event stream. See [Deleting features](/docs/deleting-features). --- # Admin console Source: https://shipkit.sh/docs/admin-console > The /admin console — its five sections, the user drawer, overview KPIs, operator settings, and how to add a page of your own. The console lives at `/admin`, inside the same signed-in shell as the user's own pages. Anyone whose role holds `admin.access` sees an **Admin** entry at the bottom of the sidebar. Under `/admin` the sidebar switches to the console sections plus **Back to app**. What a person sees inside depends on their role — see [Roles and permissions](/docs/permissions). To give yourself the built-in `admin` role locally, follow [Getting started](/docs/getting-started). ## The five sections The sidebar has one row per section. Each section's pages appear as tabs under the page title, and a section with only one page the viewer may open shows no tabs. Pages the viewer's role does not allow are hidden, and a section with none left drops out of the sidebar. | Section | Pages (permission) | |---|---| | Overview | Overview (`admin.access`) | | People | Users (`users.read`), Waitlist (`waitlist.read`), Feedback (`feedback.read`) | | Revenue | Subscriptions and Orders (`billing.read`), Credits (`credits.read`), Affiliate (`affiliate.read`) | | Developers | API keys (`api_keys.manage`), Email log (`email.read`), Audit log (`audit.read`) | | Settings | General (`settings.read`), Roles (`roles.write`), Emails (`email.read`) | Pages from optional modules disappear when you delete that module; see [Deleting features](/docs/deleting-features). All of these rows live in one array, `adminNav` in `src/components/app-sidebar.tsx`. A row's `section` decides where it sits and its position decides the tab order. ## Overview `/admin` shows the product's headline numbers for the last 7, 30 or 90 days, each with the change against the previous window of the same length. A chart plots any series you click. Counts that are someone's job — open feedback, pending waitlist entries, failed emails, queued affiliate payouts — are shown as to-dos that link to the page where they get handled. A custom UTC range also works through the URL: `/admin?from=2026-01-01&to=2026-01-31`. Every figure comes from a stats registry. Each feature registers one provider in its `server/stats.ts`, with a line in `src/core/stats/handlers.ts`: ```ts registerStats('feedback', async (range) => ({ metrics: [ { key: 'feedback.open', value: open, format: 'count' }, { key: 'feedback.received', value: current, format: 'count', previous }, ], series: [ { key: 'feedback.received', format: 'count', points: fillDays(range, perDay) }, ], })) ``` A metric with `previous` and a matching series is a period figure (something that happened in the window); one without is a state figure (true right now). Give each new key a label in the `labels` map in `src/routes/_app/admin/index.tsx`. The same data is served to agents at `/api/v1/admin/stats` — see [API keys](/docs/api-keys). The overview is gated on `admin.access` only, so every console role sees every figure on it, revenue included. If that matters for your team, check a finer permission in the providers you want to hide. ## People and the user drawer Wherever a console table names a person, the name is a `` (`src/components/user-link.tsx`). Clicking it adds `?user=` to the current URL and opens the user drawer over the page, so the list underneath keeps its filters and scroll, and the link still works when reloaded or shared. The drawer has a button to open the full page at `/admin/users/`. Viewers without `users.read` see plain text instead of a link. Both views render the same component and show: - the account facts: role (editable in place), created, last seen, id; - a strip of headline figures, such as the current plan and credit balance; - one tab per feature: billing, credits, audit, emails, feedback, files, waitlist; - a menu with ban/unban and, for a closed account still inside its retention window, restore. The tabs come from a registry. A feature adds one by calling `registerAdminUserSection({ key, label, query, component })` in its `components/admin-user-section.tsx`, with an import line in `src/core/user-detail/handlers.ts`. The line order is the tab order. ## Tables Console lists use `DataTable` (`src/components/data-table.tsx`): search, dropdown filters, sorting, paging and a CSV export of the current view. Search and filters live in the URL, so a filtered view is a link you can share. ## Settings **Settings → General** (`/admin/settings`) holds the values an operator can change without a deploy: how long closed accounts, audit rows, email log rows and webhook records are kept, the grace period before an unrenewed subscription expires, the low-credit threshold, and feedback image limits. Affiliate terms are settings too, but they sit on the Affiliate page beside the data they govern. Each feature registers its settings in a client-safe `settings.ts` with a line in `src/core/settings/handlers.ts`: ```ts registerSetting({ key: 'auth.deleted_account_retention_days', group: 'accounts', groupLabel: () => m.admin_settings_group_accounts(), kind: 'number', fallback: ACCOUNT_RETENTION_DAYS, min: 7, max: 3650, label: () => m.setting_account_retention(), unit: () => m.setting_unit_days(), }) ``` The database stores only overrides. An untouched setting falls back to the default in code, so changing a default reaches every deployment that never overrode it. Kinds are `number`, `boolean`, `string` and `secret`; a secret is encrypted and never sent back to the browser once stored. Server code reads a value with `getSetting` or `getNumberSetting` from `src/core/settings/store.ts`, which caches for 60 seconds per isolate. Changing a setting requires `settings.write`; `settings.read` shows the page read-only. The full list is on the [Configuration](/docs/configuration) page. The other two Settings tabs: **Roles**, the role editor, and **Emails**, which previews every email template in every language and can send a test to your own address (see [Email and notifications](/docs/email-notifications)). ## Adding a page Say you are adding a Reports page to the Revenue section. 1. **Declare the permission** in your feature's `permissions.ts` (`reports.read`) and add its import line to `src/core/authz/handlers.ts`. Details on [Roles and permissions](/docs/permissions). 2. **Write the server function** from `adminFn`, and assert the permission first thing in the handler: ```ts export const listReportsAdminFn = adminFn.handler(async ({ context }) => { await assertPermission(context.session, 'reports.read') return getReports() }) ``` 3. **Create the route** at `src/routes/_app/admin/reports.tsx` with a guard, a loader and a component. Use `PageHeader` from `src/components/page-header.tsx`, which renders the section title and tabs: ```tsx export const Route = createFileRoute('/_app/admin/reports')({ beforeLoad: async ({ context }) => { await requirePermission(context.queryClient, 'reports.read') }, loader: ({ context }) => context.queryClient.ensureQueryData(adminReportsQuery), component: ReportsPage, }) ``` Then run `bun run generate-routes`. 4. **Add the row** to `adminNav` in `src/components/app-sidebar.tsx`: ```ts { to: '/admin/reports', section: 'revenue', permission: 'reports.read', label: () => m.admin_reports_title(), }, ``` If the page belongs to an optional feature, wrap the row in `// [feature: ] start` and `end` comments so the deletion recipe can remove it. 5. **Add the copy** to `messages/en.json` and `messages/zh.json`. Keys that start with `admin_` load with the console, not with the marketing site. `bun run test` will fail if the route has no `requirePermission` or the server function has no `assertPermission`. For the whole feature workflow, including the data layer, see [Adding features](/docs/adding-features); the `/add-feature` skill walks an agent through it. --- # API keys Source: https://shipkit.sh/docs/api-keys > The two kinds of API key, the admin data API at /api/v1/admin/* and the user API at /api/v1/me/*, the response envelope, and how to add an endpoint. ShipKit ships two external HTTP APIs, each with its own kind of key. They are for different jobs and are kept apart on purpose. | | Admin key | User key | |---|---|---| | Surface | `/api/v1/admin/*` | `/api/v1/me/*` | | Reads | every account's data | only the owner's own data | | Used by | analysis agents, scripts, reports | your customers' own code | | Issued at | **Admin → Developers → API keys** (`/admin/api-keys`) | **API keys** in the app sidebar (`/api-keys`) | | Who can issue | roles holding `api_keys.manage` | any signed-in user | | Prefix | `tsk_adm_` | `tsk_usr_` | A key issued for one surface is refused by the other, in both directions: an admin's user key cannot read the admin API, and an admin key cannot call `/api/v1/me/*`. Revoking one never disturbs the other. The point is blast radius: a leaked user key exposes one account, and keeping the kinds separate means an automation key can never turn out to be the kind that exposes all of them. ## Issuing a key Both pages create keys through better-auth's API key plugin. A key is shown once, right after it is created, with something ready to paste: the user page gives a `curl` against `/api/v1/me`, and the admin page gives a short prompt for an AI agent that includes the base URL, the auth header and where to find the endpoint documentation. After that the list shows only the key's first characters. Revoke a key from the same page. Keys do not become sessions. Every request verifies the key and then re-reads its owner: a banned or closed account's keys stop working at once. ## Calling the API Send the key as a bearer token, or in `x-api-key`: ```bash curl -H "Authorization: Bearer $SHIPKIT_KEY" https://your-app.com/api/v1/me ``` Each key is rate-limited to 300 requests per minute; over that, the API answers `429`. A list endpoint answers with a page of rows: ```json { "data": [ … ], "meta": { "total": 42, "limit": 100, "offset": 0 } } ``` and a single-object endpoint with `{ "data": { … } }`. Every list endpoint accepts the same paging and date filters: `limit` (1–500, default 100), `offset`, and `since`/`until` (an ISO date or epoch milliseconds, applied to `createdAt`). Money is integer cents with a `currency` field. Errors share one shape and never include a stack trace: ```json { "error": { "code": "forbidden", "message": "API key owner lacks the billing.read permission." } } ``` | Status | `code` | Meaning | |---|---|---| | 400 | `bad_request` | invalid query or body | | 401 | `unauthorized` | missing, invalid or expired key | | 403 | `wrong_scope` | the key was issued for the other surface | | 403 | `forbidden` | the owner is banned or closed, or lacks the permission | | 429 | `rate_limited` | over 300 requests per minute | | 500 | `internal` | an unexpected error, logged on the server | ## The admin API Everything under `/api/v1/admin/*` is a read-only `GET`. An admin key carries its owner's role, and each endpoint asks for the same permission as the console page that shows the same data. A key held by someone with only `billing.read` reads orders and gets a `403` on users. See [Roles and permissions](/docs/permissions). | Endpoint | Permission | |---|---| | `/api/v1/admin/stats` | `admin.access` | | `/api/v1/admin/users` | `users.read` | | `/api/v1/admin/subscriptions`, `/orders` | `billing.read` | | `/api/v1/admin/credits`, `/credits/balances` | `credits.read` | | `/api/v1/admin/audit` | `audit.read` | | `/api/v1/admin/emails` | `email.read` | | `/api/v1/admin/feedback` | `feedback.read` | | `/api/v1/admin/waitlist` | `waitlist.read` | Most endpoints take their own filters on top of the shared ones, for example `status`, `planId` and `userId` on subscriptions. The endpoints of a deleted module go with it. ## The user API `/api/v1/me/*` is scoped to the key's owner. Most of it describes the account; one endpoint does billable work. | Endpoint | Returns | |---|---| | `GET /api/v1/me` | the owner's id, email and name, a cheap check that a key works | | `GET /api/v1/me/subscription` | the current subscription, or `null` | | `GET /api/v1/me/orders` | the owner's purchases | | `GET /api/v1/me/credits` | the credit ledger, with the balance in `meta.balance` | | `POST /api/v1/me/run` | a worked example of a metered endpoint | `/api/v1/me/run` is the file to copy when you sell usage: it holds the most a call can cost in credits, does the work, then settles the actual cost, and it honours an `Idempotency-Key` header so a retried request is not charged twice. It answers `402 insufficient_credits` when the balance cannot cover the ceiling. [Credits](/docs/credits) explains the pattern. ## Adding an endpoint Endpoints are TanStack Start server routes under `src/routes/api/v1/`. The wrappers in `src/core/server/api.ts` handle the key, the scope, the permission, query parsing and the envelope, so a route is: parse, query, return. An admin endpoint wraps its `GET` in `adminApi` and names its permission: ```ts // src/routes/api/v1/admin/reports.ts export const Route = createFileRoute('/api/v1/admin/reports')({ server: { handlers: { GET: adminApi( ({ request, query }) => listReportsForApi({ ...query, ...parseQuery(request, reportsFilterSchema) }), 'reports.read', ), }, }, }) ``` The handler returns `{ data, total }` (plus optional `meta`). Put the query itself in the feature, in `features//server/admin.ts`, and use the `dateRange` helper so `since`/`until` work. Keep the admin API read-only. A user endpoint uses one of three wrappers, all of which authenticate a user key and hand you its owner: - `userApi` for a `GET` that returns a page of rows; - `userDoc` for a `GET` that returns one object; - `userAction(schema, handler)` for a `POST` with a JSON body validated by a zod schema. ```ts // src/routes/api/v1/me.reports.ts export const Route = createFileRoute('/api/v1/me/reports')({ server: { handlers: { GET: userApi(({ user, query }) => myReports(user.id, query)), }, }, }) ``` Nothing in the wrapper can scope a query for you. Every query behind a user endpoint must filter on `user.id` itself, which is why those queries live in the feature's `server/me.ts` next to the rest of its code. To refuse a call, throw `new ApiError(status, code, message)` and the wrapper turns it into the error envelope. Never authenticate a key by hand, and never let one endpoint accept both kinds of key. Run `bun run generate-routes` after adding a route file. If the endpoint belongs to an optional feature, add the route file to that feature's recipe in `scripts/verify-deletion.ts` so deleting the feature removes it too. ## The admin-api skill The admin API is documented for agents in `.claude/skills/admin-api/SKILL.md`: every endpoint with its filters and fields, plus recipes for common analysis such as growth, revenue, churn and credit burn. In Claude Code it is the `/admin-api` skill. The folder is self-contained, so you can copy it to any other agent. The app also serves the same file at `/api/v1/admin/skill.md`, publicly and without a key, since it holds no secrets. The prompt the admin keys page hands you points an agent there first. When you add an admin endpoint, add it to the skill file so agents can find it. --- # Email and notifications Source: https://shipkit.sh/docs/email-notifications > Transactional email through Resend, templates localized by each user's locale, the email log, and the in-app notification bell fed by notify(). ShipKit has two ways to tell a user something happened: a transactional **email**, sent through Resend, and an in-app **notification** that shows up under the bell in the app sidebar. Both are optional infrastructure — with no API key, or with the notifications feature deleted, every call is a harmless no-op. ## Setting up Resend Email goes through the [Resend](https://resend.com) REST API with a plain `fetch` (no SDK), in `src/core/email/send.ts`. Three values control it: | Where | What | | --- | --- | | `RESEND_API_KEY` in `.dev.vars` / a Worker secret | Turns sending on. Without it every send is logged as `skipped`. | | `emailFrom` in `src/config/app-config.ts` | The From address. Use a verified **sending subdomain** such as `noreply@send.example.com`. | | `supportEmail` in `src/config/app-config.ts` | Set as Reply-To on every email. It must reach a real inbox. | A sending subdomain keeps Resend's SPF/DKIM records off your root domain, so your inbound mail provider's records do not have to share it. Verify the subdomain in Resend before changing `emailFrom`. Without a key, sign-in codes are printed to the server log so local sign-in still works (see [Authentication](/docs/authentication)). In production a missing key means nobody can sign in by email — add it before you [deploy](/docs/deploy). ## What gets sent | Email | Sent when | Code | | --- | --- | --- | | Sign-in code | Someone asks for one on the login page | `src/features/auth/server/auth.ts` | | Welcome | A new account is created | `src/features/auth/server/auth.ts` | | Receipt | A payment succeeds | `src/features/billing/server/events.ts` | | Subscription canceled | A subscription is canceled | `src/features/billing/server/events.ts` | | Payment failed | The first failed renewal in a row | `src/features/billing/server/events.ts` | | Account closed | The user deletes their account | `src/features/auth/server/fns.ts` | | Erasure reminder | Three days before a closed account is erased | `src/features/auth/server/jobs.ts` | | Feedback reply | An admin replies to feedback | `src/features/feedback/` | | Waitlist approved | An admin lets someone in | `src/features/waitlist/` | The billing emails fire once per real state change, so a webhook redelivery does not send a second receipt. ## Sending an email Templates live in `src/core/email/templates/`. Each is a plain function that takes a locale and data and returns `{ subject, html }`. `layout.ts` provides the shared HTML shell (inline styles only, one button, no images) plus `button()`, `keyValueTable()` and `escapeHtml()`. ```ts import { emailLocale, sendEmailSafely } from '@/core/email/send' import { welcomeEmail } from '@/core/email/templates/welcome' const locale = emailLocale(user.locale) await sendEmailSafely({ to: user.email, template: 'welcome', // names the row in the email log userId: user.id, // links it to the recipient locale, ...welcomeEmail({ name: user.name, locale }), }) ``` Two send functions: - `sendEmailSafely` catches and logs any failure. Use it for side effects of something more important — a sign-up, a webhook — where a mail outage must not fail the main action. - `sendEmail` throws on a provider error and returns `{ sent: boolean }`. Use it when the email **is** the action; the sign-in code uses it. Always pass `template` and `userId`, so the delivery is attributable in the log. ### Localizing a template Emails are localized by the **recipient's** stored `user.locale`, never by the request that happened to trigger them — a webhook has no reader at all. `emailLocale()` turns the free-form column into a valid locale, falling back to English. Inside a template, pass the locale to each message explicitly: ```ts subject: m.email_welcome_subject({ app }, { locale }), ``` Email copy lives in `messages/{en,zh}.json` under `email_*` keys. See [Internationalization](/docs/i18n). ### Adding a template 1. Write `src/core/email/templates/.ts` using `layout()` and `email_*` messages in every language. 2. Add it to `emailTemplateIds` and `renderEmailPreview` in `src/core/email/templates/samples.ts` with sample data. 3. Give it a label in `templateLabel` in `src/routes/_app/admin/emails.tsx` (typecheck fails until you do). The unit test in `templates.test.ts` renders every registered template in every locale. ## Previewing and testing **Admin → Settings → Emails** (`/admin/emails`) renders each registered template with sample data in any language, and can send a real test to your own address. That test goes through Resend, so it also proves the API key and sender domain work. The sign-in code email is not in the preview list. ## The email log Every send attempt is reported through `src/core/email/events.ts`, and the `email-log` feature stores it in the `email_log` table: template, recipient, subject, locale, status (`sent`, `skipped` or `failed`) and the provider id or error. Bodies are never stored. - **Admin → Developers → Email log** (`/admin/email-log`) lists the attempts, and a user's page in the console shows theirs. - `GET /api/v1/admin/emails` serves the same rows to an [admin API key](/docs/api-keys). - Rows older than 90 days are pruned nightly. The window is an operator setting in the console. - A user's rows are deleted when their account is erased, and included in their data export. When someone says "no code arrived", a `failed` row with the provider's error is where to look. Delete the `email-log` feature and sending keeps working; nothing is recorded. ## In-app notifications The bell sits at the top of the app sidebar (the mobile top bar on phones). A dot means something is unread; opening it lists the latest 50 and marks all of them read. Rows that belong to credits or feedback open the matching section of the account dialog; the rest open a small detail view. Producers call `notify()` from `src/core/notify/events.ts`: ```ts import { notify } from '@/core/notify/events' await notify({ userId: data.userId, type: 'credits.adjusted', // '.' params: { amount: data.amount, reason: data.reason }, }) ``` The row stores only the `type` and small scalar `params`. The copy is rendered when the bell opens, in the viewer's current language — so a notification follows the user if they switch languages later. To add a type, call `notify()` from your feature and add a renderer for it in `src/features/notifications/components/notification-bell.tsx`. A type with no renderer is skipped, never an error. Rules that keep it honest: - Call `notify()` **after** the write it describes has succeeded. D1 has no transactions, so a lost notification is acceptable; a notification about something that did not happen is not. - If the write is deduplicated (a webhook redelivery), deduplicate the notification the same way. - Never put secrets or free text from other users in `params`. There are no sockets. The bell polls every 60 seconds and refetches when the tab regains focus. Notifications are deleted with the user's account. ## Desktop notifications `src/lib/browser-notify.ts` wraps the browser's Notification permission together with a per-browser on/off switch shown in **Settings → Preferences**. Nothing in the template sends a desktop notification itself; the helper is there for features that make the user wait. ## Removing them Both `email-log` and `notifications` are deletable modules — see [Deleting features](/docs/deleting-features). The core senders (`sendEmail`, `notify`) stay and keep compiling. --- # File storage Source: https://shipkit.sh/docs/file-storage > How ShipKit stores files in Cloudflare R2 through a Worker binding — uploads, private downloads, profile photos, quotas and the optional files module. All stored files live in one Cloudflare R2 bucket, reached through a Worker binding called `BUCKET`. There are no S3 access keys and no presigned URLs: the browser posts a file to a server route, the Worker streams it into R2, and downloads come back through the Worker the same way. That keeps every read behind your own session check, at the cost of every byte passing through a Worker. Three things use the bucket: | What | Upload route | Read route | R2 key | Who can read | | --- | --- | --- | --- | --- | | Files module | `POST /api/files` | `GET /api/files/$fileId` | `//` | The owner only | | Profile photos (core) | `POST /api/avatar` | `GET /api/avatar/$userId` | `avatars/` | Anyone | | Feedback screenshots | `POST /api/feedback-image` | `GET /api/feedback-image/$userId/$id` | `feedback//` | The uploader, and roles with `feedback.read` | ## The binding The bucket is declared in `wrangler.jsonc`: ```jsonc "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "shipkit-files" } ] ``` In development, `bun run dev` runs the app in workerd with a local R2 binding, so uploads work with no Cloudflare account; the objects are kept under `.wrangler/`. For production, create the bucket once before your first deploy (the `/deploy` skill does this for you): ```bash bunx wrangler r2 bucket create shipkit-files ``` Server code reaches the bucket through `getBindings().BUCKET` from `src/core/server/cf.ts` — the one module allowed to import `cloudflare:workers` (see [Architecture](/docs/architecture)). Uploads and downloads are API routes rather than server functions because they carry multipart bodies and binary streams. They are one of the few places the template uses `server.handlers` instead of the normal data channel described in [Server functions](/docs/server-functions). ## Uploading a file The files module's upload route is `src/routes/api/files.ts`. It takes a multipart `POST` with a single `file` field, which is what the `/files` page sends: ```ts const form = new FormData() form.append('file', file) const res = await fetch('/api/files', { method: 'POST', body: form }) ``` In order, the route: 1. Requires a session (`401` without one). 2. Rate-limits by user id, against the `PUBLIC_RATE_LIMIT` binding (5 requests per minute by default in `wrangler.jsonc`) — `429` when over. 3. Refuses anything over 25 MB, first by the declared `Content-Length` and again by the actual file size (`413`). 4. Checks the account's total against a 1 GB quota (`507` when it would go over). 5. Streams the file into R2 with its content type, inserts a row into the `files` table, and writes a `files.uploaded` audit entry. The two limits are constants at the top of the route: ```ts const MAX_SIZE = 25 * 1024 * 1024 // 25 MB const USER_QUOTA = 1024 * 1024 * 1024 // 1 GB ``` Change them there. Two limitations to know before raising them a lot: the route reads the body with `request.formData()`, which buffers the whole file in the Worker's memory, and Cloudflare caps request body size by plan. For very large files, direct-to-R2 uploads with presigned URLs are the better shape, and that would be a replacement of this one route. The quota check is also a read before a write, so two parallel uploads can overshoot it by one file. ## Downloading a file `GET /api/files/$fileId` (`src/routes/api/files.$fileId.ts`) looks the row up by id **and** the caller's user id, so a file id is useless to anyone but its owner. There is no public or shared link. The response's `Content-Disposition` depends on the stored type. Images (PNG, JPEG, GIF, WebP, AVIF), video, audio, plain text and PDF open inline; everything else downloads as an attachment. This matters because the stored type is whatever the uploader declared: an HTML or SVG file shown inline would run its scripts on your app's origin with the viewer's session. If you add sharing or an admin preview later, keep that rule. ## Deleting `deleteFileFn` in `src/features/files/server/fns.ts` is a normal server function. It deletes the R2 object first and then the row. D1 and R2 cannot share a transaction, so if the second step fails you are left with a row that points at nothing — harmless and deletable again, which is why that order was chosen over the reverse (an object nobody can find). ## Profile photos Avatars are part of core, not the files module. The account dialog posts to `/api/avatar`, which accepts PNG, JPEG, WebP or GIF up to 2 MB (`src/features/auth/avatar.ts`) and replaces the user's single object at `avatars/`. There is no removal, only replacement. The route stores a versioned URL in `user.image`: ``` /api/avatar/?v= ``` Avatars are public, and because every upload changes the `?v=` value, the read route serves them with `Cache-Control: public, max-age=31536000, immutable`. A user who never uploads keeps the picture URL their Google account provided. ## Account deletion and export The `files` rows cascade with the user, but R2 objects do not. Each feature that writes to the bucket registers cleanup in `src/core/account/events.ts` (see [Authentication](/docs/authentication) for the account-deletion flow): - the files module deletes every object it has a row for, before the rows go; - core auth deletes the avatar; - the feedback module deletes everything under the user's `feedback/` prefix, which also catches screenshots whose feedback was never submitted. This runs when the account is purged, not when it is closed, so a restored account keeps its files. **Download my data** includes file metadata (name, size, type, date), not the files themselves. ## In the admin console The files module adds a section to the user detail drawer and page, gated by the `files.read` permission: the user's file count, total size and latest uploads. It shows metadata only — an admin never gets a download link from the console. See [Admin console](/docs/admin-console) and [Permissions](/docs/permissions). ## Removing the files module The files module is optional. Its recipe removes `src/features/files/`, the `/files` page, both `/api/files` routes, its sidebar row, the dashboard card and the registry lines. The R2 binding stays in `wrangler.jsonc`, because profile photos use it. See [Deleting features](/docs/deleting-features). --- # Internationalization Source: https://shipkit.sh/docs/i18n > Paraglide messages, English at / and Chinese at /zh, how locale is detected and remembered, localized emails and content, and adding a language. ShipKit ships in English and Chinese. Translation uses [Paraglide JS](https://inlang.com/m/gerre34r/library-inlang-paraglideJs), which compiles every message into a small typed function, so a missing key is a type error and a page only downloads the strings it uses. ## Messages All UI copy lives in two flat JSON files: ``` messages/en.json messages/zh.json ``` Keys are flat and prefixed by area — `auth_login_title`, `admin_emails_title`, `email_welcome_subject`, `setting_account_retention`. Parameters use braces: ```json { "email_welcome_subject": "Welcome to {app}" } ``` In a component, import `m` and call the key: ```tsx import { m } from '@/paraglide/messages'

{m.auth_login_title()}

{m.email_welcome_subject({ app: appConfig.name })}

``` The compiled output in `src/paraglide/` is generated by the Paraglide Vite plugin whenever the dev server or a build runs, using the options in `vite.config.ts`. Do not run `paraglide-js compile` by hand: it ignores those options (cookie name, URL strategy) and locale routing breaks until the dev server restarts. The project settings — base locale and the list of locales — are in `project.inlang/settings.json`. ### Why the prefixes matter The prefixes are also how the bundle is split. `vite.config.ts` groups messages by prefix into chunks: `admin_*` loads with the admin console, and `marketing_`, `blog_`, `docs_`, `home_`, `footer_`, `seo_` and `legal_` load with the public site. A visitor to the landing page does not download the console's copy. If you add an area with a lot of text, give its keys a prefix and consider adding a group there. ## URLs English is unprefixed and Chinese is under `/zh`: | English | Chinese | | --- | --- | | `/pricing` | `/zh/pricing` | | `/dashboard` | `/zh/dashboard` | | `/` | `/zh` | The route tree has no locale segment. The router (`src/router.tsx`) strips the prefix from incoming URLs and adds it back to outgoing ones, so route files are written once and a `` points at `/zh/pricing` when the reader is in Chinese. For an href built outside the router, use `localizeHref()` from `@/paraglide/runtime`. ### How the locale is chosen The Paraglide strategy is `url`, then `cookie`, then the browser's preferred language, then the base locale (`en`). Because an unprefixed URL always means English, the other signals are applied by a small script at the top of `` in `src/routes/__root.tsx`: on an unprefixed page it redirects to `/zh/...` if the `APP_LOCALE` cookie says `zh`, or, on a first visit with no cookie, if the browser's language is Chinese. This runs in the browser because marketing pages are prerendered and served as static files, where no server code can look at the request. On the server, `src/server.ts` wraps every request in `paraglideMiddleware`, so `getLocale()` resolves correctly during server rendering. ### Switching language The language menu in the marketing header and the language row in **Settings** both call `setLocale()`, which writes the `APP_LOCALE` cookie and reloads the page in the new language. The Settings row also saves the choice to the user's `locale` column, which is what emails are sent in. ### SEO Every page emits a self-referencing canonical plus `hreflang` alternates for each locale and an `x-default` (in `__root.tsx`). The sitemap generator (`scripts/generate-sitemap.ts`) lists each English page with its `/zh` twin. ## Server functions never localize A server function is called at `/_serverFn/...`, a URL with no locale segment. Paraglide inside one resolves from the cookie or `Accept-Language`, not from the page the caller is looking at — so a visitor who reached `/zh` by a link would get English strings back. The rule is: - Server code returns **keys and values** (an error code, an amount, a status). - The **component** calls `m.*` to turn them into text. Account deletion is a real example. A feature guard on the server refuses with a code (`blockAccountDeletion('ACTIVE_SUBSCRIPTION')`), and the settings component maps it to copy: ```ts switch (code) { case 'ACTIVE_SUBSCRIPTION': toast.error(m.settings_danger_active_subscription()) break default: toast.error(m.auth_error_generic()) } ``` The registries the admin console renders, such as settings and permissions, keep their labels as functions (`label: () => m.setting_account_retention()`) and are read in the browser for the same reason. The landing page config in `src/config/landing.ts` does the same: every string is a thunk, because the module is evaluated once per Worker isolate while the locale is per request. The exception is text that leaves the app — emails and notifications. Those are localized explicitly by the **recipient's** `user.locale`, never by the request: ```ts m.email_welcome_subject({ app }, { locale }) ``` `user.locale` is set at sign-up from the language cookie and changed in Settings. See [Email and notifications](/docs/email-notifications). In-app notifications store only a type and parameters and are rendered in the viewer's current language when the bell opens. ## Localized content Blog posts, docs and legal pages are MDX files, one per locale: `privacy.en.mdx` and `privacy.zh.mdx`, or `getting-started.mdx` with a `getting-started.zh.mdx` beside it. A missing translation falls back to the locale-neutral file, then to English, so a page never disappears from a list for lack of a translation. See [Content](/docs/content). ## Adding a language Adding a third locale is more than a JSON file, because some places know the two current locales by name. Using German (`de`) as the example: 1. Add `"de"` to `locales` in `project.inlang/settings.json`. 2. Create `messages/de.json` with every key from `messages/en.json`. 3. In `vite.config.ts`, add `['de', '/de/:path(.*)?']` to `urlPatterns` before the `en` entry, and add `{ path: '/de/' }` to the prerender `pages`. 4. Add the language's display name to `localeNames` in `src/components/LocaleSwitcher.tsx` and in `src/features/auth/components/account-section.tsx`. 5. Update the places that hard-code `zh`: - the redirect script and `og:locale` in `src/routes/__root.tsx` - `scripts/generate-sitemap.ts` - `src/config/private-paths.ts`, whose `/zh` variants keep private pages out of the prerender and `robots.txt` - `src/core/email/templates/receipt.ts`, for number and date formats - `stripeLocale()` in `src/core/payment/stripe.ts`, for Stripe's checkout language - the `'en' | 'zh'` type in `src/features/billing/server/events.ts` 6. Add `.de.mdx` files for the legal pages and any docs or posts you translate. 7. Extend the tests that loop over `['en', 'zh']` (`src/core/settings/i18n.test.ts`, `src/core/email/templates/templates.test.ts`). Then run `bun run typecheck` and `bun run test`, and click through `/de` with `bun run dev`. --- # Blog, docs and legal pages Source: https://shipkit.sh/docs/content > Writing the blog, docs and legal pages in MDX — file naming per language, frontmatter, the table of contents, and adding a collection of your own. The blog, the docs you are reading and the legal pages are MDX files in the repo, compiled at build time and prerendered to static HTML. There is no CMS and no database behind them: publishing a post is committing a file. | Collection | Folder | URL | Frontmatter | | --- | --- | --- | --- | | Blog | `content/blog/` | `/blog/` | `title`, `description`, `date`, `category`, `cover` | | Docs | `content/docs/` | `/docs/` | `title`, `description`, `group`, `order` | | Legal | `content/legal/` | `/legal/` | `title`, `updated` | Blog and docs belong to the optional `content` feature (`src/features/content/`). Legal pages are core: a product that takes payments needs terms and a privacy policy whether or not it has a blog, so they survive when the content feature is deleted. ## Files and languages A page's slug is its file name. A file can be locale-neutral or tied to one locale: ``` content/docs/deploy.mdx # neutral content/docs/deploy.zh.mdx # Chinese content/blog/launch.en.mdx # English content/blog/launch.zh.mdx # Chinese ``` For each visitor the page is picked in this order: the file for their locale, then the neutral file, then the English one. A page you have not translated yet still shows up — in English — at `/zh/docs/`, rather than dropping out of the list. See [Internationalization](/docs/i18n) for how the locale is chosen from the URL. ## Frontmatter Frontmatter is YAML at the top of the file, validated with zod in `src/features/content/collections.ts` (blog and docs) and `src/core/content/legal.ts` (legal). A missing or misspelled field fails the dev server or the build, not a visitor. A docs page: ```yaml --- title: Deploy description: One sentence under ~155 characters. It is the meta description and the subtitle under the title. group: start order: 5 --- ``` `description` is used twice: as the page's meta description and as the subtitle the page renders under its title. So do not repeat it as the first paragraph, and do not start the body with a `#` heading — the page prints the title itself. A blog post: ```yaml --- title: "D1 has no interactive transactions" description: "What to do instead." date: "2026-09-05" category: specs cover: blog-d1 --- ``` - `date` is a string, and the blog index sorts by it, newest first. - `category` is one of `guide`, `specs` or `example` (`postCategories`), shown as filter pills on `/blog`. - `cover` is a file stem: `blog-d1` reads `public/covers/blog-d1.webp`. Covers are committed next to the posts rather than uploaded anywhere, so a post and its picture ship in the same commit. The template's covers are free stock photos; replace them with your own. ## Writing MDX MDX is Markdown plus JSX. Everything in standard Markdown works, plus GFM tables, strikethrough and task lists (`remark-gfm`). A wide table scrolls inside its own box on a phone. You can import modules at the top of a file and use them in the text. The legal pages do this to print your company name from the config: ```mdx import { appConfig } from '@/config/app-config' This Service is operated by {appConfig.legalEntity.name}. ``` Two traps, because MDX parses JSX: a bare `<` or `{` in prose has to be in backticks or escaped, and HTML comments are not allowed — write `{/* a comment */}` instead. One custom component is available in every page without an import: ``, a video with a poster frame, read from your media origin (`appConfig.mediaUrl`, see [Landing page and themes](/docs/landing-themes)). Until `mediaUrl` is set it renders an empty placeholder. MDX is compiled at build time by `@mdx-js/rollup`. There is no runtime MDX evaluation — workerd does not allow it — so content cannot come from a database or an API without replacing this pipeline. ## Table of contents Every `##` and `###` heading gets an anchor id at compile time, and the page receives the list of headings with it (`src/core/content/remark-headings.ts`). That list becomes the table of contents: on the right of docs pages on wide screens, and on the left of blog posts. Because it is built with the page, it is in the prerendered HTML and can never link to an anchor that does not exist; only the "you are here" highlight needs JavaScript. Things that follow from how it works: - Only top-level `##` and `###` headings count. `####` and headings inside JSX are left out. - The list hides itself when a page has fewer than two headings. - Anchors come from the heading text (lowercased, spaces to hyphens, punctuation dropped). Chinese headings keep Chinese anchors. Renaming a heading changes its anchor, so links to `#old-name` stop jumping. - Two headings with the same text get `-1`, `-2` suffixes. ## Docs sidebar and ordering The docs sidebar is built from frontmatter. `group` must be one of the sections in `docGroups` (`start`, `concepts`, `features`, `customize`, `reference`), shown in that order, and `order` sorts pages inside a group. The previous/next links at the bottom follow the same order, and `/docs` redirects to the first page. To add a section, add its id to `docGroups` in `src/features/content/collections.ts`, a `docs_group_` message in `messages/en.json` and `messages/zh.json`, and its entry in the `groupLabel` map in `src/routes/_marketing/docs/$slug.tsx`. That map is typed as a record over the groups, so typecheck tells you if you miss it. ## Legal pages Legal pages are one file per locale: `content/legal/terms.en.mdx`, `terms.zh.mdx`, and the same for `privacy`, `refunds` and `dmca`. They carry an `updated` date, printed under the title. The shipped text is a template — have a lawyer review it before you rely on it. The list of legal slugs is fixed in `legalSlugs` in `src/core/content/legal.ts`, and the footer links to each one by hand in `src/routes/_marketing.tsx`. A new legal page needs its files, an entry in that list and a footer link. ## How pages load Each collection is two `import.meta.glob` calls over the same files: one loads only the frontmatter (for lists, the sidebar and titles), the other loads the page body lazily, one chunk per file. A page's route loader calls `entry.load()` so the body is ready before it renders. The split matters for speed. Frontmatter is read on every page, and reading it from the compiled MDX module would pull every post's body into the main bundle. Keep the pattern when you add a collection. ## Prerendering and the sitemap Blog, docs and legal pages are prerendered at `bun run build`: the prerender crawls links from the home page, so a page is included as long as something links to it. After the build, `scripts/generate-sitemap.ts` writes `sitemap.xml` from the prerendered files. There is nothing to register by hand. ## Adding a collection Say you want a changelog at `/changelog/`: 1. Put the files in `content/changelog/`. 2. In `src/features/content/collections.ts`, add a zod schema and a `collect(...)` call with the two globs, copied from `posts`. Both glob patterns must be string literals, because Vite rewrites them at build time. 3. Add routes under `src/routes/_marketing/changelog/` modelled on the blog ones: the index lists entries from frontmatter, the `$slug` loader finds the entry with `pickLocalized` and awaits `load()`, and the component renders `` (plus `` if you want one). 4. Link to it from the header or footer in `src/routes/_marketing.tsx`, so the prerender finds it. 5. Run `bun run generate-routes` if the dev server is not running. 6. Extend the `content` recipe in `scripts/verify-deletion.ts` to remove the new folder, routes and links, then run `bun run verify:deletion content`, so the feature stays deletable. ## Removing blog and docs The `content` recipe deletes `src/features/content/`, the blog and docs content, routes and post covers, and the `/blog` and `/docs` links in the header, footer and landing page. The MDX pipeline stays, because the legal pages use it. See [Deleting features](/docs/deleting-features). --- # Landing page and themes Source: https://shipkit.sh/docs/landing-themes > The landing page as a list of blocks in one config file, the block shapes you can use, the four built-in themes, and product screenshots. The landing page is data. `src/config/landing.ts` exports an array of blocks, and `src/routes/_marketing/index.tsx` does nothing but render it — the route holds no copy and no layout. The look of the whole app, landing page included, comes from a theme: a set of CSS custom properties, with four built in. ## Editing the landing page Everything in `landing.ts` is the template's own pitch and is meant to be replaced. The common edits are one-line changes: | To | Do | | --- | --- | | Reorder sections | Move an entry in the array | | Remove a section | Delete its entry | | Add a section | Add an entry with one of the block shapes below | | Change copy | Edit the message in `messages/en.json` and `messages/zh.json` | A block is an object with a `type` and that shape's fields: ```ts import { m } from '@/paraglide/messages' export const landing: readonly LandingBlock[] = [ { type: 'hero', title: () => m.marketing_hero_title(), subtitle: () => m.marketing_hero_subtitle(), }, { type: 'faq', kicker: () => m.marketing_faq_kicker(), title: () => m.marketing_faq_title(), items: [ { q: () => m.marketing_faq_cost_q(), a: () => m.marketing_faq_cost_a() }, ], }, { type: 'cta', title: () => m.marketing_cta_band(), docs: () => m.marketing_cta_band_docs(), }, ] ``` ### Every string is a function Copy is always written as `() => m.key()`, never `m.key()`. The config module is evaluated once per Worker isolate, but the locale belongs to each request. A bare call would freeze whichever language loaded the module first and serve it to everyone. The `Text` type in `src/components/marketing/blocks/types.ts` enforces this, so a bare string or call fails typecheck. See [Internationalization](/docs/i18n). ### Tone Most blocks take an optional `tone`: `'plain'` (the page ground, the default) or `'sheet'`, which lays a rounded grey sheet under the section. Alternating the two gives a long page rhythm without images. One rule: blocks whose own cards use the surface colour — `modules`, `bento`, `chips`, `pricing`, `faq`, `compare`, `gallery`, `testimonials` — must stay `plain`, or the cards disappear into the sheet. ## Block shapes All shapes are one union type, `LandingBlock`, in `src/components/marketing/blocks/types.ts`, and each is implemented in its own file beside it. | Type | What it renders | | --- | --- | | `hero` | Title, subtitle, the main call to action and a docs link | | `logos` | Rows of brand marks, such as your stack | | `logos-grouped` | Brand marks in labelled groups | | `chips` | Named chips in groups, for things with no brand mark | | `stats` | Big numbers that count up when scrolled into view | | `terminal` | A shell session, line by line | | `steps` | Numbered steps, each with its own shell block | | `surfaces` | Full-width product screenshots that swap as the reader scrolls | | `modules` | A ledger of what is included: a name and short chips per row | | `bento` | An uneven grid of cards | | `compare` | A comparison table | | `gallery` | Things people built with your product; renders nothing when empty | | `testimonials` | Customer quotes on moving cards | | `pricing` | Your plans, read from `src/config/plans.ts` | | `faq` | Folding questions and answers | | `cta` | The closing call to action | The `pricing` block has no prices of its own: it reads the same `src/config/plans.ts` that checkout uses, so the page cannot advertise a price you do not charge (see [Payments](/docs/payments)). The main call-to-action target in `hero` and `cta` is `/dashboard`, or `/waitlist` when the waitlist is switched on in `src/config/app-config.ts`. ### The block palette at /blocks The default page uses only some of the shapes. Run `bun run dev` and open `/blocks` to see every shape rendered with sample data. It is a workbench, so its copy is plain English strings. Copy the object you want into `landing.ts` and replace its strings with message thunks. The page renders nothing in a production build. ### Adding a shape Add a member to the `LandingBlock` union, a component in a new file under `src/components/marketing/blocks/`, an export in `blocks.tsx`, and a case in the switch in `render.tsx`. The switch is exhaustive, so a missing case is a type error rather than a section that silently renders nothing. ### Before launch The default page ships a `testimonials` block with invented people and placeholder avatars. Replace every item with a real customer's words, used with their permission, or delete the block. Leaving it in publishes invented reviews. ## Screenshots and media The `surfaces` block shows real screenshots of the app. They are committed files in `public/marketing/`, one per theme mode: `admin-light.webp` and `admin-dark.webp`, and so on. Only the current mode's image is downloaded. To refresh them after the UI changes: ```bash bun run dev # in one terminal bun run capture:marketing # all shots; `bun run capture:marketing admin` for one ``` The script seeds the local database, signs in with test sessions, drives the real app with Playwright, and writes the files. It refuses to save a capture that shows an email address outside the seed data's fake domains, because the admin screens show whatever is in your local database. It converts PNGs to WebP with `cwebp` if that is installed. Adding a surface means adding an entry to `SHOTS` in `scripts/capture-marketing.ts` as well as to `landing.ts`. For heavier assets you do not want in git, such as video, the `` and `` components in `src/components/marketing/media.tsx` read from `appConfig.mediaUrl`, under a `marketing/` prefix. With `mediaUrl` empty (the default) they render a quiet placeholder rather than a broken image. `bun run upload:marketing ` resizes the images in a folder to WebP and uploads them with wrangler. Note that it uploads to the first bucket in `wrangler.jsonc`, the same bucket that holds private user files; if you give that bucket a public domain to serve as `mediaUrl`, consider a separate bucket for marketing media instead (see [File storage](/docs/file-storage)). ## Themes Four visual styles are built in, registered in `src/config/themes.ts`: | Theme | Look | | --- | --- | | `shipkit` (default) | Neutral and blue, soft corners | | `editorial` | Serif, square corners, printed paper | | `soft` | Lilac, round corners, gentle shadows | | `brutal` | Monospace, hard edges, loud yellow | Each has a light and a dark face. Two settings combine independently: - The **style** is the `data-theme` attribute on ``, set by `src/components/theme-provider.tsx`. The reader's choice is kept in `localStorage`, and a small inline script applies it before first paint so the page never flashes the default. - The **mode**, light or dark, is the `dark` class, handled by next-themes. Readers pick a style from the menu in the marketing header or the Style row in the account settings; mode has its own toggle. ### A theme is only tokens The default theme lives in `:root` and `.dark` in `src/styles.css`; the other three are blocks in `src/styles/themes.css` under `:root[data-theme="…"]` and `:root[data-theme="…"].dark`. No component reads `data-theme`. Everything a theme changes is a CSS custom property: colours such as `--background`, `--primary` and `--brand`, and shape tokens: | Token | Controls | | --- | --- | | `--radius-btn` | Button corners | | `--radius-card` | Card corners | | `--radius-sheet` | Large sheets and bands | | `--shadow-surface` | Shadow on cards, dialogs and menus | | `--shadow-raised` | Shadow on buttons | | `--font-body` | Body typeface | | `--font-display` | Marketing headline typeface | That is why a hand-written button uses `rounded-[var(--radius-btn)]` rather than `rounded-full`. If a new look needs a component to change shape, add a token for that shape and set it in each theme, instead of branching in the component. ### Changing the default or adding a theme To ship in a different look, change `defaultTheme` in `src/config/themes.ts`. If you want one look with no choice at all, also remove `ThemePicker` from `src/routes/_marketing.tsx` and the Style row (`ThemeRow`) from `src/features/auth/components/account-section.tsx`. To add a theme, add an entry to `themes` (id, label and hint messages, and three swatch colours for the picker) and write a light block and a dark block in `src/styles/themes.css`. The no-flash script is generated from the registry, so it needs no edit. The design rules for the default look — colour, radius, buttons — are in `src/CLAUDE.md`. --- # More modules Source: https://shipkit.sh/docs/more-modules > Product analytics, the affiliate programme, the audit log, in-app feedback, the pre-launch waitlist and the public demo mode, and how to switch each one on or off. Beyond sign-in, payments and credits, ShipKit ships six smaller modules. Each is a vertical slice under `src/features//`, and each can be removed with `/delete-feature ` (see [Deleting features](/docs/deleting-features)). Some are on as soon as you clone, some wait for a switch. | Module | Out of the box | Switch | | ---------- | -------------- | ------------------------------------------------------------- | | Analytics | Off | `analytics.posthog.key` in `src/config/app-config.ts` | | Affiliate | On | Delete the feature to turn it off | | Audit log | On | Delete the feature to turn it off | | Feedback | On | Delete the feature to turn it off | | Waitlist | Off | `waitlist: true` in `src/config/app-config.ts`, then deploy | | Demo mode | Off | `DEMO_MODE=true`, on a separate demo deployment only | The admin pages below are gated by permissions (`affiliate.read`, `audit.read` and so on); see [Permissions](/docs/permissions) and [Admin console](/docs/admin-console). ## Analytics (PostHog) Analytics has two halves, and both stay silent until you set a PostHog project key: ```ts // src/config/app-config.ts analytics: { posthog: { key: 'phc_…', // empty = no script, no events host: 'https://us.i.posthog.com', // or the EU cluster assets: 'https://us-assets.i.posthog.com', ui: 'https://us.posthog.com', }, }, ``` The key is a public, write-only token, which is why it sits in config and not in secrets. - **In the browser**, `src/features/analytics/components/posthog.tsx` loads the SDK lazily and records page views, including client-side navigations. It talks to PostHog through your own origin at `/api/ph`, so ad blockers see first-party traffic; that proxy has its own per-IP rate limit (`ANALYTICS_RATE_LIMIT` in `wrangler.jsonc`). Persistence is `localStorage`, not cookies. Autocapture and session recording are off. Signed-in visitors are identified by user id, and signing out resets the id. - **On the server**, any code can call `track()` from `@/core/analytics/events`. ShipKit already sends `signed_up`, `signed_in`, `account_deleted`, `checkout_started`, `subscription_started`, `purchase_completed`, `waitlist_joined` and `waitlist_approved`. Events are snake_case, past tense, with a few flat properties; never secrets or free text. `track()` is best-effort: a PostHog failure is logged and never fails the action it describes. With the feature deleted, `track()` stays and does nothing, so call sites need no changes. ## Affiliate programme Any user can join from **Affiliate** (`/affiliate`) in the app sidebar. They get a permanent code and a link, `/r/`. 1. A click on the link sets an `AFFILIATE_REF` cookie for 30 days and redirects to the home page. It is a server route because the marketing pages are prerendered and cannot set cookies. 2. When a new account is created with that cookie, it is attributed to the affiliate, once and for good. Unknown codes and self-referrals are ignored without failing the signup. 3. Every `order.paid` from that user, renewals included, creates a commission at 20% of the amount. It stays `pending` for a 30-day refund holdback; a full refund in that window voids it. A nightly job then marks it `approved`. 4. Once the approved balance reaches $50, the affiliate can request a payout, naming where to send it. 5. An admin reviews requests under **Revenue → Affiliates** (`/admin/affiliate`), sends the money through whatever they already use, and marks the request paid or rejected. ShipKit does not move money itself. Rejecting releases the commissions so they can be requested again. All four numbers (rate, holdback, minimum payout, attribution window) are operator settings under **Settings → Affiliates**, so you can change them without a deploy. A changed rate applies to the next order, not to past ones. For a negotiated deal, set `commissionBps` and `negotiatedRate` on that affiliate's row. An account with an unanswered payout request cannot be deleted until the request is settled. ## Audit log `audit()` from `@/core/audit/events` records who did what to which thing. Sign-ins and other auth events, role and ban changes, API key management, credit adjustments, plan changes and every payment event are already covered; payment events have no actor because they come from a webhook. Add a call anywhere your code changes account or security state: ```ts import { audit, requestMeta } from '@/core/audit/events' await audit({ action: 'project.deleted', // . actorId: context.session.user.id, actorEmail: context.session.user.email, targetType: 'project', targetId: project.id, meta: { name: project.name }, // never secrets or whole request bodies ...requestMeta(getRequest().headers), }) ``` Rows are stored in D1 and shown under **Developers → Audit log** (`/admin/audit`), in each user's drawer, and through `/api/v1/admin/audit`. They are pruned after 180 days (`audit.retention_days`, minimum 7). When an account is deleted its rows stay, since that is the point of an audit trail, but the email address in them is cleared. Like `track()`, `audit()` is best-effort and becomes a no-op if you delete the feature. ## Feedback **Help & feedback** in the user menu at the bottom of the sidebar opens a short form: a kind (bug, idea, question, other), a message, and up to three screenshots of up to 5 MB each, picked, pasted or dropped. Screenshots are stored in R2 and served only to their owner and to admins. Both limits are settings (`feedback.image_limit`, `feedback.image_max_mb`). Admins triage under **People → Feedback** (`/admin/feedback`): statuses are `new`, `seen` and `closed`. A reply marks a new thread as seen, notifies the user in the app and emails them. Users read their threads and replies under **My feedback** in the account dialog. ## Waitlist For a closed launch, set `waitlist: true` in `src/config/app-config.ts` and deploy. It is a build-time switch because the prerendered marketing pages need the matching call to action: while it is on, the landing page buttons point to `/waitlist` instead of `/dashboard`. With the gate on, a user who signs in is placed in the queue and sent to `/waitlist`, which shows their position and lets them leave an optional note about what they want to make. Anyone who can open the admin console skips the queue. Admins approve people under **People → Waitlist** (`/admin/waitlist`); approval sends an email and an in-app notification, and the user gets in on their next visit. Approval can be revoked. Set the flag back to `false` and deploy to open the doors. The queue and its history stay in the table. ## Public demo mode A second deployment of the same code can serve as a live demo. Set `DEMO_MODE=true` on that deployment and nowhere else. - The login page becomes a single button. Each click creates a throwaway account (`demo-…@demo.invalid`) with the `demo` role: the admin console plus every `*.read` permission, derived from the registry, so a new feature's read permission shows up automatically. Every write is refused by the same `assertPermission` check a real role hits. - Every other sign-in method is refused, so no real identities collect there. The button is rate limited. - A banner across the app explains the read-only console and gives the Stripe test card. - The nightly `demo.purge-visitors` job erases demo accounts older than a day, uploads included. To set one up, copy `wrangler.jsonc` with its own Worker name, D1 database and R2 bucket, use test-mode payment keys, and fill it with data using `bun run db:seed --remote`. Seed dates are relative to the day the script ran, so re-run it now and then. See [Deploy](/docs/deploy). Demo mode exists to show your product to prospects. Never set `DEMO_MODE` on a real deployment: it hands console access to anyone who clicks. --- # Adding features Source: https://shipkit.sh/docs/adding-features > Build a new module as a vertical slice — schema, server functions, route, registries — and keep it deletable with a verified recipe. Everything optional in ShipKit — credits, files, feedback, the waitlist — is a vertical slice: one folder under `src/features//` that owns its tables, server code, queries and components, plus a handful of one-line registrations in shared files. Your own modules should look the same. It keeps each one easy to find, and it means you can take one out again with the same confidence as the bundled ones (see [Deleting features](/docs/deleting-features)). ## Start with `/add-feature` If you work with Claude Code or another agent, run the `/add-feature` skill (`.claude/skills/add-feature/SKILL.md`). It walks the full wiring checklist below in order and finishes with the same checks this page ends on. See [AI agents](/docs/ai-agents) for how the skills fit together. Whether you use it or not, copy an existing slice rather than starting from a blank folder. `src/features/credits/` is the reference implementation and touches almost every registry; `src/features/feedback/` is smaller and a good model for "a table, a user form and an admin page". ## The shape of a slice ``` src/features// ├── schema.ts # Drizzle tables, if the feature owns data ├── config.ts # client-safe constants (status lists, limits) ├── permissions.ts # admin permissions, if it has console pages ├── settings.ts # operator-editable values, if any ├── queries.ts # queryOptions wrapping the server functions ├── server/ │ ├── fns.ts # server functions (authedFn / adminFn) │ └── ... # events.ts, stats.ts, account.ts, jobs.ts as needed └── components/ # the feature's own components src/routes/_app/.tsx # user page (session-guarded) src/routes/_app/admin/.tsx # console page, if any ``` Only create the files you need. A feature with no tables has no `schema.ts`; one that never appears in the console has no `permissions.ts`. ## Step by step ### 1. Schema Define tables in `schema.ts`: text ids from `crypto.randomUUID()`, timestamps as `integer({ mode: 'timestamp_ms' })`, money in integer cents, and a foreign key to `user` with `onDelete: 'cascade'` so the rows go when the account does. Add one line to the schema barrel, then generate and apply the migration: ```ts // src/core/db/schema.ts export * from '@/features//schema' ``` ```bash bun run db:generate bun run db:migrate:local ``` [Database](/docs/database) covers the D1 specifics, including why there are no interactive transactions. ### 2. Server functions Every read and write goes through a server function built from `authedFn` (signed-in user) or `adminFn` (console) in `src/core/server/fn.ts`. The session arrives in `context`; input is validated with zod: ```ts export const submitThingFn = authedFn .validator(z.object({ title: z.string().trim().min(1).max(200) })) .handler(async ({ data, context }) => { await getDb().insert(thing).values({ userId: context.session.user.id, title: data.title, }) return { success: true } }) ``` An `adminFn` only proves the caller may open the console. Each one must also name its own permission with `await assertPermission(context.session, '.read')`. Server functions return keys and values, never translated text. The details are in [Server functions](/docs/server-functions). ### 3. Queries and the route Wrap each read in `queryOptions` in `queries.ts`. The route's loader calls `context.queryClient.ensureQueryData(...)`, the component reads with `useSuspenseQuery`, and mutations invalidate the query key. A page under `src/routes/_app/` inherits the session guard. After adding a route file: ```bash bun run generate-routes ``` ### 4. Copy Add every string to both `messages/en.json` and `messages/zh.json`, with keys prefixed `_`, and call `m.()` in components. See [Internationalization](/docs/i18n). ### 5. Navigation A user page gets a row in `mainNav` in `src/components/app-sidebar.tsx`. Wrap it in markers (more on those below): ```ts // [feature: ] start { to: '/', label: () => m._title(), icon: SomeIcon }, // [feature: ] end ``` A card on `/dashboard` is optional; if you add one, fence it the same way. ## Plugging into the registries Shared behaviour — payments, the admin overview, account deletion, cron — is wired through registries. Each has a `handlers.ts` in `src/core/` with one import line per feature; your file calls a register function when it loads. Add only the ones your feature needs: | If your feature… | Write | And add a line to | |---|---|---| | owns tables | `schema.ts` | `src/core/db/schema.ts` | | has console pages | `permissions.ts` — `registerPermission` | `src/core/authz/handlers.ts` | | has operator-editable values | `settings.ts` — `registerSetting` | `src/core/settings/handlers.ts` | | reacts to payments | `server/events.ts` — `onPaymentEvent` | `src/core/payment/handlers.ts` | | shows figures on `/admin` | `server/stats.ts` — `registerStats` | `src/core/stats/handlers.ts` | | has per-user data | `components/admin-user-section.tsx` — `registerAdminUserSection` | `src/core/user-detail/handlers.ts` | | stores per-user rows | `server/account.ts` — `onAccountExport`, `onBeforeAccountDelete`, `onAccountDeleted` | `src/core/account/handlers.ts` | | has scheduled work | `server/jobs.ts` — `registerJob` | `src/core/jobs/handlers.ts` | Calling `audit()`, `notify()`, `sendEmailSafely()` or `track()` needs no registration: those dispatchers are in core, and the features that store their output (audit log, notifications, email log, analytics) are the ones with lines in the sink registries. Two rules come with the registries. `permissions.ts` and `settings.ts` are loaded by the browser too, so they must import nothing server-only — put defaults in a client-safe `config.ts`. And a payment handler must be idempotent: store the external order id in a unique column and insert with `onConflictDoNothing`, because webhooks are redelivered. Jobs must tolerate running twice for the same reason. ## Admin pages A console page needs a permission, a gated route, gated server functions and a sidebar row: ```ts // src/features//permissions.ts registerPermission({ key: '.read', group: '', groupLabel: () => m.admin__title(), label: () => m.perm_read(), }) ``` ```tsx // src/routes/_app/admin/.tsx export const Route = createFileRoute('/_app/admin/')({ beforeLoad: async ({ context }) => { await requirePermission(context.queryClient, '.read') }, // loader, component… }) ``` Then add an `adminNav` row in `app-sidebar.tsx` with `to`, `permission`, `label` and a `section` — one of `overview`, `people`, `revenue`, `developers`, `settings` — which makes the page a tab of that section. Tables use `DataTable` from `src/components/data-table.tsx`. The full picture is in [Permissions](/docs/permissions) and [Admin console](/docs/admin-console). You do not have to remember all of this: `src/core/authz/registry.test.ts` fails if an admin route skips its permission check, if an `adminFn` export never calls `assertPermission`, or if a `permissions.ts` is missing from its `handlers.ts`. ## Keeping it deletable Anything your feature adds to a file it does not own — a sidebar row, a dashboard card, an import in the app shell — goes between markers, so a script can cut it out: ```tsx // [feature: ] start … // [feature: ] end {/* [feature: ] start */} … {/* [feature: ] end */} import { Thing } from '@/features//components/thing' // [feature: ] ``` Then add a recipe for it to `scripts/verify-deletion.ts`. A recipe deletes the feature's folder and routes, strips its registry lines with `stripLines(['features/'])` and its marked blocks with `stripFeatureBlocks('')`. The existing recipes are the examples to copy. Run it: ```bash bun run verify:deletion ``` It copies the repo, applies your recipe, and requires build, typecheck and the unit tests to pass. CI builds its matrix from the recipe list, so a new recipe is checked on every pull request without editing the workflow. ## Definition of done ```bash bun run typecheck && bun run check && bun run build && bun run verify:deletion ``` Add an end-to-end case as well — a `e2e/.spec.ts` that opens the page and asserts its heading and one interaction — and run `bun run e2e`. Match the design language in `src/CLAUDE.md`: hairline borders, no shadows or gradients, no colored accent. --- # Deleting features Source: https://shipkit.sh/docs/deleting-features > Remove the bundled modules you don't need — credits, files, blog and docs, waitlist and more — with recipes that are checked by build, typecheck and tests. ShipKit ships with more than most products need. Every optional module is a vertical slice (see [Adding features](/docs/adding-features)), and every one has a deletion recipe that is run and checked, not just written down. Remove what you will not use early: less code to read, fewer tables, fewer console pages. ## How it works A module lives in `src/features//`. Outside that folder it touches the rest of the app in three kinds of places: - **Its own routes** — user pages, console pages, API routes. These are deleted. - **Registry lines** — one import line per registry in `src/core/*/handlers.ts` and the schema barrel `src/core/db/schema.ts`. These are removed. - **Marked blocks** in shared files — a sidebar row, a dashboard card, a menu item — fenced by `// [feature: ] start` / `end` comments (or `{/* … */}` in JSX), or a single line tagged `// [feature: ]`. These are cut out. The core dispatchers stay behind. `audit()`, `notify()`, `track()` and the email event emitter all live in `src/core/`; with no feature registered to receive their events, calling them does nothing. That is why deleting the audit log does not mean editing every server function that records an audit entry. ## The recipes are code `scripts/verify-deletion.ts` holds one recipe per module. Each is a function that deletes files and edits shared ones — the exact steps, not a summary. ```bash bun run verify:deletion credits # one module bun run verify:deletion # every recipe, each on its own copy bun run verify:deletion all # every recipe applied to ONE copy bun scripts/verify-deletion.ts --list # the recipe names ``` For each target the script copies the repo to `/tmp/verify-deletion-`, applies the recipe, regenerates routes, then requires `bun run build`, `bun run typecheck` and `bun run test` to pass. It never touches your working tree. Each run is a full production build, so the full sweep takes a while. CI runs every recipe on pull requests, so a change that breaks one is caught before it merges. `all` is the case for a buyer who wants none of the optional modules: a recipe that patches a file another recipe already deleted only fails there. ## Deleting a module for real The easiest path is the `/delete-feature` skill in Claude Code (see [AI agents](/docs/ai-agents)): it dry-runs the recipe, applies the same steps to your tree and does the cleanup below. By hand: 1. Run `bun run verify:deletion ` first. If it fails, the recipe has drifted from the code — fix the recipe before touching your tree. 2. Read the recipe in `scripts/verify-deletion.ts` and apply the same deletions and edits. 3. Clean up what the recipe leaves alone: - unused keys in `messages/en.json` and `messages/zh.json` (recipes only drop a few prefixes, such as the module's `setting_*` keys) - the module's cases in `e2e/*.spec.ts` - the recipe itself, and this page if you keep your own docs 4. Generate the drop migration and check everything: ```bash bun run db:generate && bun run db:migrate:local bun run generate-routes && bun run typecheck && bun run build && bun run e2e ``` Apply the migration to production with `bun run db:migrate:remote` before you deploy (see [Deploy](/docs/deploy)). The drop migration deletes the module's tables and their data, locally and in production. ## Module by module "Registries" lists the `src/core//handlers.ts` files that lose a line; `schema` is the barrel `src/core/db/schema.ts`. Pages are given by URL. | Module | Deletes | Registries | Also edits | |---|---|---|---| | `credits` | `src/features/credits/`, `/credits`, `/admin/credits`, `/api/v1/admin/credits*`, `/api/v1/me/credits`, `/api/v1/me/run`, `src/core/payment/money-path.test.ts` | schema, payment, account, jobs, stats, user-detail, settings, authz | sidebar rows, balance chip in `_app.tsx`, dashboard card and ledger panel, account-dialog tabs, billing section, credit packs in `src/config/plans.ts` and its test, notification bell, seed | | `files` | `src/features/files/`, `/files`, `/api/files`, `/api/files/$fileId` | schema, account, user-detail, authz | sidebar row, dashboard card and recent-files panel | | `content` (blog and docs) | `src/features/content/`, `content/blog/`, `content/docs/`, `public/covers/`, `/blog`, `/docs` | none | blog/docs links in `_marketing.tsx`, docs links in the landing `hero.tsx` and `cta.tsx` blocks | | `audit` | `src/features/audit/`, `/admin/audit`, `/api/v1/admin/audit` | schema, audit, stats, user-detail, payment, account, jobs, settings, authz | console nav row, seed | | `email-log` | `src/features/email-log/`, `/admin/email-log`, `/api/v1/admin/emails` | schema, email, stats, user-detail, account, jobs, settings | console nav row, seed | | `affiliate` | `src/features/affiliate/`, `/affiliate`, `/admin/affiliate`, the `/r/` referral link | schema, payment, account, jobs, stats, settings, authz | sidebar rows, notification bell, overview labels, deletion-guard message in `account-section.tsx`, console settings page, `src/config/private-paths.ts`, the guard code union in `src/core/account/events.ts` | | `feedback` | `src/features/feedback/`, `/admin/feedback`, `/api/v1/admin/feedback`, `/api/feedback-image*`, `feedback-reply` email template | schema, stats, user-detail, account, settings, authz | Help and feedback menu item and dialog in `_app.tsx`, console nav row, overview labels, notification bell, "My feedback" in the account dialog, email preview samples, seed | | `waitlist` | `src/features/waitlist/`, `/waitlist`, `/admin/waitlist`, `/api/v1/admin/waitlist`, `waitlist-approved` email template | schema, stats, user-detail, account, authz | the gate in `_app.tsx`, the `waitlist` flag in `app-config.ts`, the landing and header calls to action, console nav row, overview labels, notification bell, email preview samples, seed, e2e setup | | `analytics` | `src/features/analytics/`, `/api/ph` (the PostHog proxy) | audit, payment, account, analytics | `` in `__root.tsx`, the `analytics` block in `app-config.ts` | | `demo` | `src/features/demo/` | jobs | marked blocks in `auth.ts`, `env.ts`, `_app.tsx`, `_auth/login.tsx`, `_marketing.tsx`; `demo_*` messages | | `notifications` | `src/features/notifications/` | schema, notify, account | the bell in `_app.tsx` | ## What stays, and why - **Billing without credits.** Subscriptions and one-time orders keep working; billing and credits only share the payment event stream. See [Payments](/docs/payments). - **R2 without files.** The `wrangler.jsonc` bucket binding stays: profile photos are core and use the same bucket. Objects already uploaded by the files or feedback modules are not removed from the bucket. - **Legal pages without content.** The MDX pipeline, `src/core/content/` and `content/legal/` stay, because the terms and privacy pages are core. See [Content](/docs/content). - **Sending without the email log.** `sendEmail` keeps working; deliveries are just not recorded. The email preview page and its `email.read` permission are core. - **No migration for three.** `analytics`, `content` and `demo` own no tables. You can also switch analytics off without deleting it by leaving the PostHog key empty, and demo mode is off unless `DEMO_MODE=true`. See [More modules](/docs/more-modules). ## Modules without a recipe Billing, authentication and the admin console are not optional modules; the rest of the app is built on them, and there is no recipe to remove them. For a module you added yourself, write the recipe when you build it — [Adding features](/docs/adding-features) shows how. --- # Commands Source: https://shipkit.sh/docs/commands > Every package.json script in ShipKit, grouped by what you use it for, plus the handful of wrangler one-liners you will reach for. Every script runs with `bun run `. Arguments after the name are passed through, so `bun run db:seed --reset` and `bun run verify:deletion credits` work as written. ## Develop | Command | What it does | |---|---| | `bun run dev` | Dev server on port 3000, running your code inside workerd with local D1 and R2 bindings | | `bun run preview` | Serve the output of the last `bun run build` locally | | `bun run generate-routes` | Regenerate `src/routeTree.gen.ts` after adding or moving route files. The dev server does this on its own; use it when the dev server is not running | | `bun run cf-typegen` | Regenerate `worker-configuration.d.ts` (the `Env` types) from `wrangler.jsonc`. Run it after a fresh clone and after any change to `wrangler.jsonc` | Env is read at boot. After editing `.dev.vars`, restart `bun run dev`. ## Check and test | Command | What it does | |---|---| | `bun run typecheck` | `tsc --noEmit` over the whole project | | `bun run check` | Biome format and lint check in one pass (what CI runs) | | `bun run lint` | Biome lint only | | `bun run format` | Biome format check only; `bun run format --write` applies it | | `bun run test` | Vitest unit tests, including the guard-rail tests (permissions, settings registry) and the money-path tests against an in-memory D1 | | `bun run e2e` | Playwright end-to-end suite against the real dev server and local D1. Reuses a server already on :3000 or starts one, seeds demo data, forges an admin and a user session from `BETTER_AUTH_SECRET` | | `bun run e2e:ui` | The same suite in Playwright's inspector | | `bun run verify:deletion` | Apply every feature-deletion recipe to a copy of the repo, one at a time, and require build, typecheck and unit tests to pass | `verify:deletion` takes recipe names (`bun run verify:deletion credits files`) or `all` to apply every recipe to one copy. The first `bun run e2e` on a machine needs a browser: `bunx playwright install chromium`. ## Database | Command | What it does | |---|---| | `bun run db:generate` | Write a new SQL migration into `drizzle/` from the schema in `src/core/db/schema.ts` | | `bun run db:migrate:local` | Apply pending migrations to the local D1 | | `bun run db:migrate:remote` | Apply pending migrations to the production D1. Run it by hand, before deploying code that needs them | | `bun run db:seed` | Fill the local D1 with 90 days of deterministic demo data (every id starts with `seed-`). Idempotent | | `bun run db:seed --reset` | Remove the seed rows only | | `bun run db:seed --remote` | Seed a public demo deployment's D1. Never point it at a real production database | | `bun run auth:generate` | Regenerate the better-auth tables in `src/features/auth/schema.ts` from `auth.cli-config.ts`; follow with `bun run db:generate` | See [Database](/docs/database) for the migration workflow. ## Build and deploy | Command | What it does | |---|---| | `bun run build` | Production build, prerender of the marketing pages, then `sitemap.xml` and the `robots.txt` lines from the prerendered page tree | | `bun run deploy` | `bun run build`, then `wrangler deploy` | | `bun run deploy:www` | Deploy the optional `www` to apex redirect Worker in `infra/www-redirect/`. A Cloudflare redirect rule is usually the simpler choice | The build also generates `src/paraglide/`, which typecheck and the unit tests import, so on a fresh checkout run `bun run build` (or start the dev server) before `bun run typecheck`. Deployment is covered in [Deploy](/docs/deploy). ## Payments | Command | What it does | |---|---| | `bun run stripe:listen` | Forward Stripe webhooks to `localhost:3000/api/webhooks/stripe` through the Stripe CLI, using `STRIPE_SECRET_KEY` from `.dev.vars`. It prints the `whsec_…` value that goes in `STRIPE_WEBHOOK_SECRET` | Needed for any local purchase test on Stripe. Creem has no forwarding CLI; see [Payments](/docs/payments). ## Marketing assets | Command | What it does | |---|---| | `bun run capture:marketing` | Re-shoot the landing page's product screenshots into `public/marketing/` from the running app and the seeded local D1. Pass a surface name to redo just one | | `bun run upload:marketing ` | Resize the images in `` to webp and upload them to the R2 bucket under `marketing/`, read through `appConfig.mediaUrl`. `--dry-run` prints the plan first | | `bun run og` | Redraw `public/og.png` (1200×630) from `scripts/og-card.html` | `capture:marketing` needs the dev server on :3000 and the sessions that `bun run e2e` sets up, so run the e2e suite once first. After `bun run og`, bump the `v` on `socialCard` in `src/routes/__root.tsx`, or social crawlers keep serving the cached card. See [Landing page and themes](/docs/landing-themes). ## Useful one-liners These are not scripts, but you will use them. Make yourself an admin after your first sign-in (swap `--local` for `--remote` in production): ```bash bunx wrangler d1 execute DB --local \ --command "UPDATE user SET role='admin' WHERE email='you@example.com'" ``` Query the local database: ```bash bunx wrangler d1 execute DB --local --command "SELECT id, email, role FROM user LIMIT 5" ``` Run the nightly cron jobs on demand while `bun run dev` is up: ```bash curl "localhost:3000/cdn-cgi/handler/scheduled" ``` Add a shadcn/ui primitive (then check `git diff src/components/ui`, since the CLI can overwrite customized files): ```bash bunx shadcn@latest add ``` Push a production secret: ```bash bunx wrangler secret put BETTER_AUTH_SECRET ``` --- # Operations Source: https://shipkit.sh/docs/operations > What runs in production besides requests — the nightly cron jobs, logs and error pages, rate limits, security headers, CI and backups. This page covers what keeps a deployed ShipKit healthy once it is live: scheduled maintenance, where errors show up, what protects the public endpoints, and what CI checks before it ships. ## Cron jobs One cron trigger in `wrangler.jsonc` fires every day at 00:00 UTC (`"crons": ["0 0 * * *"]`). The Worker's `scheduled` handler in `src/server.ts` calls `runJobs()`, which runs every job registered through `registerJob` in `src/core/jobs/events.ts`. Each feature registers its jobs with one import line in `src/core/jobs/handlers.ts`, so deleting a feature removes its jobs with it. Jobs run one after another. A job that throws is logged and the rest still run. Every job must be idempotent, because a cron can fire twice and you can fire it by hand. | Job | What it does | |---|---| | `payment.prune-webhooks` | Delete stored webhook payloads older than the retention setting (90 days by default) | | `auth.purge-expired` | Delete expired sessions, verification rows and expired API keys, which better-auth never removes on its own | | `auth.remind-deleted-accounts` | Email a closed account 3 days before it is erased, once | | `auth.purge-deleted-accounts` | Erase closed accounts whose retention window (30 days by default) has passed, 25 per night | | `billing.expire-stale-subscriptions` | Expire subscriptions whose paid period ended more than the grace period ago (3 days by default) with no webhook — a safety net for missed events | | `affiliate.mature-commissions` | Mark pending commissions as payable once their refund holdback has elapsed | | `credits.monthly-grant` | Grant each user's monthly allowance, once per calendar month | | `audit.prune` | Delete audit log rows older than the retention setting (180 days by default) | | `email_log.prune` | Delete email log rows older than the retention setting (90 days by default) | | `demo.purge-visitors` | Erase day-old public-demo accounts. Matches nothing unless demo mode was used | The retention and grace periods are operator settings, editable at **Admin → Settings** without a deploy (see [Configuration](/docs/configuration)). The time of day matters for one job: the previous month's credit allowance expires at exactly 00:00 UTC, and `credits.monthly-grant` runs at that instant so balances do not read zero for hours. If you move the cron, keep that in mind. To run the jobs locally while `bun run dev` is up: ```bash curl "localhost:3000/cdn-cgi/handler/scheduled" ``` To add a job, see [Adding features](/docs/adding-features): register it in your feature's `server/jobs.ts` and add the import line to `src/core/jobs/handlers.ts`. ## Logs and errors `wrangler.jsonc` enables Workers Logs (`"observability": { "enabled": true }`) at a 100% sample rate. Request logs, exceptions and anything written with `console.*` appear in the Cloudflare dashboard under your Worker, with no code. On very high traffic, lower `head_sampling_rate`. To follow logs live from a terminal: ```bash bunx wrangler tail ``` Useful lines to search for: - `[jobs] : …` — each job's result, such as `pruned=12`, or its failure - `stripe webhook failed` / `creem webhook … failed` — a payment handler threw; the event was released so the provider's retry gets a second attempt - `[email] RESEND_API_KEY not set` — an email was skipped Users never see a stack trace. `src/components/error-pages.tsx` holds the 404 and 500 pages, wired as the router's default not-found and error components; production shows generic copy and the detail goes to the log. The admin console also records what happened at the business level: the audit log, the email log with every send attempt and its status, and stored webhook payloads. See [Admin console](/docs/admin-console). ## Rate limits Two Cloudflare rate-limiting bindings are declared in `wrangler.jsonc`, both per client IP unless a key is given: | Binding | Limit | Used for | |---|---|---| | `PUBLIC_RATE_LIMIT` | 5 per minute | Sign-in code requests (per IP and per recipient address), avatar uploads, file uploads (per user), feedback screenshots, the demo sign-in | | `ANALYTICS_RATE_LIMIT` | 200 per minute | The first-party PostHog proxy at `/api/ph/*`, which carries several calls per page view | Call `isRateLimited(scope, options)` from `src/core/server/rate-limit.ts` and return `tooManyRequests()` (a 429 with `retry-after: 60`) on any new public endpoint that costs real resources. Pass `key` to count against something other than the IP, as the sign-in code limit does for the recipient's address. If the binding is missing the check fails open, so a config gap never takes an endpoint down. Separately, API keys carry better-auth's own limit of 300 requests per minute. ## Security headers Every response carries `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` and, over HTTPS, `Strict-Transport-Security`. They come from two places, and the two lists must stay in sync: - `src/core/server/security-headers.ts` for everything the Worker answers, applied in `src/server.ts` - `public/_headers` for prerendered pages and static assets, which Cloudflare serves without running the Worker `e2e/security-headers.spec.ts` checks pages, redirects and API responses. There is no Content-Security-Policy by default. TanStack Start hydrates through inline scripts, so a useful CSP needs per-request nonces; add one when you have that in place. The opener policy is `same-origin-allow-popups` rather than `same-origin` because Google One Tap needs its popup to keep `window.opener`. Server functions have one more guard: `src/server.ts` refuses a call to `/_serverFn/…` that a browser marks as cross-site, with a 403, before it is decoded. API routes are not covered, because webhooks and API-key clients come from elsewhere by design. ## CI `.github/workflows/ci.yml` runs on every push to `main`, on pull requests, and by hand (`workflow_dispatch`): | Job | When | What | |---|---|---| | Typecheck · lint · test · build | Always | `build`, `typecheck`, `check`, `test`, then `db:generate` must write nothing — a schema change without its migration fails here | | End-to-end | Always | The Playwright suite against a dev server | | Verify deletion | Pull requests | One parallel job per recipe from `scripts/verify-deletion.ts` | | Deploy | Push to `main` or manual run, after the first two pass | `bun run deploy` | CI builds with placeholder secrets written from `.dev.vars.example`; real keys never go into CI. The Worker reads its real secrets at runtime from `wrangler secret put`. The deploy job needs `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in the repository's `production` environment. Without them it skips and says so. It never applies migrations. It checks that the production D1 has no pending migrations and refuses to deploy if it has, so the order is always: run `bun run db:migrate:remote` yourself, then push (or re-run the workflow). See [Deploy](/docs/deploy). ## Backups ShipKit ships no backup job. Your data lives in two Cloudflare services: - **D1** keeps a point-in-time history of the database (Time Travel), managed with `wrangler d1 time-travel`; how far back it reaches depends on your Cloudflare plan. For a copy you hold yourself, `wrangler d1 export DB --remote --output backup.sql` writes the database as SQL. - **R2** holds uploaded files and profile photos. There is no automatic copy; if you need one, set it up on the Cloudflare side. Before a risky migration, take an export first. Migrations are applied by hand for exactly this reason: a schema change should be a step you watch. --- # Troubleshooting Source: https://shipkit.sh/docs/troubleshooting > The failures you are most likely to hit with ShipKit — env validation, migrations, stale generated files, OAuth, webhooks — and how to fix each one. Each entry names the symptom you see, why it happens, and the fix. Most of them come down to three facts: env is read once at boot, migrations are applied by hand, and the code runs in workerd, not Node. ## Setup and local dev ### "Missing or invalid environment secrets" at startup `src/core/env.ts` validates env with zod the first time anything reads it and throws with a list of the offending keys. Required: `APP_URL` (a URL), `BETTER_AUTH_SECRET` (at least 16 characters), `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Everything else is optional. Fix: `cp .dev.vars.example .dev.vars` if you have not, fill in those four (`openssl rand -base64 32` for the secret), and restart `bun run dev`. If you do not have Google credentials yet, any non-empty placeholder passes validation; the Google button will not work, but the emailed sign-in code will. See [Getting started](/docs/getting-started). ### I changed `.dev.vars` and nothing happened Env is read at boot and cached for the life of the process. Restart the dev server after every edit. Production does not read `.dev.vars` at all: its values are set with `wrangler secret put`. ### "Secret X is not set" An optional module asked for its key at the moment it needed it, through `requireEnv()`. Add the key to `.dev.vars` (local) or run `wrangler secret put X` (production). Optional keys are checked at use rather than at boot so that deleting a feature never breaks startup. ### `bun run typecheck` cannot find `@/paraglide/messages` `src/paraglide/` is generated by the Vite plugin from `messages/*.json` and is not committed. Run `bun run build` or start `bun run dev` once, then run typecheck again. CI builds before it typechecks for this reason. ### Locale routing breaks after compiling messages by hand Never run `paraglide-js compile` yourself. It ignores the options in `vite.config.ts` (the `APP_LOCALE` cookie name and the URL strategy), and the output it writes breaks `/zh` routing. Restart the dev server; the Vite plugin regenerates `src/paraglide/` with the right options. See [Internationalization](/docs/i18n). ### Port 3000 is already in use, or the first dev run fails on a stale cache A previous dev server is still running: `pkill -f "vite dev"`. If the very first run fails on a Vite cache left by another package manager, delete it with `rm -rf node_modules/.vite` and start again. ### Wrangler warns about the D1 `database_id` `wrangler.jsonc` ships with `"database_id": "REPLACE_ME"`. Local dev ignores it, so the warning is harmless until you deploy; `wrangler d1 create` gives you the real id. See [Deploy](/docs/deploy). ## Database ### "no such table" or "no such column" The code expects a migration that has not been applied to this database. Locally: `bun run db:migrate:local`. In production: `bun run db:migrate:remote`. After pulling a new ShipKit release, check for new files in `drizzle/` (see [Upgrading](/docs/upgrading)). ### CI fails with "Schema changed without a migration" You edited a `schema.ts` and did not generate its migration. CI runs `bun run db:generate` and fails if it writes anything. Run it yourself, commit `drizzle/` (including `drizzle/meta`), and push again. ### CI refuses to deploy over "Unapplied D1 migrations" Correct behaviour: the deploy job never migrates, and it will not ship code whose migrations the production D1 has not seen. Run `bun run db:migrate:remote` from your machine, then re-run the workflow (`gh workflow run CI`, or **Re-run** in the Actions tab). Always migrate before deploying code that needs the new schema. ### `db.transaction()` throws D1 has no interactive transactions. Use `db.batch([...])` for several statements that must go together, or a single conditional statement when a check and a write must be atomic. See [Database](/docs/database). ## Build and runtime ### "Cannot read properties of null (reading 'useEffect')" Two copies of React ended up in the dev server's bundle, usually because `src/paraglide/` did not exist when Vite scanned dependencies. Run `bun run build` once, then restart the dev server. ### The dev server breaks after importing `cloudflare:workers` `cloudflare:workers` may be imported only in `src/core/server/cf.ts`. Anywhere else it leaks into the client bundle and crashes Vite in dev. Read bindings through `getBindings()` from that file, and env through `getEnv()`. ### A library that works in Node fails in production The Worker runs in workerd with `nodejs_compat`, not in Node. Anything that evaluates code at runtime (`eval`, `new Function`) is forbidden — that is why MDX is compiled at build time instead of by a runtime content layer. Prefer libraries that support Workers, and test with `bun run dev`, which runs the same runtime. ### A server function answers 403 "Cross-site request refused" Server functions only accept calls from pages this origin served. A browser request from another origin or a sibling subdomain is refused on purpose. Call it from your own pages, or expose an API route if an outside client needs the data (see [API keys](/docs/api-keys)). ## Sign-in ### Google shows `redirect_uri_mismatch` The redirect URI in Google Cloud Console must match `/api/auth/callback/google` exactly: scheme, port, no trailing slash. For production, add the production origin and redirect URI to the same client, and check that the `APP_URL` secret is the production URL, not localhost. `invalid_client` means a wrong or swapped secret. The `/google-oauth` skill walks through it. ### Google One Tap does not appear One Tap checks **Authorized JavaScript origins**, not redirect URIs. Add the origin; for local dev add both `http://localhost:3000` and `http://localhost`. Origin changes can take minutes to propagate. Ad blockers can hide the prompt, and Google suppresses it for a while after a user dismisses it. ### No GitHub button It appears only when both `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are set. Set both, restart, and register `/api/auth/callback/github` as the callback URL of the GitHub OAuth app. ### The sign-in code email never arrives Without `RESEND_API_KEY`, no email is sent: the code is printed to the server log as `[auth] no mail provider — code for …`. That is how local sign-in works with no mail provider. In production, set the key and make sure `emailFrom` in `src/config/app-config.ts` is an address on a domain you have verified in Resend (the shipped value is a placeholder). Failed sends appear as `failed` rows in **Admin → Email log**, with the provider's error. A 429 means the address or IP asked for too many codes in a minute. ### The admin area does not appear after promoting myself Set `role='admin'` on your user row (see [Getting started](/docs/getting-started)), then reload the page. Check you ran the command against the right database: `--local` for dev, `--remote` for production. ## Payments ### Purchase buttons say "Coming soon" Nothing can be bought until the active provider has its key. `PAYMENT_PROVIDER` picks the provider for new checkouts (unset, it prefers Stripe when `STRIPE_SECRET_KEY` exists); that provider's secret key must be set. Restart after editing. See [Payments](/docs/payments). ### The webhook answers 401 "Invalid signature" The signing secret does not match the sender. - Stripe locally: `STRIPE_WEBHOOK_SECRET` must be the `whsec_…` that `bun run stripe:listen` prints. In production it must be the secret of the dashboard endpoint, not the CLI's. - Creem: the secret must come from the webhook you created in the Creem dashboard. Anything in front of the dev server (such as an ngrok tunnel) must pass the request body through untouched, because Creem signs the exact bytes. Restart after changing either secret. ### The webhook answers 500 "Handler failed" A feature's handler threw. The event is released rather than marked as seen, so the provider's retry gets a full second attempt. The cause is in the log under `stripe webhook failed` (or `creem webhook …`). ### Checkout fails as soon as I click buy `src/config/plans.ts` ships with `REPLACE_ME` placeholders for every `stripePriceId` and `creemProductId`, and the provider rejects an id it does not know. Create the catalog (the `/stripe` or `/creem` skill does it) and put the real ids in each slot. Test mode and live mode are separate catalogs, so going live means replacing every id. ### A `stripe trigger` event records an order with no user Expected. The CLI's fixture carries no `metadata.userId`, so it proves the signature, dedupe and translation steps but grants nothing to anyone. To test a real grant, buy through the UI with card `4242 4242 4242 4242`. ## Operations ### An admin setting change does not take effect Settings are cached per Worker isolate for 60 seconds. Wait a minute. --- # Stripe vs Creem vs Paddle vs Lemon Squeezy: getting paid when Stripe doesn't support your country Source: https://shipkit.sh/blog/stripe-vs-creem-vs-paddle-vs-lemon-squeezy > Selling a SaaS to customers abroad without a company in a Stripe country? Four payment platforms compared: who is the merchant of record, what each charges, who can sign up, and how the money gets home. Worked through for mainland China, as of September 2026. **The short answer:** if you live somewhere Stripe does not onboard, a merchant of record is usually the way in, and the question becomes which one can pay you out where you live. Take mainland China, where Stripe does not open accounts: of these four, **Creem is the only one whose documentation says it pays out there** (to Alipay for individuals, with limits). **Paddle** accepts individual sellers and does not exclude China, but does not say whether its wires reach mainland banks. **Stripe** needs a company in a supported country first, such as Hong Kong or the US. **Lemon Squeezy** does not list mainland China for payouts, and it is moving its users to a Stripe product that does not accept China-based sellers either. **Where we stand:** we make [ShipKit](/), which ships with both Stripe and Creem wired in, and shipkit.sh itself sells through Creem. Every fact about the platforms below comes from their own pricing pages, docs or help centres, checked on **25 September 2026**. Where a platform does not say something, we mark it as not stated rather than guess. Fees and policies change often, so check the platform before you sign up. None of this is legal or tax advice. ## The short version | | [Stripe](https://stripe.com/pricing) | [Creem](https://www.creem.io/pricing) | [Paddle](https://www.paddle.com/pricing) | [Lemon Squeezy](https://www.lemonsqueezy.com/pricing) | | --- | --- | --- | --- | --- | | Role | Processor; you are the merchant of record | Merchant of record | Merchant of record | Merchant of record (owned by Stripe) | | Standard fee | US: 2.9% + 30¢, +1.5% international cards, +1% conversion | 3.9% + $0.40, no international card fee | 5% + 50¢ | 5% + 50¢, plus surcharges (below) | | Individual in mainland China | Not supported | Yes, with payout limits | Individuals can sign up; China is not excluded | Mainland China not in payout countries | | Company abroad needed | Yes (e.g. Hong Kong company, US LLC) | No | No | In practice, unusable from the mainland | | Payout to mainland China | Depends on your company and bank abroad | Individuals: Alipay; companies: local bank account | Wire or Payoneer; mainland banks not stated | Mainland not in bank payout list | | Review | Account review | Store review, usually 24–48 hours, **no appeal after a rejection** | Domain review, mostly automatic, manual about 5–7 business days | Identity and store review, about 1–3 business days | ## First, the one idea that matters: merchant of record The row that decides most of this table is the first one. A **merchant of record** (MoR) is the legal seller. When a customer pays through Creem, Paddle or Lemon Squeezy, they are legally buying **from that platform**, which then pays you what is left after its fee. That is why the platform collects and remits sales tax and VAT: EU VAT, UK VAT, US state sales tax and the rest. **Stripe** (the standard product) is a payment processor, and you are the merchant of record. Tax is yours: where to register and how to file. Stripe Tax can calculate it (0.5% per transaction in places where you are registered), but registering and filing are still your job. For a solo developer that difference is large. Selling a $10 subscription to customers in thirty countries and handling each country's tax yourself is not realistic, and that is the problem a merchant of record solves. The price is a higher fee: around 4–5% or more, against Stripe's 2.9% base rate. **One thing a merchant of record does not do:** it handles the tax on the **buyer's** side. The income tax you owe at home on what it pays you is still yours. ## What you keep from a $99 sale A one-time $99 purchase, paid with an international card, counting only the platform's transaction fee (no payout, conversion or tax): | Platform | Fee | You keep | | --- | --- | --- | | Creem | 3.9% + $0.40 = $4.26 | **$94.74** | | Stripe (US account, you handle tax) | 2.9% + 1.5% + 30¢ = $4.66 | $94.34 | | Paddle | 5% + 50¢ = $5.45 | $93.55 | | Lemon Squeezy (non-US sale) | 5% + 1.5% + 50¢ = $6.94 | $92.06 | | Stripe Managed Payments (US account) | 2.9% + 1.5% + 3.5% + 30¢ = $8.12 | $90.88 | **The lower the price, the more the fixed fee hurts.** The same sums on a $10 monthly subscription: Creem takes $0.79 (7.9%), Paddle $1.00 (10%), and a non-US Lemon Squeezy subscription $1.20 (12%). Paddle prices products under $10 separately. To run your own prices, use our [Stripe fee calculator](/tools/stripe-fee-calculator), which puts all of these side by side. ## The four, one by one ### Stripe **For people who already have a company in a supported country.** Stripe's self-serve signup covers 44 countries and regions. **Hong Kong is one of them; mainland China is not.** Opening an account in another country takes a company registered there, a physical (not virtual) local bank account, a real local address, a tax ID and a website. So the usual routes are a Hong Kong company or a US LLC through Stripe Atlas. ([source](https://stripe.com/global), [source](https://support.stripe.com/questions/requirements-to-open-a-stripe-account-in-another-country)) Fees: a US account pays 2.9% + 30¢ for domestic cards, 1.5% more for international cards and 1% more when currency is converted; Stripe Billing adds 0.7% of billing volume for subscriptions. A Hong Kong account pays 3.4% + HK$2.35, with 0.5% more for international cards and 2% for conversion. ([US](https://stripe.com/pricing), [Hong Kong](https://stripe.com/en-hk/pricing)) **What you get:** the lowest base rate, the most features and the largest ecosystem. **What it costs:** setting up and running a company abroad, and owning your tax. ### Stripe Managed Payments Stripe's own merchant-of-record product, **generally available since 22 April 2026**. It charges **3.5% on top of** the normal Payments fees, sells digital products only, and needs an eligible tax code on every product. ([source](https://docs.stripe.com/payments/managed-payments/changelog), [source](https://stripe.com/pricing)) Two limits matter here. The seller must be based in a supported location: **Hong Kong is on the list, mainland China is not.** And **customers in mainland China cannot buy through it**: China is on its list of restricted customer countries. ([source](https://docs.stripe.com/payments/managed-payments/eligibility)) ### Creem **The friendliest of the four if you are an individual in mainland China.** It is a merchant of record charging 3.9% + $0.40, with no monthly fee and no international card surcharge. ([source](https://www.creem.io/pricing)) China is on its supported-countries list, marked as having transfer limits, and onboarding accepts an individual's name or a business name. ([source](https://docs.creem.io/merchant-of-record/supported-countries), [source](https://docs.creem.io/merchant-of-record/account-reviews/account-reviews)) Payouts ([source](https://docs.creem.io/merchant-of-record/finance/payouts)): - Twice a month, on the 1st and the 15th, once the balance reaches 50 USD or 50 EUR - **Individuals in mainland China:** paid to Alipay, **up to 50,000 CNY per payout and 300,000–600,000 CNY a year** - **Companies in mainland China:** paid to a local bank account, listed as unlimited - Bank transfers cost 7 USD/EUR or 1%, whichever is higher; USDC payouts cost 2%. The fee for Alipay payouts is not stated Store review usually takes 24–48 hours, up to 72 at busy times. It wants a live site, visible pricing, a privacy policy and terms, and a support email that matches the site. **A rejection is final and cannot be reviewed again**, so get the site ready before you submit. Physical goods, deepfake tools, adult content, NFTs and crypto, and marketplaces are not allowed. ([source](https://docs.creem.io/merchant-of-record/account-reviews/account-reviews)) Buyers can pay by card, PayPal, Apple Pay or Google Pay; Alipay and WeChat Pay are listed as coming soon. ([source](https://docs.creem.io/getting-started/introduction)) **Worth knowing:** the Alipay yearly limit is plenty for a product that is just starting, but a growing business can outgrow it, and then a company is the next step. ### Paddle **The long-established merchant of record, open to individuals.** It charges 5% + 50¢; products under $10, or sales that need invoicing, are priced separately. ([source](https://www.paddle.com/pricing)) Paddle supports every country except those on an exclusion list, and neither mainland China nor Hong Kong is on it. Individuals and sole traders **do not need business verification**. ([source](https://www.paddle.com/help/start/intro-to-paddle/which-countries-are-supported-by-paddle), [source](https://www.paddle.com/help/start/account-verification/what-is-business-verification)) It reviews every domain you take payments on: a live HTTPS site with a product description and pricing, terms, a refund policy and a privacy policy reachable from the navigation, and a company or sole-trader name in the terms. Most domains are approved automatically; a manual review takes about 5–7 business days. ([source](https://www.paddle.com/help/start/account-verification/what-is-domain-verification)) Payouts go out by wire or Payoneer, monthly, by the 15th, above a default threshold of $100. There is usually no fee, though a $15 SWIFT fee may apply in some countries. ([source](https://www.paddle.com/help/manage/get-paid/when-and-how-do-i-get-paid)) **Not stated:** Paddle does not say country by country whether its wires reach banks in mainland China, or whether China pays the $15 SWIFT fee. If you plan to use Paddle from there, **ask their support before you sign up**. ### Lemon Squeezy **Not one to pick from mainland China today.** First, the fee. The pricing page says 5% + 50¢, but the fees doc adds 1.5% for non-US sales, 1.5% for PayPal and 0.5% for subscriptions, so an international subscription can reach 8.5% + 50¢. ([source](https://www.lemonsqueezy.com/pricing), [source](https://docs.lemonsqueezy.com/help/getting-started/fees)) Second, payouts. Its bank-payout list **includes Hong Kong and not mainland China**; PayPal payouts cover "200+ countries" without naming China. Its docs are blunt about it: if your country is not listed, you cannot use Lemon Squeezy. ([source](https://docs.lemonsqueezy.com/help/getting-started/supported-countries)) Third, where it is going. Stripe bought Lemon Squeezy in 2024, and on 28 January 2026 its founder wrote that the goal is an easy way for users to move to Stripe Managed Payments. No shutdown date has been announced and signup is still open, but the product it is heading to **does not accept sellers based in mainland China**. ([source](https://www.lemonsqueezy.com/blog/2026-update)) ## What we would do - **No company, and you want to start selling:** **Creem**. It is the only one of the four whose docs say it pays out to the mainland, to Alipay for individuals, and it has the lowest fee of the merchants of record. Getting the first payment through matters more than anything else. - **You want a second option:** try **Paddle**, after asking whether it can pay out to a mainland bank. - **You have a Hong Kong company:** **Stripe** gives you the lowest base rate if you handle tax yourself. If you would rather not, use **Creem**, or look at **Stripe Managed Payments** if your product and customers qualify. - **You have a US LLC:** **Stripe** is the most mature choice. - **Lemon Squeezy:** from mainland China, there is no reason to choose it now. A common path: **start on Creem, and once revenue is steady, set up a company and move to Stripe.** If that is the plan, write the payment code so the provider can be swapped from day one. ## Where ShipKit fits ShipKit has Stripe and Creem both wired in, and one environment variable, `PAYMENT_PROVIDER`, decides which one takes new checkouts. A subscription keeps renewing with the provider it started on, so moving from Creem to Stripe later strands nobody. The differences between the two inside the template (Creem has no in-app plan changes, for one) are in the [payments docs](/docs/payments). How shipkit.sh itself sells through Creem and delivers a private repository is in [another post](/blog/sell-access-to-a-private-github-repo). --- # TanStack Start vs Next.js: why we built a SaaS starter on TanStack Start Source: https://shipkit.sh/blog/tanstack-start-vs-nextjs > Next.js is the default for React SaaS. We chose TanStack Start for ShipKit anyway: native Cloudflare Workers support, type-safe routing and an explicit data flow that an AI agent can follow. Here is the trade, including what we gave up. Next.js is the default answer to "which React framework for a SaaS?", and for good reasons: the biggest ecosystem, the most tutorials, the easiest hiring. When we started [ShipKit](/) we chose TanStack Start anyway. This post explains why, and it is just as specific about what that choice costs. If what you need sits on the other side of the trade, Next.js is the right answer for you, and we would rather you know that before you buy anything. A quick note on status: as of **September 2026**, TanStack Start is a **Release Candidate**. Its docs call it feature-complete, with a stable API. React Server Components are available as an experimental feature. That matters, and we come back to it below. ## 1. It runs on Cloudflare Workers natively ShipKit runs on Cloudflare Workers, with D1 for the database and R2 for files (why is [its own post](/blog/why-cloudflare-workers-not-vercel)). That decision constrained the framework choice more than anything else. TanStack Start builds with Vite, and Cloudflare's own framework guide sets it up with the Cloudflare Vite plugin. In development, `vite dev` runs your server code in `workerd`, the same runtime as production. The D1 and R2 bindings are real there too, backed by local state. The code that works on your laptop is running under the same rules it will run under in production. Next.js reaches Workers through an adapter layer. Cloudflare's docs currently recommend **vinext** as the default way to run Next.js on Workers, with OpenNext as the alternative for existing apps. vinext is marked **beta**, and some features, such as image optimization, are only partially supported. It works, and it keeps getting better. But you are running a framework built with Node and Vercel in mind, translated for a different runtime. When something behaves differently in production than in `next dev`, that translation is one more place to look. ## 2. Type safety all the way through the router In TanStack Start the router knows every route, its path params and its **search params**, and a route can validate its search params with a schema. A `` to a route that doesn't exist, a missing param, or a search param of the wrong type is a **type error**. You don't find it at runtime. That sounds like a nice-to-have until you rename a route in a codebase with forty pages. In ShipKit, `tsc` then lists every link, redirect and loader that needs to change. It matters even more when an AI agent is writing the code. An agent confidently links to a route that was renamed last week. With typed routes that mistake fails `bun run typecheck` before anyone sees it. ## 3. A data flow you can read ShipKit has one rule for data, and TanStack Start makes it easy to follow: - A route **loader** asks TanStack Query for the data (`ensureQueryData`). - The query calls a **server function**, a typed RPC with middleware for the session and permissions. - The server function calls **Drizzle**. Every step is a function call you can follow with "go to definition". Caching lives in one place, TanStack Query, with defaults you set yourself. Next.js's App Router is built around React Server Components. Data fetching happens inside components that render on the server, and there are several layers of caching whose defaults have changed between major versions. It is a powerful model, and many teams like it. For us it meant more implicit behavior. Our test was "can a newcomer, or an agent, tell where this data comes from and when it refreshes, just by reading the code?" The explicit version passed that test more easily. ## 4. Server functions with real middleware A TanStack Start server function is declared with a validator and a handler, and it can be built on top of middleware. ShipKit uses two bases: - `authedFn` checks for a session. - `adminFn` also requires permission to open the admin console. Every admin function then asserts its own specific permission. Because these are ordinary typed functions, a unit test can scan them. ShipKit has one that **fails the test suite, and with it CI, if any admin server function forgets its permission check**. That rule lives in `CLAUDE.md` too, but the test is what keeps it true. ## What we gave up These are real costs. Weigh them. - **Ecosystem and hiring.** Next.js has far more tutorials, examples, Stack Overflow answers and developers who know it. With TanStack Start you'll read source code more often. - **Maturity.** "Release Candidate" is not "1.0". We pin exact versions and upgrade on purpose, and the ShipKit release notes say when an upgrade needs action from you. - **React Server Components.** They are experimental in TanStack Start. If your architecture depends on RSC, Next.js is the mature option today. - **Batteries.** Next.js ships things like `next/image` optimization. On Start you pick your own tools, and on Workers some Node-only packages don't run at all. - **Some sharp edges.** A route file's `component` is split into its own chunk, but the rest of its options (`loader`, `validateSearch`…) ship in the main bundle. Wrapping `createServerFn` in a factory can pull a server module into the browser bundle. We hit these, fixed them, and wrote each one into `CLAUDE.md` so neither you nor your agent has to hit them again. ## When to choose Next.js instead Choose Next.js if: - you're deploying to **Vercel** and want the platform and framework made by the same company; - your team **already knows Next.js**, or you need to hire for it; - you want **React Server Components** in production today; - you depend on libraries or examples that assume Next.js. Choose TanStack Start if: - you want **Cloudflare Workers** without an adapter in between; - you value **types that catch broken links and params**; - you like data flow you can trace; - you'll build with an AI agent that benefits from all of the above. ## Where this leaves ShipKit ShipKit is TanStack Start on Cloudflare Workers, all the way down. You get auth, payments with Stripe and Creem, an admin console, i18n and docs, plus the rules and tests that keep an agent from breaking them. If that's the stack you want, [take a look](/pricing), or open the [live demo](https://demo.shipkit.sh) first. If it isn't, our [comparison of TanStack Start boilerplates](/blog/best-tanstack-start-boilerplates) covers kits built on Postgres and Vercel as well. --- # How to sell access to a private GitHub repo Source: https://shipkit.sh/blog/sell-access-to-a-private-github-repo > Payment in, read-only collaborator out, access gone on a full refund: the whole delivery flow behind shipkit.sh, with the GitHub API calls and the edge cases that bite. If what you sell is source code, a private GitHub repository is the best delivery channel there is. Buyers get `git clone`, every future update is a `git pull`, issues and releases come for free, and you never host a zip file. What is missing is the part in the middle: turning a payment into a repository invitation automatically, and taking it back when the money goes back. This is exactly how shipkit.sh delivers the ShipKit template. Below is the whole flow, the four GitHub API calls it needs, and the edge cases that took longer than the happy path. ## The shape of it 1. The buyer pays. The payment provider (Creem here, Stripe works the same) sends an `order.paid` webhook. 2. The webhook handler writes a **license row**: who paid, for which order, and a status. 3. The server works out which GitHub account to invite and adds it to the repository as a **read-only collaborator**. 4. GitHub emails the buyer an invitation. They accept it and clone. 5. On a **full** refund or a lost dispute, the collaborator is removed. Everything interesting lives in step 3 and in what happens when any step runs twice. ## Before any code: put the repo in an organization This is the one that costs people a day. On a repository owned by a **personal account**, a collaborator is always given write access. There is no read-only option. Anyone you sell to could push to your main branch. Repositories owned by an **organization** let you pick the role per collaborator, and `pull` (read) is one of them. A free organization is enough. Create one, transfer the repository into it, and sell from there. ## The token The server needs a token that can add and remove collaborators on that one repository and nothing else. Create a **fine-grained personal access token** with: - Repository access: only the release repository - Permissions: **Administration: Read and write** That is all four calls below need. Keep it as a server secret (`GITHUB_REPO_TOKEN` in our case). Never ship it to the browser. ## The four API calls **Invite**: `PUT /repos/{owner}/{repo}/collaborators/{username}` with `{"permission": "pull"}`. Read the status code: - `201`: an invitation was created (or refreshed). - `204`: they are already a collaborator. ```ts async function addCollaborator(login: string) { const res = await gh(`/repos/${owner}/${repo}/collaborators/${login}`, { method: 'PUT', body: JSON.stringify({ permission: 'pull' }), }) if (res.status === 201) return 'invited' if (res.status === 204) return 'active' throw new Error(`invite failed: ${res.status}`) } ``` **Check**: `GET /repos/{owner}/{repo}/collaborators/{username}` returns `204` if they are in and `404` if not. GitHub sends no webhook when someone accepts an invitation, so ask when it matters: we check whenever the buyer opens their dashboard and the license still says `invited`. **Cancel a pending invitation**: list `GET /repos/{owner}/{repo}/invitations`, find the invitee's login and `DELETE` that invitation. Without this step, a buyer who was refunded before accepting could still accept afterwards. **Remove**: `DELETE /repos/{owner}/{repo}/collaborators/{username}`. Treat `404` as success, because the goal ("they have no access") is already met. ## Which GitHub account? Never a text box The obvious design is a field that says "enter your GitHub username". Don't build it. A typo invites a stranger. So does someone typing a friend's name to share one purchase. And GitHub usernames can be renamed and then taken by someone else. Instead, the account comes from **GitHub OAuth**: - A buyer who signed in with GitHub is invited straight away. - A buyer who signed in another way (Google, an email code) sees a "Connect GitHub" button on their dashboard. It links a GitHub account to their existing one through OAuth, and the invitation goes out when they come back. Store GitHub's **numeric user id**, not only the login. The id never changes. Look up the current login from the id right before you call the API. ## Make every step safe to run twice Webhooks are delivered at least once. The buyer also comes back from checkout to a page that tries to settle the order on its own, in case the webhook is slow. So the same order *will* be processed more than once, possibly at the same moment. What holds this together: - **A unique key on (provider, order id).** The webhook inserts with "on conflict do nothing", then reads the row back. A redelivery finds the existing row and does no damage. - **A status machine instead of flags.** `needs_github`, `invited`, `active`, `failed`, `revoked`. Each entry point asks "what does this row need?" A row that is already `invited` or `active` is left alone. A row that is `needs_github` or `failed` gets another attempt. - **One module owns every change.** The webhook, the checkout return, the "connect GitHub" callback, the retry button and the refund handler all call the same `grantAccess` / `revokeAccess` functions. Nothing else talks to GitHub. - **Inviting twice is harmless.** The `PUT` is idempotent. It is also how an **expired** invitation is resent. Repository invitations expire after seven days, which is a real case when someone buys on a Friday and opens the email a week later. ## When GitHub says no GitHub can fail: a bad token, rate limits, an outage. The payment has already succeeded, so the grant must **not** throw back into the webhook. Retrying can't fix a revoked token, and the provider would keep redelivering. Instead, the license row becomes `failed` with the error stored server-side. The buyer sees "something went wrong, retry", and the admin console shows the same row with a retry button. The error text stays on the server, because it can contain a raw GitHub response body. Revocation is the other way round: **it does throw**. If removing access fails during a refund, you *want* the provider to redeliver the webhook until it works. ## Refunds: full removes access, partial doesn't Treat a partial refund as goodwill: the buyer keeps the license. A **full** refund, or a chargeback you lose, removes access. The revoke runs in this order: 1. Cancel any pending invitation for that login. 2. Remove the collaborator. 3. Mark the row `revoked` and write an audit entry. Be honest with yourself about what this does. Revoking access stops future updates, but it can't take back a copy that was already cloned. That is true of every way to sell source code. The repository makes it *clean*: the buyer loses the updates, which is most of what they paid for. ## Moving to a different GitHub account Buyers switch accounts: a work account, an organization, a new handle. Support it by letting them connect a different GitHub account. Then **remove the old one first** and invite the new one. If the removal fails, keep the old account and report the error. The failure you want is "still on the old account", not "two seats for one purchase". ## What it adds up to On shipkit.sh this is a few hundred lines: a `licenses` table, a small GitHub client, one access module and two payment event handlers. It is short because the template underneath already turns Stripe and Creem webhooks into provider-neutral events (`order.paid`, `order.refunded`) that are verified, deduplicated and delivered to whichever feature registered for them. The license feature never sees a provider payload. It reacts to "this order was paid" and "this order was refunded in full". If you are building something you sell as code, a template, a UI kit or a course repository, that event layer is where most of the work is. It is also the part [ShipKit](/pricing) gives you on day one. --- # Moving from Cloudflare D1 to Postgres later: what an AI can translate, and what it can't Source: https://shipkit.sh/blog/migrate-d1-to-postgres > Starting on D1 (SQLite) doesn't lock you in forever. Switching a Drizzle app to Postgres is mostly mechanical, and an AI agent handles that part well. The real work is behavior that changes silently and the data you already have. A checklist. The most common worry about starting a SaaS on Cloudflare D1 is: *what if I outgrow SQLite?* It's a fair question. D1 has a 10 GB ceiling per database, no interactive transactions and none of Postgres's extensions. We covered [why ShipKit starts on D1 anyway](/blog/why-cloudflare-workers-not-vercel). This post is about the exit: what it takes to move a Drizzle app from D1 to Postgres if you ever need to. To be clear up front: **we have not shipped a Postgres version of ShipKit.** What follows is the checklist we'd work from, grounded in the template's actual code. The short answer is that the code is the easy half. The hard half is behavior that changes without any error, and data you already have. ## The part an AI agent does well With Drizzle, the schema is TypeScript, so switching databases starts as a translation job. An agent like Claude Code or Codex does this reliably when the codebase is regular. In ShipKit it is: the database client is created in one file, each feature keeps its tables in its own `schema.ts`, and every table follows the same conventions. The mechanical steps: - **Schema.** Change `sqlite-core` to `pg-core` in every schema file: `sqliteTable` becomes `pgTable`, and each column gets its Postgres type. - **Defaults.** SQLite defaults like `cast(unixepoch('subsecond') * 1000 as integer)` become `now()`. - **Date functions.** Any raw SQL using SQLite's `date(x / 1000, 'unixepoch')` becomes `date_trunc('day', x)`. ShipKit's analytics has exactly one such helper. - **Transactions.** D1 has no interactive transactions, so code that needs several writes to be atomic uses `batch()`. On Postgres those become real `db.transaction()` blocks, which is an upgrade. - **Auth.** Point Better Auth's Drizzle adapter at `pg` and regenerate its tables. - **Migrations.** Don't translate the old SQLite migration files. Generate a fresh Postgres baseline from the new schema. - **Connection.** On Workers, connect through Cloudflare **Hyperdrive**, which is included in the Workers plans. Off Workers, use a normal pooled client. All of that is a few hours of agent work, and `tsc` catches most slips along the way. ## The part that compiles and is still wrong These changes produce no type error and often no failing test. They're why "the AI translated it" isn't the same as "it works". **`LIKE` becomes case-sensitive.** SQLite's `LIKE` ignores case for ASCII letters, and Postgres's doesn't. ShipKit's admin search uses `LIKE`, so after a straight translation, searching "john" no longer finds "John". Nothing errors, you just get fewer results. Every `LIKE` needs a decision: `ILIKE`, or `lower()` on both sides. **NULLs sort to the other end.** In ascending order SQLite puts `NULL` first, and Postgres puts it last (the reverse for descending). A list sorted by "last login" or "paid at" quietly changes order. Add explicit `NULLS FIRST`/`LAST` wherever the order matters. **Types get strict.** SQLite has no real boolean or timestamp type. ShipKit stores timestamps as integer milliseconds and booleans as 0/1, and Drizzle's `mode` option hides that from your code. In Postgres you'll want native `timestamptz` and `boolean` columns. Then check every place that compares, buckets or does arithmetic on those values in raw SQL. **SQLite forgives bad data, and Postgres doesn't.** SQLite's type affinity lets a text value sit in an integer column. Postgres rejects it. You find out during the data import, not during the code migration. **Local development changes shape.** On D1, local dev needs no setup at all. On Postgres you need a database running locally (Docker, or a branch on your hosting provider), plus a new way to seed data and reset it for end-to-end tests and CI. ## If you already have users: moving the data For a new project with no data, you're done at this point. For a live product, this is the step that decides whether the switch goes well. 1. **Export.** `wrangler d1 export --remote --output=dump.sql` writes the database as SQL. Cloudflare's docs note two things. A running export blocks other requests to the database. And very large integers can lose precision, because values pass through JavaScript numbers. 2. **Transform.** The dump is SQLite SQL with SQLite values. Integer milliseconds need to become timestamps, and 0/1 need to become booleans. A small script that reads the dump and writes Postgres-typed rows is safer than hoping a generic converter guesses your conventions. 3. **Load and verify.** Import into Postgres, then check row counts per table and **sum the money columns** on both sides. An order total that doesn't match is how you learn a conversion is wrong. 4. **Cut over.** Put the app in read-only or maintenance mode, run a final export and import, switch the connection, and watch the error logs. An agent can write the transform and the verification queries. A person should still read them and check the totals. ## So how long does it take? - **No production data yet:** about a day or two. That's the translation, plus going through the silent-behavior list above and re-running the test suite. - **A live product:** a few days, most of it in data migration and verification, not code. That's the honest cost of the exit. It's real, but it's bounded. It's also why we built ShipKit for one database instead of trying to support both: a single clean codebase is easier to move than two half-maintained ones. And if you already know you need Postgres, start with a Postgres starter. Our [TanStack Start boilerplate comparison](/blog/best-tanstack-start-boilerplates) lists several. If D1 fits your first year, and for most new SaaS products it does, [ShipKit](/pricing) gives you the rest: auth, payments, an admin console, i18n, and the rules and tests that keep an agent from breaking them. --- # The best TanStack Start boilerplates in 2026, compared Source: https://shipkit.sh/blog/best-tanstack-start-boilerplates > Nine TanStack Start SaaS starters side by side: where each one runs, which database, auth and payments it uses, what it costs, and who it is actually for. Written by the makers of one of them. TanStack Start went from "interesting" to "a real option for a SaaS" in about a year, and a small market of starter kits grew around it. If you are picking one now, the kits look alike from the outside: most of them say "Better Auth, Drizzle, Stripe, AI-ready". The differences that matter are underneath. Where does it run? Which database does it assume? What does it charge, and for what? **A disclosure first:** we make [ShipKit](/), one of the kits below. We still want this page to be useful if you end up buying something else. So every competitor fact here comes from that product's own website or repository, checked in **September 2026**. If a page didn't say something, we left it out instead of guessing. Several of these kits use "X spots left" launch pricing, so check the current price before you decide. ## The short version | Kit | Price (Sept 2026) | Runs on | Database | Payments | Best for | | --- | --- | --- | --- | --- | --- | | [ShipKit](/pricing) | $99 one-time (launch; $199 after) | Cloudflare Workers | D1 (SQLite) + Drizzle, R2 | Stripe and Creem | Solo builders on Cloudflare who work with an AI coding agent | | [TanStarter](https://tanstarter.dev) | $139 one-time (list $199) | Cloudflare Workers | D1 + Drizzle, R2, KV | Stripe, Creem, Waffo | Cloudflare plus built-in AI demos and several payment options | | [FlareStarter](https://flarestarter.com) | Free (Apache-2.0); Pro $99 one-time | Cloudflare Workers | D1 + Drizzle | Stripe | A free Cloudflare base, with a paid upgrade for teams and RBAC | | [supastarter](https://supastarter.dev/tanstack-start-saas-boilerplate) | From $299 one-time | Vercel, serverless, Docker | Postgres + Drizzle | Stripe, Lemon Squeezy, Polar, Creem, Dodo | Teams and agencies that need organizations on Postgres | | [MakerKit](https://makerkit.dev/tanstack-start) | $349 / $649 one-time | Any Node host (Nitro) | Postgres (Drizzle, Prisma or Supabase) | Stripe | B2B multi-tenant SaaS with mature docs | | [SaaS.js](https://saas-js.com) | $200 / $400 one-time | Not stated | Postgres + Drizzle | Stripe | Teams that may want both a TanStack and a Next.js version | | [TS-SaaS](https://ts-saas.com) | $75 / $395 one-time | Workers (Hono API), Railway, Fly.io, Docker | Postgres + Drizzle | Dodo Payments | API-heavy products on Dodo | | [Cove Stack](https://github.com/mugnavo/cove) | Free (Unlicense) | Netlify, Vercel, Node | Postgres + Drizzle | None | A clean free base when you'll build billing yourself | | TanStack CLI (`npm create @tanstack/start`) | Free | Anywhere | Your choice | None | The zero-cost baseline | ## How to choose, in four questions **1. Where will it run?** This decides more than anything else, and it splits the list in two. The Cloudflare kits (ShipKit, TanStarter, FlareStarter) run on Workers with D1 and R2 as *bindings*: no connection strings, no connection pool, and a free tier that covers an early product. The Postgres kits (supastarter, MakerKit, SaaS.js, Cove) expect a Node-style host and a hosted database, which is more familiar and has fewer runtime restrictions. Neither is better in general. Pick the platform you want to pay and debug. **2. SQLite or Postgres?** D1 is SQLite. It is fast and nearly free, but it has no interactive transactions, and each database has a size ceiling. If your product is going to have heavy relational reporting, or you already know you need Postgres features, choose a Postgres kit and don't fight it. **3. Do you need organizations?** Teams, members, invitations and per-team billing are a lot of work to add later. supastarter, MakerKit, SaaS.js and FlareStarter Pro list them. ShipKit doesn't have them yet, and TanStarter's page doesn't mention them. A single-user product doesn't need them; a B2B product almost always does. **4. How will you build on it?** If most of your code will be written by Claude Code, Codex or Cursor, look past the "AI-ready" badge that almost every kit now carries. Ask what actually stops an agent from breaking the codebase: written rules, tests that enforce them, repeatable setup steps. ## The kits ### ShipKit What we built: TanStack Start on Cloudflare Workers with D1, R2 and Drizzle. Sign-in is passwordless through Better Auth: Google, GitHub and emailed codes. Stripe and Creem both work, even at the same time, through one provider-neutral payment event layer. The rest: - An admin console with role-based permissions, an audit log and a read-only admin API - API keys for your customers, and credits for metered billing - Affiliate tracking, in-app notifications and an email log - English and Chinese, through Paraglide - An MDX blog and docs **Where it's different:** it is built around the agent working in it. `CLAUDE.md` and `AGENTS.md` state the rules, and tests enforce them: every admin server function has to check a permission, or the test suite fails. Every optional feature is a vertical slice with a deletion recipe, and CI actually runs those recipes and requires the build to pass afterwards. Setup flows such as Stripe, Creem, Google OAuth and deploy are skills the agent runs step by step. **Where it's not the right pick:** - There are no teams or organizations yet. - There are no passwords, 2FA or passkeys. - It is Cloudflare-only. - It is new, so there are no buyer reviews yet. $99 one-time at launch, unlimited projects, and updates through the private repository. There's a [live demo](https://demo.shipkit.sh) with no sign-up. ### TanStarter The closest commercial match on stack: Workers, D1, R2 and KV, with Drizzle and Better Auth (email and password, plus social sign-in). It adds Waffo to Stripe and Creem, and ships AI demo features. The site claims 230+ customers and has a CLI that provisions Cloudflare resources and deploys. The price is $139 one-time, with a stated list price of $199. It covers unlimited projects and lifetime updates. **Choose it if** you want Cloudflare, password sign-in and AI demos out of the box, and a kit that already has a user base. ### FlareStarter An open-source Cloudflare starter under Apache-2.0: Workers, D1, Drizzle, Better Auth (email and password, Google, GitHub), Stripe, English and Chinese, and `AGENTS.md`/`CLAUDE.md` in the repository. The free version includes Better Auth's admin plugin. A Pro edition ($99 one-time, list $249) adds organizations and RBAC. **Choose it if** you want to start free on Cloudflare and read every line before paying anything. It is the most direct free alternative to ShipKit, and we would rather you hear that from us. ### supastarter (TanStack Start edition) A well-established kit that now has a TanStack Start edition: Postgres with Drizzle, and deployment to Vercel, serverless platforms or Docker. Its auth is the most complete in this list: passwords, magic links, 2FA and passkeys. It supports five payment providers and has organizations with member roles. The tiers are $299, $799 and $1,499 by seat count, and the TanStack edition advertises an early-access discount. **Choose it if** you are a team or an agency building B2B on Postgres and want broad options. ### MakerKit (TanStack Start kit) MakerKit's TanStack kits run on any Node host through Nitro. You can use Postgres with Drizzle or Prisma, or a Supabase variant. Features include passkeys and MFA, multi-tenancy, granular RBAC and a super-admin, plus published rules for Claude Code, Cursor and Codex and an MCP server. $349 (Pro) or $649 (Teams) one-time. **Choose it if** you are building a multi-tenant B2B product and value long-maintained documentation. ### SaaS.js Postgres and Drizzle, Better Auth with SSO, passkeys and 2FA, plus RBAC and workspaces. It is sold as TanStack Start or Next.js, with a bundle of both. $200 (Individual) or $400 (Teams) one-time. The Individual license allows unlimited self-hosted projects but at most one client project. **Choose it if** you might want the same kit in Next.js as well. ### TS-SaaS A monorepo with a TanStack Start frontend and a Hono API that can run on Workers, Railway, Fly.io or Docker. It uses Postgres with Drizzle, Better Auth with passkeys and 2FA, and Dodo Payments. There's a $75 Foundation tier (one product) and a $395 Infrastructure tier (unlimited projects). **Choose it if** your product is mostly an API and Dodo is your payment provider. ### Cove Stack The free, open-source starter formerly known as `react-tanstarter`, released under the Unlicense. It is deliberately minimal: TanStack Start, Better Auth, Postgres and Drizzle, with no billing. It is actively maintained. **Choose it if** you want a clean base and plan to write billing and admin yourself. ### Starting from the TanStack CLI `npm create @tanstack/start` scaffolds a minimal app and can add integrations like Better Auth. It's the honest baseline: a paid kit should save you more time than it costs, compared with starting here. ## Our take If you're going to build on **Postgres and Vercel**, or you need **organizations** from day one, look at supastarter or MakerKit first. Their extra cost goes into exactly those features. If you're going to build on **Cloudflare**, you are choosing between FlareStarter (free, with a paid tier), TanStarter and ShipKit. FlareStarter is the one to read if budget comes first. TanStarter has the larger user base and password sign-in. ShipKit is the one to pick if an AI agent will write most of your code: it's built so that the rules the agent follows are checked by tests and CI, not just written down. You can try it before paying: the [live demo](https://demo.shipkit.sh) opens the whole app, admin console included, in read-only mode, and the [docs](/docs) are public. Two related comparisons: [SaaS starter kits for Cloudflare Workers](/blog/best-saas-starter-kits-for-cloudflare-workers), across every framework, and [SaaS boilerplates for AI coding agents](/blog/best-saas-boilerplates-for-ai-coding-agents), by what each kit gives Claude Code, Cursor and Codex. --- # Cloudflare Workers + D1 vs Vercel + Postgres for a SaaS: why ShipKit chose Cloudflare Source: https://shipkit.sh/blog/why-cloudflare-workers-not-vercel > What it costs to run a SaaS on Workers, D1 and R2 versus Vercel and a hosted Postgres, at 1,000 and 10,000 users, with the arithmetic. What Cloudflare gives you, what it takes away, and when to pick the other side. Most SaaS starters assume Vercel and a hosted Postgres. [ShipKit](/) runs on Cloudflare instead: Workers for the app, D1 for the database, R2 for files. It is the decision that shaped everything else in the template, [including the framework](/blog/tanstack-start-vs-nextjs). This post lays out the reasoning: the money, the developer experience, and the parts where Cloudflare is genuinely the worse choice. All prices below come from the vendors' own pricing pages, checked on **September 24, 2026**. Pricing changes; check before you commit. ## The bill Two rough scenarios. The assumptions: about 10 ms of CPU per request, a typical read-heavy app, 10% of requests serving a file. | | ~1,000 users/month | ~10,000 users/month | | --- | --- | --- | | Requests | 300k | 5M | | Database | 1 GB | 5 GB | | Files | 5 GB | 50 GB | | **Cloudflare** (Workers Paid + D1 + R2) | **about $5** | **about $6–7** | | **Vercel Pro + Neon + Vercel Blob** | **about $33–40** | **about $60–80** | How the numbers work out: - **Cloudflare's $5 is the Workers Paid minimum,** and for a product this size it covers nearly everything. It includes 10M requests and 30M CPU-ms a month. D1 on the paid plan includes 25 billion rows read, 50 million rows written and 5 GB of storage. R2 includes 10 GB of storage for free and never charges for egress. At 10,000 users, the only extras are a few cents of CPU time and the storage above R2's free 10 GB. - **Vercel Pro is $20 per deploying seat,** which includes $20 of usage credit. Both scenarios fit inside that credit. At 5M requests, though, you pass the 1M CDN requests Pro includes, and the next tier costs $20 more. - **Neon is the biggest variable.** Storage is cheap. Compute is billed by the hour it runs: an app with steady traffic keeps it awake, and that comes to roughly $13–40 a month in these scenarios, depending on compute size. Supabase Pro ($25/month, with 8 GB of database and 100 GB of file storage) is a reasonable alternative and would replace Blob as well. Before paying anything, the gap is wider still. Workers' **free** plan (100,000 requests a day) will run a pre-launch product at $0. Vercel's Hobby plan is limited to **non-commercial** use by Vercel's own fair-use rules, and taking payments counts as commercial. A SaaS that charges needs Pro from its first sale. None of these sums is large. A founder who gets real value from Vercel should not switch over $50 a month. But the difference is not zero, and it compounds. Every preview project, side project and demo on Cloudflare shares the same $5. ## What you get besides the price **Bindings instead of credentials.** In a Worker, D1 and R2 are *bindings*: objects handed to your code by the runtime. There is no connection string to leak, no connection pool to size, no S3 key pair to rotate, and no "too many connections" error at a traffic spike. ShipKit reads every binding in exactly one file, and nothing else in the codebase knows how the database is reached. **The same runtime locally and in production.** ShipKit's dev server runs your server code in `workerd`, the open-source runtime behind Workers, with local D1 and R2 behind the same bindings. "Works on my machine" means something here. **No egress fees on files.** R2 doesn't charge for downloads. For a product that serves user uploads, avatars or generated files, that line item is usually the one that surprises you on other platforms. **Everything else is in the same account.** Scheduled jobs are a line in `wrangler.jsonc`. Rate limiting is a binding (ShipKit uses it on public endpoints). DNS, TLS and a CDN come with the domain. There's one dashboard and one bill. **Global by default.** A Worker runs close to each visitor. You don't pick a region for the app. ## What it costs you These are real, and each one has cost us time. - **D1 is SQLite.** It is fast and cheap, but it has no interactive transactions. `db.transaction()` throws, and the documented way to make several writes atomic is `batch()`. ShipKit is written around that: a conditional single statement for balances, a batch where several writes must land together. We wrote about it in [D1 has no interactive transactions](/blog/d1-has-no-transactions). - **Size limits.** A D1 database can be at most 10 GB on the paid plan. That is plenty for most SaaS products, and a hard ceiling for some. - **No Postgres ecosystem.** No extensions (pgvector, PostGIS, full-text search as Postgres does it), no row-level security, and the SQL is less capable. If your product needs any of those, D1 is the wrong database. - **`workerd` is not Node.** With `nodejs_compat` most packages work, but not all of them. Runtime `eval` is forbidden, which rules out some MDX and templating approaches. ShipKit already works around the ones it hit. - **CPU limits per request.** Long-running work, such as video processing or big exports, needs a different design (queues, or another service). - **Lock-in.** Bindings are a Cloudflare concept. Moving to another host later means replacing the database layer, file storage, cron and rate limiting. That is days of work, not an afternoon. ## When to choose Vercel and Postgres instead - Your product needs **Postgres features**: extensions, heavy relational reporting, row-level security, or a database that will grow past 10 GB. - You need **long-running server work** that can't be broken into short jobs. - Your team **already runs on Vercel** and is productive there. A few dozen dollars a month buys a lot of familiarity. - You want **Next.js with React Server Components** in production. That pairing is at its best on Vercel. If that's you, pick a Postgres-based starter from the start instead of fighting SQLite. Our [comparison of TanStack Start boilerplates](/blog/best-tanstack-start-boilerplates) lists several. ## Why ShipKit is Cloudflare-only We considered supporting both. But a Drizzle schema is written for one database, so supporting both would mean two schemas, two migration histories and two copies of every feature's tests. Every future feature would have to be built twice, and the checks that keep the template honest would double with it. We would rather do one platform properly. So ShipKit is for people who want what Cloudflare gives: a SaaS that costs about $5 a month until it has real traffic, with no connection strings and no egress bill. Auth, payments, an admin console and i18n are already running on it. [See what's included](/pricing), or open the [live demo](https://demo.shipkit.sh). It runs on the same stack. --- # The best SaaS boilerplates for AI coding agents in 2026 (Claude Code, Cursor, Codex) Source: https://shipkit.sh/blog/best-saas-boilerplates-for-ai-coding-agents > Twelve SaaS starters compared on what they actually give a coding agent: AGENTS.md and CLAUDE.md, skills, MCP servers, llms.txt, and whether anything checks that the agent kept to the rules. Prices and sources as of September 2026. **The short answer:** if an agent will write most of your SaaS, the kits with the deepest agent tooling today are **MakerKit** (paid; skills, MCP server, per-package `AGENTS.md`), **Open SaaS** (free; skills, a Claude Code plugin, `llms-full.txt`) and **supastarter** (paid; `AGENTS.md`, Agent Skills, Markdown docs). **ShipKit**, which we make, is the one built around a different idea: the rules an agent must follow are enforced by tests and CI, so a broken convention fails the build instead of waiting for review. Almost every boilerplate now says "AI-ready". That phrase covers anything from a single `.cursorrules` file to a full MCP server. This page sorts them by what is really in the repository. **A disclosure first:** we make [ShipKit](/), one of the kits below. Every fact about the other kits comes from their own website, docs or repository, checked on **24 September 2026**. Where a page didn't say something, we wrote "not stated" rather than guess. Prices change often, so check before you buy. ## What "agent-ready" should mean Three findings are worth knowing before you compare kits: - **Written context alone doesn't make an agent more correct.** An ETH Zurich study ([Gloaguen et al., 2026](https://arxiv.org/abs/2602.11988)) found that repository context files "do not generally improve task success rates" and raise inference cost by over 20% on average. It also found that the instructions in them "are well followed". - **It does make the agent faster.** Across 10 repositories and 124 pull requests, an `AGENTS.md` cut median runtime by 28.64% and output tokens by 16.58%, with comparable task completion ([Lulla et al., 2026](https://arxiv.org/abs/2601.20404)). - **The vendors say the same.** Anthropic's own guidance calls `CLAUDE.md` "advisory" and says hooks and checks are what "guarantee the action happens". Its first recommendation is to give the agent "a check it can run: tests, a build" ([Claude Code best practices](https://code.claude.com/docs/en/best-practices)). So look for three layers, in order of how much they are worth: 1. **Checks** the agent can run that fail when it breaks the architecture. Typecheck and lint catch syntax and style, not "this admin endpoint has no permission check". 2. **Procedures** for the risky, multi-step jobs (payments, OAuth, deploy), written as skills or commands the agent follows step by step. 3. **Context**: a short `AGENTS.md` / `CLAUDE.md`, and docs the agent can read as Markdown (`llms.txt`, `.md` pages, an MCP server). ## The short version | Kit | Stack | Price (Sept 2026) | Agent files and tools | Anything enforced? | | --- | --- | --- | --- | --- | | [ShipKit](/pricing) | TanStack Start, Cloudflare Workers, D1 | $99 one-time (launch) | `CLAUDE.md`, `AGENTS.md`, 9 setup skills, `llms.txt` + `llms-full.txt` | Yes: architecture tests, deletion recipes run in CI, schema/migration check | | [MakerKit](https://makerkit.dev) | Next.js or TanStack Start; Supabase, Drizzle or Prisma | $349 / $649 one-time | `AGENTS.md` (root and per package), `CLAUDE.md`, 9 skills, MCP server, `llms.txt` | Typecheck, lint and healthcheck commands; no architecture tests stated | | [supastarter](https://supastarter.dev) | Next.js (also Nuxt, TanStack Start), Hono | $299 / $799 / $1,499 one-time | `AGENTS.md`, Agent Skills, `.cursorrules`, docs as `.md` | Lint and Playwright e2e configured | | [Open SaaS](https://opensaas.sh) | Wasp (React, Node, Prisma, Postgres) | Free, MIT | `AGENTS.md`, `CLAUDE.md`, skills, Claude Code plugin, `llms-full.txt` | Playwright e2e, ESLint in CI | | [TurboStarter](https://www.turbostarter.dev) | Turborepo: Next.js, Expo, extension, Hono | $249 / $399 one-time | `AGENTS.md`, skills, a reviewer subagent, docs MCP server | Not stated | | [Achromatic](https://www.achromatic.dev) | Next.js, tRPC, Prisma or Drizzle | $180 one-time | Instructions for Codex, Claude Code and Cursor; local MCP server | Not stated | | [FlareStarter](https://flarestarter.com) | TanStack Start, Cloudflare Workers, D1 | Free (Apache-2.0); Pro $99 | `AGENTS.md`, `CLAUDE.md` | Lint, typecheck and build in CI | | [MkSaaS](https://mksaas.com) | Next.js, Drizzle, Better Auth | $129 one-time (sale) | `agents.md`, `claude.md`, ~17 Cursor rules | Not stated | | [NextDevKit](https://nextdevkit.com) | Next.js; Vercel, Workers or AWS | $169–$219 | Rules generated per IDE, `llms.txt` | Not stated | | [Cove Stack](https://github.com/mugnavo/tanstarter) | TanStack Start, Postgres | Free, Unlicense | `AGENTS.md`, a testing guide, skills | Not stated | | [Next.js SaaS Starter](https://github.com/nextjs/saas-starter) | Next.js, Postgres, Drizzle, Stripe | Free, MIT | None | Not stated | | [ShipFast](https://shipfa.st) | Next.js, MongoDB or Supabase | $199–$299 one-time | None found | Not stated | ## How to choose, in three questions **1. Who reviews the agent's work?** If you read every diff, written context and a good test suite are enough, and any kit in the top half works. If you don't, and most solo builders shipping with an agent don't, you want the repository to reject a broken change on its own. **2. Which jobs will the agent do unsupervised?** Wiring Stripe webhooks, OAuth clients and a production deploy are the steps where a wrong guess costs money or leaks data. Kits that ship these as skills (ShipKit, MakerKit, supastarter, Open SaaS) turn them into a procedure instead of an improvisation. **3. Does the stack suit you apart from the AI features?** Agent tooling is the easiest part of a kit to copy. The database, the host, and whether you need teams and organizations are not. Pick the stack first, then the best agent support within it. ## The kits ### ShipKit TanStack Start on Cloudflare Workers, with D1, R2, Drizzle, passwordless Better Auth, Stripe and Creem, an admin console, credits, API keys and English and Chinese. **What the agent gets:** a `CLAUDE.md` and `AGENTS.md` with the hard rules, and 9 skills for the setup flows: first run, Google OAuth, Stripe, Creem, deploy, adding a feature, deleting one, an SEO audit and the admin data API. The docs are published as [`llms.txt`](/llms.txt) and a full-text [`llms-full.txt`](/llms-full.txt). **What is enforced:** - A unit test reads every admin route and server function and fails if one doesn't check a permission. - Another fails if a module the browser loads imports server-only code. - Every optional feature has a deletion recipe. `bun run verify:deletion` applies it to a copy of the repo and requires the build, typecheck and tests to pass, and CI runs it on every pull request. - CI fails if the database schema changed without a migration. **Where it's weaker:** no MCP server, no teams or organizations yet, Cloudflare only, and no buyer reviews yet because it's new. $99 one-time at launch; there's a [live demo](https://demo.shipkit.sh) with no sign-up. ### MakerKit The most complete agent package among the paid kits. It ships `AGENTS.md` at the root and per package, a `CLAUDE.md`, Gemini instructions, nine skills (`/server-action-builder`, `/rls-review`, `/playwright-e2e`, `/bug-hunt` and others) and an MCP server, launched in September 2025, that exposes its components, scripts and a PRD tracker. Its instructions tell the agent to run typecheck, lint and format. Next.js or TanStack Start, with Supabase or Drizzle/Prisma. $349 (Pro) or $649 (Teams). **Choose it if** you are building multi-tenant B2B and want the broadest agent tooling. ([source](https://makerkit.dev/docs/next-supabase-turbo/installation/ai-agents)) ### supastarter Markets itself as "the SaaS starter kit your coding agent deserves". It ships `AGENTS.md`, Agent Skills for features, auth, payments and tests, and `.cursorrules`, and every docs page is available as Markdown. Lint and Playwright end-to-end tests are configured. Next.js, Nuxt or TanStack Start, with organizations, five payment providers and the widest auth options (passkeys, 2FA). $299, $799 or $1,499 by seats. **Choose it if** you're a team on Postgres and want an established kit with agent support. ([source](https://supastarter.dev/claude-code-boilerplate)) ### Open SaaS The strongest free option. Built on the Wasp framework, with `AGENTS.md`, `CLAUDE.md`, skills for both Claude Code and other agents, `llms.txt` and `llms-full.txt` for the docs, and an official Wasp plugin for Claude Code. Playwright tests and ESLint run in CI. Its own docs call the rules "just a starting point". About 16k GitHub stars, MIT. **Choose it if** you want free and don't mind building on Wasp. ([source](https://docs.opensaas.sh/guides/vibe-coding/)) ### TurboStarter A monorepo that covers web, mobile (Expo) and a browser extension. One `.agents/` folder with skills, a code-reviewer subagent and a `setup-new-feature` command is linked into the Cursor, Claude and GitHub folders, plus a public MCP server for the docs. $249, or $399 with its AI kit. **Choose it if** you need mobile and web from one codebase. ([source](https://www.turbostarter.dev/docs/web/installation/ai-development)) ### Achromatic Next.js with tRPC, shipping repository instructions for Codex, Claude Code and Cursor and a local, read-only MCP server with 19 tools that report the project's structure and validation commands. $180 one-time, unlimited projects. **Choose it if** you want MCP-level context on a Next.js stack at a lower price. ([source](https://www.achromatic.dev/blog/repository-aware-mcp-server-nextjs)) ### FlareStarter The closest to ShipKit on stack: TanStack Start, Workers, D1, Drizzle and Better Auth, open source under Apache-2.0, with `AGENTS.md` imported into `CLAUDE.md`. CI runs lint, typecheck and build. A Pro edition ($99 one-time) adds organizations and RBAC. **Choose it if** you want Cloudflare and want to start free. ([source](https://github.com/FlareStarter/flarestarter)) ### MkSaaS, NextDevKit, Cove Stack - **MkSaaS** ($129 on sale) ships `agents.md`, `claude.md` and about 17 topic rules for Cursor, and recommends the Context7 and Chrome DevTools MCP servers. - **NextDevKit** ($169–$219) keeps its rules in one folder and generates them per IDE. Its docs make a good point: "AI rules are not the more the better". - **Cove Stack** (free) is a minimal TanStack Start base with an `AGENTS.md` and a testing guide for agents. ### Next.js SaaS Starter and ShipFast Two popular kits with no agent files at all. The official Next.js starter is free and minimal. ShipFast is the best-known paid Next.js kit, and it markets compatibility with AI editors rather than shipping instructions for them. Either is fine if you plan to write your own `AGENTS.md`. ## Our take - **Broadest tooling, paid:** MakerKit, then supastarter. - **Free:** Open SaaS, or FlareStarter if you want Cloudflare. - **Mobile plus web:** TurboStarter. - **You won't review every diff:** that's the case ShipKit was built for. Written rules get followed most of the time; the ones that matter here are also tests, so the rest of the time the build fails instead of your production app. Whatever you choose, add the check before you add the context. A fifty-line `AGENTS.md` and a test that fails on a missing permission check do more than a thousand-line rules file. --- # The best SaaS starter kits for Cloudflare Workers in 2026 Source: https://shipkit.sh/blog/best-saas-starter-kits-for-cloudflare-workers > Ten SaaS boilerplates that deploy to Cloudflare Workers, compared: which run natively and which go through an adapter, D1 or Postgres over Hyperdrive, auth, payments and price. With the Workers and D1 limits that decide the choice, as of September 2026. **The short answer:** for a SaaS that lives on Cloudflare Workers with D1 and R2, the kits built for Workers from the start are **ShipKit**, **TanStarter** and **FlareStarter** (TanStack Start), **ShipAny's TanStack edition**, **SuperSaaS** (Nuxt) and the free **cloudflare-workers-nextjs-saas-template**. If you want Next.js with Vercel as the main target and Cloudflare as an option, **MkSaaS** and **NextDevKit** deploy to Workers through the OpenNext adapter. "Deploys to Cloudflare" means very different things from one kit to the next. Some are written for the Workers runtime and use D1 and R2 as bindings. Others are Node apps that an adapter squeezes onto Workers, sometimes from a separate branch, often still talking to Postgres somewhere else. This page sorts them by that difference first. **A disclosure first:** we make [ShipKit](/), one of the kits below. Every fact about the others comes from their own website, docs or repository, checked on **24 September 2026**; where a page didn't say something, we left it out. Several prices are launch or sale prices, so check before you buy. ## The short version | Kit | Framework | On Cloudflare | Database | Auth | Payments | Price (Sept 2026) | | --- | --- | --- | --- | --- | --- | --- | | [ShipKit](/pricing) | TanStack Start | Workers, native | D1, R2 | Better Auth, passwordless | Stripe, Creem | $99 one-time (launch) | | [TanStarter](https://tanstarter.dev) | TanStack Start | Workers, native | D1, R2, KV | Better Auth, password + social | Stripe, Creem, Waffo | $139 one-time | | [FlareStarter](https://flarestarter.com) | TanStack Start | Workers, native | D1, R2, KV | Better Auth, password + social | Stripe | Free (Apache-2.0); Pro $99 | | [ShipAny](https://shipany.ai/blog/shipany-tanstack) (TanStack edition) | TanStack Start | Workers | D1, or Postgres via Hyperdrive | Better Auth | Stripe | $199, or via a $249 membership | | [SuperSaaS](https://supersaas.dev) | Nuxt 4 | Workers / Pages via NuxtHub | D1 (also Postgres, Turso) | Better Auth, incl. passkeys, 2FA | Stripe | $149 one-time | | [cloudflare-workers-nextjs-saas-template](https://github.com/LubomirGeorgiev/cloudflare-workers-nextjs-saas-template) | Next.js on Vinext | Workers, native | D1, R2, KV | Lucia (deprecated), passkeys | Stripe | Free, MIT | | [Svelteflare](https://svelteflare.com) | SvelteKit + Hono | Workers, native | D1 | Better Auth, email code + Google | Stripe | Free, MIT | | [TS-SaaS](https://www.ts-saas.com) | TanStack Start + Hono | API on Workers | Postgres | Better Auth, passkeys, 2FA | Dodo Payments | $75–$395 one-time | | [NextDevKit](https://nextdevkit.com) | Next.js 15 | OpenNext adapter | Postgres, or D1 per guide | Better Auth | Stripe | $169–$219 one-time | | [MkSaaS](https://mksaas.com) | Next.js 16 | OpenNext, from separate branches | Postgres via Hyperdrive, or D1 | Better Auth | Stripe, Creem, Waffo | $129 one-time (sale) | ## Four facts about Cloudflare that decide the choice These are from Cloudflare's own [Workers limits](https://developers.cloudflare.com/workers/platform/limits/) and [D1 limits](https://developers.cloudflare.com/d1/platform/limits/) pages. **1. The free plan is real but tight on CPU.** Workers Free gives 100,000 requests a day and **10 ms of CPU per request**. The paid plan ($5 a month minimum) raises that to 30 seconds by default, up to 5 minutes. Free is fine for building and a quiet launch; plan on the $5 plan once you take payments. **2. The bundle-size worry is out of date.** Cloudflare now allows a 64 MiB Worker on both plans and says "there is no compressed size limit. Only the uncompressed bundle size counts." Several kits' deployment guides still quote the old 1 MB, 3 MB or 10 MB limits as a reason you need the paid plan. That reason no longer holds. The CPU limit above still might. **3. D1 is SQLite with a ceiling.** One D1 database holds up to 500 MB on the free plan and **10 GB on the paid plan**, runs queries one at a time, and has no interactive transactions. That's plenty for most SaaS products, and it's why several kits offer Postgres over Hyperdrive as the alternative. We wrote up [the transaction rule](/blog/d1-has-no-transactions) and [how to move to Postgres](/blog/migrate-d1-to-postgres) if you outgrow it. **4. Native beats adapted.** A kit written for Workers uses D1, R2 and KV as bindings: no connection strings, no pool, and one `wrangler deploy`. A Node app on an adapter runs, but you inherit its workarounds. MakerKit's Cloudflare guide, for example, lists swapping the mailer, dropping the logger and changing Stripe's HTTP client. The cost side, Workers + D1 + R2 against Vercel + Postgres at 1,000 and 10,000 users, is in [a separate post](/blog/why-cloudflare-workers-not-vercel). Short version: about $5 a month versus $33–80. ## The kits ### ShipKit What we make: TanStack Start on Workers with D1, R2 and Drizzle. Sign-in is passwordless (Google, GitHub, emailed codes) through Better Auth. Stripe and Creem both work through one payment event layer. It also has an admin console with role-based permissions and an audit log, credits and API keys for your customers, affiliate tracking, an MDX blog and docs, and English and Chinese. **Where it's different:** it's built for an AI agent to work in. The rules are in `CLAUDE.md` and `AGENTS.md`, and tests and CI enforce the important ones. Every optional feature has a deletion recipe that CI proves still builds. Setup, Stripe, Creem, Google OAuth and deploy are skills the agent runs step by step. **Where it's not the right pick:** no teams or organizations yet, no passwords or 2FA, Cloudflare only, and no reviews yet because it's new. $99 one-time at launch, unlimited projects. [Live demo](https://demo.shipkit.sh), no sign-up. ### TanStarter The closest commercial match on stack: Workers, D1, R2 and KV with Better Auth (password and social sign-in), and Waffo as a third payment provider. It ships AI demo features and a CLI that provisions Cloudflare resources. $139 one-time for unlimited personal and commercial projects. **Choose it if** you want password sign-in and AI demos on Cloudflare. ([source](https://tanstarter.dev/pricing.md)) ### FlareStarter Open source under Apache-2.0: Workers, D1, KV, R2 avatar uploads, Better Auth and Stripe in the free repo, with `AGENTS.md` and `CLAUDE.md`. The Pro edition ($99 one-time) adds teams and RBAC, seat billing, API keys, credits, an audit log, 2FA and referrals. **Choose it if** you want to start free on Cloudflare and need organizations later. It's the most direct free alternative to ShipKit. ([source](https://flarestarter.com/pricing)) ### ShipAny (TanStack edition) ShipAny sells several editions. The TanStack one runs on Workers "in one command" with D1 or Postgres over Hyperdrive, R2 storage and Better Auth, plus agent skills such as `/deploy-cloudflare`. Each template is $199, or $1.99 with a $249 membership. **Choose it if** you want several templates from one vendor. ([source](https://shipany.ai/blog/shipany-tanstack)) ### SuperSaaS The Nuxt option: Nuxt 4 deployed through NuxtHub to Cloudflare, with D1 by default and Postgres or Turso as options. It has the fullest auth here (password, magic link, OTP, passkeys, social, 2FA), teams, and a super-admin with impersonation. $149 one-time. **Choose it if** you write Vue. ([source](https://supersaas.dev)) ### cloudflare-workers-nextjs-saas-template The strongest free Next.js option for Workers, and actively maintained. It moved from OpenNext to Cloudflare's experimental Vinext, and has D1, KV, R2, multi-tenancy, Stripe per-team plans, Playwright tests and agent files. One caution: its auth is built on Lucia, which its author [deprecated in March 2025](https://lucia-auth.com/). **Choose it if** you want Next.js on Workers for free and are comfortable owning the auth code. ([source](https://github.com/LubomirGeorgiev/cloudflare-workers-nextjs-saas-template)) ### Svelteflare A small, current, MIT-licensed SvelteKit + Hono starter where each app is its own Worker, on D1 with Better Auth and Stripe subscriptions. **Choose it if** you write Svelte and want something free and small. ([source](https://github.com/pinebasedev/svelteflare)) ### TS-SaaS A Turborepo with a TanStack Start frontend and a Hono API that runs on Workers (or Railway, Fly.io, Docker), on Postgres with Dodo Payments. From $75 (a limited founding price) to $395. **Choose it if** your product is mostly an API. ([source](https://www.ts-saas.com)) ### NextDevKit and MkSaaS Both are Next.js kits whose main target is Vercel, with Cloudflare as a supported path through OpenNext: - **NextDevKit** ($169–$219; a $199 tier adds the Cloudflare setup) has a guide that moves it to D1 and KV. ([source](https://nextdevkit.com/docs/deployment/cloudflare-worker)) - **MkSaaS** ($129 on sale) keeps Cloudflare on separate branches, one for Postgres over Hyperdrive and one for D1. ([source](https://mksaas.com/docs/deployment/cloudflare)) **Choose one of these if** you want Next.js and may not stay on Cloudflare. ### Not a SaaS kit, but worth knowing - **RedwoodSDK** is a React framework for Cloudflare only, with D1 and Durable Objects, but no billing. Good if you want to build the SaaS parts yourself. - **better-auth-cloudflare** is an auth library and CLI for D1, KV and R2, not a full starter. ## Our take - **Native Workers, TanStack Start:** ShipKit, TanStarter or FlareStarter. FlareStarter if budget comes first, TanStarter for password sign-in and a larger user base, ShipKit if an AI agent will write most of your code. - **Vue:** SuperSaaS. **Svelte:** Svelteflare. - **Next.js, free:** the Lubomir Georgiev template, keeping the Lucia caveat in mind. - **Next.js, and you may leave Cloudflare:** NextDevKit or MkSaaS. For a closer look at the TanStack Start kits, including the Postgres ones, see [the TanStack Start comparison](/blog/best-tanstack-start-boilerplates). --- # Why every feature in this template is deletable Source: https://shipkit.sh/blog/why-every-feature-is-deletable > A starter you delete from is more useful than one you add to — but only if deleting is a recipe rather than an archaeology dig. Most SaaS starters are judged by what they include (our [comparison of TanStack Start boilerplates](/blog/best-tanstack-start-boilerplates) does it too). That is the wrong axis. You will use maybe half of any starter's features, and the half you do not use is not free: it is schema you migrate, code you typecheck, dependencies you patch, and surface area you have to understand before you can safely change anything near it. So the rule here is the opposite one: **every optional feature must be removable in minutes, by a recipe, without leaving a trace.** ## What that forces It turns out you cannot bolt deletability on afterwards. It constrains the architecture from the first commit: - **Features are vertical slices.** `src/features//` owns its schema, its server functions, its queries and its components. Nothing in one feature imports from another — shared ground lives in `src/core/`. - **Registration is one line.** A feature does not get wired in from ten places. It exports a handler and adds a single import line to a registry: the schema barrel, the payment handlers, the stats providers, the admin user-detail sections, the cron jobs. Deleting a feature is deleting those lines. - **Registries are lazy and tolerant of being empty.** `audit()` with no sink registered is a no-op, not a crash. That is what lets you delete the audit feature while a dozen call sites keep calling it. - **Anything that cannot be one line is fenced.** Where a feature genuinely has to touch shared UI — a sidebar row, a dashboard card — the block sits between `[feature: name] start` and `end` comments so a recipe can cut it out exactly. ## The part that keeps it true A documented recipe rots the first time someone renames a file. So the recipes are executable: `bun run verify:deletion` copies the repo, performs each deletion for real, and requires typecheck, build and the unit tests to pass afterwards. It runs on every pull request, one recipe per job, and `bun run verify:deletion all` applies every recipe to a single copy to prove they also work together. Eleven features have a recipe today: affiliate, credits, files, content, audit, email log, feedback, notifications, waitlist, analytics and the public demo. The `/delete-feature` skill runs a recipe for you. That job has caught recipe rot more than once — a moved import, a new registration nobody remembered to document. Which is the point: the claim on the landing page is only worth making because a machine re-checks it every time the code changes. The same idea runs through the rest of the template: a rule in `CLAUDE.md` counts only once a test enforces it, [the permission check on every admin server function](/blog/tanstack-start-vs-nextjs) for one. --- # D1 has no interactive transactions: how to spend a balance safely anyway Source: https://shipkit.sh/blog/d1-has-no-transactions > Spending credits safely on Cloudflare D1, where db.transaction() throws: a conditional single statement, when to reach for batch(), and the append-only ledger ShipKit uses so there is no balance to get wrong. Cloudflare D1 does not support interactive transactions. `db.transaction()` throws at runtime, and no amount of Drizzle configuration changes that: the constraint is in the platform, because a transaction that stays open across round trips is exactly what a distributed SQLite cannot offer. It is one of the trade-offs of building on Cloudflare; we weigh [the others, and what you get for them](/blog/why-cloudflare-workers-not-vercel) in a post of its own. That matters the moment you have a balance to spend. ## The naive version, and why it loses money ```ts const { balance } = await db.select(...) // read if (balance < cost) throw new Error('insufficient') await db.update(...).set({ balance: balance - cost }) // write ``` Two requests arriving together both read the same balance, both pass the check, and both write. The account goes negative, and the ledger no longer explains how. ## The fix: make the check part of the write Do not read, decide, then write. Write conditionally, and let the database decide: ```ts const spent = await db .update(credits) .set({ balance: sql`${credits.balance} - ${cost}` }) .where(and(eq(credits.userId, userId), gte(credits.balance, cost))) .returning({ balance: credits.balance }) if (!spent.length) throw new InsufficientCredits() ``` One statement. The `gte` in the `WHERE` is the balance check, so it is evaluated under the same lock as the update. Zero rows back means it did not apply — the caller learns that from the row count, not from a prior read. ## When one statement is not enough Spending usually also writes a ledger row, and you want both or neither. That is what `db.batch()` is for: several statements, one round trip, applied atomically. It is not an interactive transaction — you cannot branch on the result of the first statement — but for "these writes go together" it is exactly right. ```ts await db.batch([ db.update(credits).set(...).where(...), db.insert(ledger).values(...), ]) ``` ## What ShipKit actually does: the ledger is the balance The examples above keep a balance column next to a ledger. ShipKit goes one step further and drops the balance column: the balance is the sum of an append-only ledger, and spending is one `INSERT … SELECT` that only produces a row if the sum covers the cost. Simplified: ```sql INSERT INTO credit_ledger (id, user_id, delta, reason, ref_id) SELECT :id, :user, -:cost, :reason, :ref WHERE (SELECT coalesce(sum(delta), 0) FROM credit_ledger WHERE user_id = :user) >= :cost ON CONFLICT DO NOTHING ``` Zero rows changed means the balance was short, or that this `ref_id` was already charged. A unique index on it turns a retried request into a no-op, not a second charge. There is no balance to drift out of step with the ledger, because there is only the ledger. The real statement does a little more: it spends the expiring monthly allowance before purchased credits, splitting the debit across two rows. It is still one statement. ## The rule of thumb If a decision depends on current state, push the decision into the `WHERE` clause. If several writes must land together, use `batch()`. If you find yourself wanting to read, branch in JavaScript, and then write — that is the shape D1 cannot give you, and it is worth restructuring before it becomes a bug you only see under load. And if you find you need that shape everywhere, your product may want Postgres. Here is [what moving from D1 to Postgres involves](/blog/migrate-d1-to-postgres). --- # From clone to deployed, start to finish Source: https://shipkit.sh/blog/from-clone-to-deploy > Every step between `bun install` and a live domain — what you configure, in what order, and which steps you genuinely cannot automate. This is the whole path, in the order the dependencies actually run. Nothing here is optional except where it says so. If you work with Claude Code, you can hand steps 1, 2 and 4 to the skills that ship with the repo (`/setup`, `/google-oauth`, `/stripe` or `/creem`, then `/deploy`). They run the same commands and check the result. Here is what they do, so you know what you're approving. ## 1. Secrets, database, run (5 minutes) ```bash bun install cp .dev.vars.example .dev.vars # then set BETTER_AUTH_SECRET: openssl rand -base64 32 bun run db:migrate:local bun run db:seed # optional: 90 days of demo data for the admin pages ``` `.dev.vars` is gitignored; never force-add it. The environment is validated at boot and Google's client id and secret are required, so do step 2 before `bun run dev`. If you want to look around first, placeholder values pass validation and the app starts. Only the Google button won't work. ## 2. Google OAuth (10 minutes, mostly browser) Create an OAuth client in Google Cloud with redirect URI `http://localhost:3000/api/auth/callback/google`. Put the id and secret in `.dev.vars`, and the id **also** in `appConfig.googleClientId`: One Tap runs in the browser and needs it public. Then: ```bash bun run dev ``` Google isn't the only way in. Emailed sign-in codes work locally without any email provider: until `RESEND_API_KEY` is set, the code is printed in the dev server's log. GitHub sign-in is optional; set its client id and secret and the button appears. Sign in, then promote yourself: ```bash bunx wrangler d1 execute DB --local \ --command "UPDATE user SET role='admin' WHERE email='you@example.com';" ``` ## 3. Make it yours (30 minutes, and worth every one) `src/config/app-config.ts` first: name, canonical URL, support address, legal entity. Those values reach the marketing pages, every transactional email, and the Terms and Privacy pages — the legal entity is printed verbatim, so fill it in before you take a payment. Then `src/config/plans.ts`. The comment at the top explains the ladder: packs sell at list price, subscriptions below it, and `plans.test.ts` fails the test suite if a tier ever drops under a margin floor. Set `CREDIT_COST_CENTS` from a measurement, not a guess. ## 4. Payments (20 minutes) Stripe and Creem both work; `PAYMENT_PROVIDER` picks which one takes new checkouts, and both can be configured at once. With Stripe: create the catalog — one product per tier, one recurring price per interval — and paste the ids into `plans.ts`, replacing the `REPLACE_ME` placeholders. Put the secret key and webhook secret in `.dev.vars` and set `PAYMENT_PROVIDER`. Test locally with the Stripe CLI forwarding to your dev server (Creem has its own signed local test in the `/creem` skill): ```bash bun run stripe:listen ``` Buy something with a test card and watch the ledger move. Until a provider key exists the purchase buttons say so instead of starting a checkout, so this step is safe to defer. ## 5. Deploy (15 minutes) ```bash wrangler d1 create shipkit # id → wrangler.jsonc wrangler r2 bucket create shipkit-files bun run db:migrate:remote grep -v '^#' .prod.vars | grep = | wrangler secret bulk bun run deploy ``` Migrations are applied by hand, never from CI — a schema change is a deliberate, watched step, and the deploy job refuses to ship code whose migrations are still pending. For a small product, all of this runs on the $5 Workers Paid plan. We worked out [what it costs next to Vercel and Postgres](/blog/why-cloudflare-workers-not-vercel) at 1,000 and 10,000 users. ## 6. The things only you can do A second Google OAuth client for the production origin. A webhook endpoint in the payment dashboard pointing at `/api/webhooks/stripe`. A verified sending domain in Resend. These are browser steps with no API worth scripting — which is exactly why the repo ships Claude Code skills that walk you through them and verify the result instead of pretending they do not exist.