# AI hooks Source: https://docs.vibestrap.dev/ai/hooks Five client hooks for streaming, polling, credits, prompts, and history. AI client code is full of repeating plumbing — `AbortController` for cancellable generations, decoding streamed responses chunk by chunk, polling long-running tasks, paginating history. Without these abstractions, every demo or feature picks up the same 30 lines of boilerplate. vibestrap ships **five React hooks** under `@/ai/hooks` that wrap all of it. Your components stay focused on UI. Every hook is purely client-side; the server contract is a plain JSON or text endpoint you can swap. ## Prerequisites * Familiarity with the [providers](/ai/providers) doc — these hooks talk to the backend the manager exposes. * API routes mounted at `/api/ai/chat`, `/api/ai/history`, `/api/credits/balance` (the defaults — every hook accepts a custom `endpoint`). * The canonical UI examples live in `src/components/demos/{chat-demo,image-demo,document-demo}.tsx`. ## API reference ### `useGeneration(options?)` Stream text from a server endpoint that emits raw UTF-8 chunks. **Signature** ```ts theme={null} function useGeneration(options?: { endpoint?: string; // defaults to '/api/ai/chat' model?: string; onFinish?: (text: string) => void; }): { text: string; status: 'idle' | 'streaming' | 'done' | 'error' | 'cancelled'; error: string | null; isStreaming: boolean; generate: (input: { messages: ChatMessage[]; model?: string }) => Promise; cancel: () => void; reset: () => void; } ``` **Use it for** chat-like flows where the model emits tokens incrementally. The hook manages an `AbortController` per call and decodes the response body itself. ```tsx theme={null} const { text, isStreaming, generate, cancel } = useGeneration({ onFinish: (final) => setMessages((p) => [...p, { role: 'assistant', content: final }]), }); await generate({ messages: [{ role: 'user', content: 'hi' }] }); ``` Canonical implementation: `src/components/demos/chat-demo.tsx`. ### `useTask(options)` Long-running task: POST to start, then poll until terminal. **Signature** ```ts theme={null} function useTask(options: { startEndpoint: string; pollEndpoint: string; // hook appends `?id=` intervalMs?: number; // default 1500 parse: (json: unknown) => Pick, 'status' | 'result' | 'error' | 'progress'>; }): TaskState & { start: (input: unknown) => Promise; cancel: () => void }; type TaskStatus = 'idle' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; ``` **Use it for** image generation, video, document processing — anything that doesn't fit in one HTTP request. The `parse` callback decouples the hook from your endpoint shape. ```tsx theme={null} const task = useTask<{ images: { url: string }[] }>({ startEndpoint: '/api/ai/image', pollEndpoint: '/api/ai/image/status', parse: (json) => json as ReturnType, }); await task.start({ prompt: 'a cat astronaut' }); ``` Canonical implementation: `src/components/demos/image-demo.tsx`. ### `useCredits(endpoint?)` Read the signed-in user's balance. **Signature** ```ts theme={null} function useCredits(endpoint?: string): { balance: number | null; // null = anonymous, not an error isLoading: boolean; error: string | null; refetch: () => Promise; }; ``` **Use it for** the header credit badge, balance check before showing CTAs, refresh after checkout. The default endpoint is `/api/credits/balance`. ```tsx theme={null} const { balance, refetch } = useCredits(); // after a successful AI call: useEffect(() => { void refetch(); }, [lastCallId]); ``` The `` primitive wraps this hook so most apps never call it directly. ### `useHistory(endpoint?)` Cursor-paginated `ai_call` history for the current user. **Signature** ```ts theme={null} function useHistory(endpoint?: string): { rows: AICallHistoryRow[]; isLoading: boolean; error: string | null; hasMore: boolean; loadMore: () => Promise; refetch: () => Promise; }; ``` `AICallHistoryRow` includes `provider`, `model`, `operation`, `status`, `inputTokens` (nullable — image / video providers don't return tokens), `outputTokens` (same), `totalMs`, `createdAt`. Page size is 20. Cursor is the last row's `createdAt` (ISO). ```tsx theme={null} const { rows, hasMore, loadMore } = useHistory(); return rows.map((r) => ); ``` Canonical implementation: `src/components/demos/document-demo.tsx`. ## Verify they work 1. Boot the dev server, sign in. 2. Open `/playground/chat`. Type. Observe `text` painting tokens — that's `useGeneration` streaming. 3. Open `/playground/image`. Submit a prompt. The status pill goes `queued → running → succeeded` — that's `useTask`. 4. Open `/dashboard/usage`. The list refreshes and `Load more` extends it — that's `useHistory`. ## Common pitfalls * **Streaming endpoint returns JSON, not text.** `useGeneration` reads the body as UTF-8 chunks. If your route does `res.json()` you'll get one big aggregated string instead of streaming. Use `new Response(stream)` or `text/plain` content type. * **Forgetting to `await` `generate()`.** It returns a Promise resolving to the final text — fire-and-forget is fine, but you can't show errors without `try/catch`. * **`useTask.parse` returning the wrong status string.** The hook only stops polling on `'succeeded' | 'failed' | 'cancelled'`. Anything else means "keep polling". * **`useCredits` showing stale balance.** Call `refetch()` after any AI call (the manager consumes credits asynchronously, the badge won't update on its own). ## Official docs * React refs / AbortController: [react.dev](https://react.dev/reference/react) * Streams API: [developer.mozilla.org/en-US/docs/Web/API/Streams\_API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) * Source: `src/ai/hooks/`, `src/components/demos/` # Observability Source: https://docs.vibestrap.dev/ai/observability Every AI call lands in `ai_call` — tokens (when reported), latency, and status. The dashboard rolls it up. You can't fix what you can't see. The manager writes one row to `ai_call` per attempt — success, error, or cancelled — including tokens (if the provider reports them), latency, and any error message. That single table powers the admin dashboard at `/admin/usage` and is all you need to spot a runaway model or a broken provider. ## Prerequisites * Migrated DB so the `ai_call` table exists (`pnpm db:push`). * A few real AI calls behind you — the table is empty until then. * Read [`src/ai/manager.ts`](https://github.com/larryworld/vibestrap/blob/main/src/ai/manager.ts) `recordCall()` — that's where every row is born. ## The columns that matter ```ts theme={null} // src/db/ai.schema.ts (excerpt) ai_call { id text PRIMARY KEY userId text NULL // FK to user provider text NOT NULL // 'openai' | 'mock' | ... model text NOT NULL // 'gpt-4o-mini' operation text NOT NULL // 'chat' | 'chat_stream' | 'image' status text NOT NULL DEFAULT 'pending' // 'success'|'error'|'cancelled'|... streamed boolean NOT NULL DEFAULT false cached boolean NOT NULL DEFAULT false inputTokens integer NULL // present for text APIs only outputTokens integer NULL // present for text APIs only ttftMs integer NULL // streaming only totalMs integer NULL errorMessage text NULL metadata jsonb DEFAULT '{}' createdAt timestamp NOT NULL DEFAULT now() } ``` Indexes on `userId`, `(provider, model)`, `status`, `createdAt` cover the queries the dashboard runs. ## Why no cost column We deliberately don't compute or store dollar cost. Provider pricing is moving target — caching tiers, fine-tune surcharges, agreement discounts — and any local copy drifts within weeks. Vibestrap's job is to record what happened (calls, tokens, latency, errors); your provider's billing dashboard is the source of truth for what it cost. If your product does need a cost view (e.g., you bill end-users by provider call), add a `cost_micro_cents` column back with your own estimation logic. The schema is intentionally trivial to extend. ## Why tokens can be NULL Token-returning APIs (OpenAI / Anthropic / OpenRouter chat) populate `inputTokens` and `outputTokens`. Image / video / audio APIs (Replicate / fal.ai) typically do not — those columns stay NULL for those rows. Don't sum them as "0" silently; treat NULL as "not applicable" in your queries. ## Step-by-step: add a usage report Three useful queries straight against `ai_call`. ### Daily call volume, last 30 days ```sql theme={null} SELECT date_trunc('day', created_at) AS day, COUNT(*) AS calls, COUNT(*) FILTER (WHERE status = 'success') AS successes FROM ai_call WHERE created_at >= now() - interval '30 days' GROUP BY 1 ORDER BY 1 DESC; ``` ### Error rate per model ```sql theme={null} SELECT provider || '/' || model AS model, COUNT(*) AS calls, ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'error') / COUNT(*), 2) AS error_pct FROM ai_call WHERE created_at >= now() - interval '7 days' GROUP BY model HAVING COUNT(*) >= 10 ORDER BY error_pct DESC; ``` ### Latency p95 per model Postgres has `percentile_cont`: ```sql theme={null} SELECT provider || '/' || model AS model, percentile_cont(0.5) WITHIN GROUP (ORDER BY total_ms) AS p50_ms, percentile_cont(0.95) WITHIN GROUP (ORDER BY total_ms) AS p95_ms FROM ai_call WHERE created_at >= now() - interval '7 days' AND total_ms IS NOT NULL GROUP BY model ORDER BY p95_ms DESC; ``` ## Tying calls to credits Every successful chat call passes through the manager's token-based credit reservation. The same `ai_call` row that records `inputTokens` / `outputTokens` is the basis for `tokensToCredits()` (in `src/credits/index.ts`). If you change `siteConfig.credits.perKToken`, historical rows don't get re-priced — only future calls. For images, the manager charges `siteConfig.credits.perImage` per generated image, regardless of provider. Override in your own code if you want compute-time-based pricing. # AI primitives Source: https://docs.vibestrap.dev/ai/primitives 17 purely-presentational components for tokens, chat, images, and forms. AI tool-stations have a UI vocabulary that doesn't fit generic component libraries: token meters that update during streaming, generation cards with regenerate buttons, image galleries with download/copy/share affordances, history rows with model badges. Building each from scratch wastes days of styling and edge-case work. vibestrap ships a **17-component AI primitives library** in `src/components/ai/`, each wrapping shadcn/ui with these patterns built in. Every component is purely presentational: no global state, no hidden side effects, no Suspense boundaries. Drop one in, replace any by copy-paste. ## Prerequisites * shadcn/ui already wired (it is, in the scaffold). * Tailwind classes resolved by your theme (`@/lib/utils` `cn`). * Read [`src/components/ai/index.ts`](https://github.com/larryworld/vibestrap/blob/main/src/components/ai/index.ts) for the canonical export list. ## API reference ### Display (5) **``** — Stacked bar of input vs output tokens. Pass `total` to show context-window utilization. **``** — Pill showing total + TTFT for streaming calls. **``** — Color-coded provider chip; one color per `AIProviderName`. **``** — Header badge that wraps `useCredits()`. Renders nothing for anonymous users. **``** — `whitespace-pre-wrap` with a blinking caret while streaming. ```tsx theme={null} ``` ### Chat (2) **`}>...`** — Single message bubble. `author` is `'system' | 'user' | 'assistant'`; the user variant right-aligns. ```tsx theme={null} }> {msg} ``` **`...`** — Auto-scrolling container. Smart: only sticks to the bottom when the user is already there. Pass anything that changes between scrolls into `trigger`. ### Containers (7) **`...`** — Card chrome for any AI result: title row + content + metadata footer. ```tsx theme={null} } footer={} > ``` **``** — Responsive 1/2/3-column grid with hover download buttons. **``** — Dashed-border placeholder for "no data yet" zones. **``** — Destructive-tinted error card with optional retry button. **``** — Bar + label tied to `useTask` `TaskStatus`. Indeterminate when `progress` is omitted. **``** — Single line in an `ai_call` history list. Takes the `AICallHistoryRow` shape from `useHistory`. Token columns render as `—` for image calls (where the provider returned no token counts). **``** — Amber banner with a "Top up" CTA. Render when `chat()` returns `INSUFFICIENT_CREDITS`. ```tsx theme={null} if (isInsufficientCredits(r)) return ; ``` ### Inputs (3) **``** — Dropdown for selecting a model. `hint` shows below the label (use it for latency / model speed). ```tsx theme={null} ``` **``** — Sit it in a `GenerationCard.actions` slot. Spinner replaces the icon while loading. **``** — Copies to clipboard, flashes a check for 1.5s. Falls back to `document.execCommand('copy')` on old browsers. ## Verify it works 1. `` — bar should show roughly 1/4 vs 3/4 split. 2. Mount `` in your header. Sign in. Make a paid call. The number updates after `refetch()` (manually call it in your post-call effect). 3. Visit any of the demo pages — `chat-demo`, `image-demo`, `document-demo` — to see primitives composed in real flows. ## Common pitfalls * **Server components importing client primitives.** `CreditsBadge`, `CopyButton`, `ModelPicker`, `ChatList`, `RegenerateButton` are `'use client'`. Server components that import them break the build. Wrap or promote the parent to a client component. * **``.** Division-by-zero is guarded (`safeDenom = 1`), but the bar will show 100%+. Either pass a real total or omit the prop. * **`` without a `trigger` prop.** Auto-scroll won't fire on new messages. Pass `trigger={[messages.length, streamingText]}`. * **Replacing one but not its sibling.** These primitives share visual rhythm (same paddings, font sizes). If you fork `` to add an icon, also audit `` so the row still aligns. * **No dollar / cost component.** We deliberately don't ship a `CostBadge` — see [Observability](/ai/observability) for why. If your product genuinely needs to show \$ in the UI, add a column to `ai_call` with your own pricing logic and render it however you like. ## Official docs * shadcn/ui: [ui.shadcn.com](https://ui.shadcn.com/docs) * Tailwind CSS: [tailwindcss.com](https://tailwindcss.com/docs) * Lucide icons: [lucide.dev](https://lucide.dev/icons) * Source: `src/components/ai/`, `src/components/ai/index.ts` # AI providers Source: https://docs.vibestrap.dev/ai/providers Six pluggable providers, picked by env, with fallback and per-model pricing. AI providers move fast — pricing shifts, new models drop, vendors come and go. vibestrap wraps **six** behind a single facade so your code never knows or cares which one is active. Develop offline against the always-on `mock`, swap providers per environment, and never ship a half-configured stack — `AI_PROVIDER` picks the active one and only providers with their API keys configured register at startup. ## Prerequisites * A working install (`pnpm dev` boots). * One real API key — OpenRouter is the easiest if you only want to pick one ([openrouter.ai](https://openrouter.ai/docs)). * Read [`src/ai/index.ts`](https://github.com/larryworld/vibestrap/blob/main/src/ai/index.ts) — it's 80 lines and shows the whole story. ## The six providers | Name | Source file | Operations | Notes | | ------------ | ---------------------------- | ----------------------- | ------------------------------------------- | | `mock` | `providers/mock.ts` | chat, chatStream, image | Default. Returns canned text + picsum URLs. | | `openrouter` | `providers/openai-compat.ts` | chat, chatStream | OpenAI-shape API; routes 100+ models. | | `openai` | `providers/openai-compat.ts` | chat, chatStream | Direct OpenAI endpoint. | | `anthropic` | `providers/anthropic.ts` | chat, chatStream | Distinct Messages API + SSE event types. | | `replicate` | `providers/replicate.ts` | image | Poll-based prediction API. | | `fal` | `providers/fal.ts` | image | Sync queue, fast for FLUX schnell. | Calling an unsupported method (e.g. `image()` on `anthropic`) throws `AIUnsupportedError`. The manager catches it and tries `opts.fallback` if you pass one. ## Step-by-step: switch to a real provider 1. Pick a provider and add its key to `.env.local`: ```bash theme={null} AI_PROVIDER=openrouter OPENROUTER_API_KEY=sk-or-v1-... # OPENROUTER_BASE_URL defaults to https://openrouter.ai/api/v1 ``` 2. Restart the dev server. Conditional registration runs at import time: ```ts theme={null} if (env.OPENROUTER_API_KEY) { PROVIDERS.openrouter = createOpenAICompatProvider({ ... }); } ``` 3. Make a call from a server action or route handler: ```ts theme={null} import { chat } from '@/ai/manager'; const result = await chat( { model: 'openai/gpt-4o-mini', messages: [{ role: 'user', content: 'hi' }] }, { userId: ctx.user.id } ); ``` `result` is `ChatResult | InsufficientCredits`. Always narrow with `isInsufficientCredits(result)` before reading `.text`. ## Adding a model to the price book `src/ai/pricing.ts` is the single source of truth for cost. Keys are `provider:model`. Numbers are per-1k tokens in **micro-cents** (1/100 of a cent — \$0.0001 = 100 micro-cents). Edit, save, done: ```ts theme={null} const TOKEN_PRICES: Record = { 'openai:gpt-4o-mini': { inputPer1kMicroCents: 1500, outputPer1kMicroCents: 6000 }, // add a new line ↓ 'openai:gpt-4o-2026-q1': { inputPer1kMicroCents: 1200, outputPer1kMicroCents: 4800 }, }; ``` If you forget to add a row, `getTokenPrice` falls back to `mock:any` (cheap), so the call still goes through but cost reports will under-count. Search for "forgetting price book" in [Observability](/ai/observability) for the fix. ## Errors you can catch ```ts theme={null} import { AIProviderError, AIUnsupportedError } from '@/ai/types'; import { isInsufficientCredits } from '@/ai/manager'; try { const r = await chat(req, { userId, fallback: 'mock' }); if (isInsufficientCredits(r)) return { error: 'topup', needed: r.needed }; return { text: r.text }; } catch (err) { if (err instanceof AIProviderError) console.error(err.provider, err.status); if (err instanceof AIUnsupportedError) console.error('try a different provider'); throw err; } ``` `opts.fallback` is your retry knob. The manager invokes it once on `AIProviderError` or `AIUnsupportedError` — not on `InsufficientCredits` (refunds already handled). Use `mock` as a fallback in dev so demos never break. ## Verify it works 1. Set `AI_PROVIDER=mock` (default). Hit the chat demo at `/playground/chat` — you should see the "(mock provider …)" canned response. 2. Set a real key and `AI_PROVIDER=`. Restart. Same demo should now stream a real reply. 3. Tail the dev server. The `ai_call` insert log line includes `provider=` — confirms the active selection. 4. `psql` into your dev DB and run `SELECT provider, model, status FROM ai_call ORDER BY created_at DESC LIMIT 5;` ## Common pitfalls * **`AI_PROVIDER` set but no key.** Manager silently falls back to `mock`. Look at `Object.keys(PROVIDERS)` in `getProvider` — your provider is missing. * **OpenRouter model id format.** It's `vendor/model` (e.g. `openai/gpt-4o-mini`), not just `gpt-4o-mini`. Different from the direct OpenAI provider. * **Anthropic system messages.** They live at the top level, not in `messages`. The provider auto-splits, but if you build a request manually, system goes separate. * **Replicate version pinning.** `model` is a version hash, not a friendly name. Use `black-forest-labs/flux-schnell` only after looking up its current version on Replicate. * **`AIUnsupportedError` on image with Anthropic.** Anthropic doesn't do images. Set `fallback: 'replicate'` or `'fal'`, or branch on `operation` upstream. ## Official docs * OpenRouter: [openrouter.ai/docs](https://openrouter.ai/docs) * OpenAI: [platform.openai.com/docs](https://platform.openai.com/docs) * Anthropic: [docs.anthropic.com](https://docs.anthropic.com/) * Replicate: [replicate.com/docs](https://replicate.com/docs) * fal.ai: [fal.ai/docs](https://fal.ai/docs) * Source: `src/ai/index.ts`, `src/ai/manager.ts`, `src/ai/types.ts` # Architecture Source: https://docs.vibestrap.dev/architecture How Vibestrap is wired — stack, layout, and the load-bearing decisions. Vibestrap is a Next.js 15 app with strict TypeScript, Drizzle on Postgres, Better Auth for sessions, next-intl for i18n, and Tailwind v4 for styling. The design target is "ship a paid SaaS in a weekend" — every architectural choice is in service of that, and there's a short list of opinionated decisions that hold the whole thing together. This page is the short list. ## Stack | Layer | Tech | Why | | --------- | -------------------------------------------------------- | ------------------------------------------------ | | Framework | Next.js 15 (App Router, RSC, Turbopack) | Server-first rendering, mature ecosystem. | | Runtime | React 19 | Async server components, `use()` hook. | | Language | TypeScript 5 (strict) | Catches the wrong shape before it ships. | | Styling | Tailwind v4 + shadcn/ui | OKLCH colors, CSS-first config in `globals.css`. | | ORM | Drizzle | Typed schema, no codegen, raw-SQL escape hatch. | | Database | Postgres only | One source of truth, transactions, JSONB. | | Auth | Better Auth + Drizzle adapter | Server-side sessions, OAuth, email verification. | | i18n | next-intl | Bilingual EN/ZH with `as-needed` URL prefix. | | Docs | Fumadocs MDX | Bilingual `*.zh.mdx` with locale-aware lookup. | | Payments | Stripe / Creem / NOWPayments | Behind a single facade. | | AI | Mock / OpenRouter / OpenAI / Anthropic / Replicate / fal | Same facade pattern; mock runs offline. | | Mail | Resend + React Email | Templated, type-safe, dev-rendered. | ## Directory tree ``` vibestrap/ ├── content/ # MDX content (bilingual) │ ├── docs/ # *.mdx + *.zh.mdx parallel pairs │ ├── blog/ │ └── changelog/ ├── messages/ # next-intl message bundles │ ├── en.json │ └── zh.json ├── public/ # static assets, OG image, logo ├── drizzle/ # generated SQL migrations (committed) ├── src/ │ ├── app/ # Next.js App Router │ │ ├── [locale]/ # locale-prefixed routes │ │ │ ├── (marketing)/ # public pages — home, pricing, blog, docs │ │ │ ├── (auth)/ # login / register / forgot-password │ │ │ └── (app)/ # authed — settings, admin, dashboard │ │ ├── api/ # route handlers (auth, webhooks, ping) │ │ ├── layout.tsx # root html shell, fonts, theme │ │ └── globals.css # Tailwind v4 @theme + tokens │ ├── components/ # blocks, layout, ui (shadcn), features │ ├── config/site.ts # central config, < 250 lines │ ├── db/ # split schemas: auth / app / affiliate / ai / license │ ├── payment/ # facade + 4 providers + webhook handlers │ ├── ai/ # facade + 5 providers + cost / pricing │ ├── credits/ # 4-type ledger (server-only) │ ├── mail/ # facade + Resend + React Email templates │ ├── newsletter/ # facade + Resend / Beehiiv │ ├── customer-service/ # widget-loader (Crisp / Tawk / Intercom / Chatwoot) │ ├── affiliate/ # script loader + internal tracking │ ├── analytics/ # GA / PostHog / Plausible / Umami fan-out │ ├── i18n/ # routing.ts + request.ts + navigation helpers │ ├── lib/ # auth, safe-action, server, utils │ ├── env.ts # zod-validated env vars │ └── middleware.ts # i18n routing + auth gating ├── wrangler.toml.example # Cloudflare Workers config template └── package.json ``` `app/` (inside `src/`) is the route surface. `src/` modules outside `app/` are the business logic that pages and route handlers call. The `app/[locale]/` segment is where i18n routing happens; the route groups `(marketing)`, `(auth)`, `(app)` exist for layout grouping and auth gating, not for URL shape. ## Load-bearing decisions ### Provider facade pattern Every module that touches an external service exports a **single object** (the facade) backed by **interchangeable providers**. The facade picks an implementation at boot from `siteConfig..provider`. Consumers see only the facade. ```ts theme={null} // src/payment/index.ts const providers: Record = { stripe: stripeProvider, creem: creemProvider, nowpayments: nowpaymentsProvider, }; export function getPaymentManager(name?: PaymentProviderName): PaymentProvider { const requested = name ?? siteConfig.payment.defaultProvider; if (!siteConfig.payment.enabled.includes(requested)) { throw new Error(`Payment provider "${requested}" is not enabled.`); } return providers[requested]; } ``` Same pattern in `src/ai/`, `src/mail/`, `src/newsletter/`, `src/customer-service/`, `src/affiliate/`. Adding Postmark for mail is one new file under `src/mail/provider/postmark.ts` and one switch arm — no consumer code changes. ### Server-only enforcement Files that touch the DB or read secrets `import 'server-only'` at the top. Next.js fails the build if such a module is imported into a client component. Already protected: `src/db/index.ts`, `src/lib/auth.ts`, `src/lib/server.ts`, `src/lib/safe-action.ts`, every `src/payment/provider/*`, `src/mail/index.ts`, `src/credits/index.ts`, `src/credits/server.ts`. Add the line whenever you create a new server-only module — it's a half-second of typing that prevents accidental key leaks. ### Three-tier server actions All mutating actions go through `next-safe-action` with one of three clients defined in `src/lib/safe-action.ts`: * `actionClient` — public, no auth required. * `userActionClient` — gated; provides `ctx.user` from the session. * `adminActionClient` — gated; requires `user.role === 'admin'`. Each action declares an input schema with Zod. Outputs are typed end-to-end. The gating is centralized — no per-action `if (!user) throw` boilerplate scattered across the codebase. ### Idempotent webhooks Payment webhooks retry on any non-2xx response. If you double-grant credits on a retry, you're paying twice for one payment. The scaffold solves this with two guarantees in `src/payment/handlers/`: 1. `payment.invoiceId` has a unique index — duplicate inserts fail the transaction. 2. Every handler does a `payment.sessionId` lookup before insert and exits early on hit. The credit grant happens in the **same transaction** as the payment row insert. Either both land or neither does. When you add a new event type (subscription renewals, refunds), copy this shape — never insert credits in a separate call. ### Split DB schemas `src/db/` is split by concern: `auth.schema.ts` (Better Auth), `app.schema.ts` (payment + credits + transactions), `affiliate.schema.ts`, `ai.schema.ts`. `schema.ts` re-exports them all for Drizzle Kit and Better Auth's adapter. IDs are `text` (nanoid prefix or snowflake) — never `serial`, because text ids let you migrate between providers without breaking foreign keys and don't leak user-count via enumeration. All foreign keys cascade on user delete (GDPR-friendly out of the box). ### Bilingual MDX Every doc lives in two parallel files: `path/to/doc.mdx` and `path/to/doc.zh.mdx`. Fumadocs is configured to look up the locale-suffixed file first, falling back to the default. The same pattern works for blog posts and changelog entries. Both locales must exist for a doc to be considered shipped. ### Locale routing — `as-needed` prefix `src/i18n/routing.ts` configures next-intl to keep English at the root (`/about`) and prefix Chinese (`/zh/about`). Use `Link` / `useRouter` from `@/i18n/navigation` everywhere — they preserve the locale prefix automatically. Importing from `next/link` or `next/navigation` directly will silently lose the locale on client navigations. ### Cost in micro-cents AI inference and credit math both deal in fractions of a cent. Storing money as floats invites rounding bugs; storing as integer **cents** loses sub-cent precision (a million-token Claude call costs \$0.000003 / token). The scaffold stores everything as integer **micro-cents** (1/1,000,000 of a USD) and converts to display units at the edge. See `src/ai/pricing.ts` for the math. ### Edge runtime — only for OG image `src/app/opengraph-image.tsx` runs on the Edge runtime because it's the only path where cold-start latency directly hurts user-visible perf (social-card scrapers time out fast). Everything else — including all API routes that touch Postgres — uses the **Node** runtime, because the `pg` driver and many auth dependencies need full Node APIs. Don't move routes to Edge unless you know what you're trading away. ## Data flow — checkout A canonical flow that exercises most of the architecture: ``` User clicks "Get Vibestrap $49" → /register?plan=vibestrap-promo → after signup → /settings/billing → createCheckoutAction({plan}) [userActionClient] → paymentManager.createCheckout() [facade → active provider] → returns session.url → window.location = session.url → user pays on provider-hosted checkout → POST /api/webhooks/ [Node runtime] → verifyWebhook() — signature check via node:crypto → handler → idempotency check on payment.sessionId → DB transaction: INSERT payment row addCredits() // grants the right amount in same tx → user redirected to /settings/billing?status=success ``` ## See also * [Configuration](/configuration) — `siteConfig` knobs. * [Customization](/customization) — common recipes. * [Vercel deployment](/deployment/vercel) — ship the architecture. * [Env reference](/env-reference) — every key that selects a provider. # GitHub OAuth Source: https://docs.vibestrap.dev/auth/github-oauth Add "Continue with GitHub" by registering an OAuth App on GitHub and dropping two env vars. For a developer-flavored product, "Continue with GitHub" usually outperforms email sign-up. Vibestrap wires GitHub through Better Auth's social provider — set two env vars and the button shows up on `/login` and `/register`. ## Prerequisites * A GitHub account. * Your production domain decided. * Five minutes — GitHub's OAuth flow is the simplest of the three majors. ## Step-by-step setup 1. **Open Developer settings.** [github.com/settings/developers](https://github.com/settings/developers) → OAuth Apps → **New OAuth App**. (For an org-owned app, use `github.com/organizations//settings/applications` instead.) 2. **Fill in the form:** | Field | Value | | -------------------------- | --------------------------------------------------------- | | Application name | Your product name (users see this on the consent screen). | | Homepage URL | `https://your-domain.com` | | Authorization callback URL | `https://your-domain.com/api/auth/callback/github` | No trailing slash on the callback URL. Add a second OAuth App for local dev pointing at `http://localhost:3000/api/auth/callback/github`, or register both URLs by creating dev + prod apps separately. 3. **Generate a client secret.** Click **Generate a new client secret** on the OAuth App page. Copy it now — GitHub only shows it once. 4. **Drop into `.env.local`:** ```bash theme={null} GITHUB_CLIENT_ID=Iv1.abc123... GITHUB_CLIENT_SECRET=... ``` 5. **Enable the button.** In `src/config/site.ts`: ```ts theme={null} features: { enableGithubLogin: true, /* ... */ } ``` 6. **Restart `pnpm dev`.** Env vars are read at startup. ## Scopes Better Auth requests `read:user` and `user:email` by default — enough to populate `user.name`, `user.email`, and `user.image`. You don't need to ask for `repo` or anything else unless your product actually uses the GitHub API on the user's behalf. Asking for more scopes than necessary tanks signup conversion. If you later need a wider scope (say, to read a user's repos), pass `scopes` in the provider config in `src/lib/auth.ts`: ```ts theme={null} socialProviders.github = { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, scopes: ['read:user', 'user:email', 'repo'], }; ``` Existing users will need to re-authorize for the new scope to take effect. ## Verify it works 1. Visit `/login` — the "Continue with GitHub" button should be visible. 2. Click it. You bounce to GitHub's consent page, hit Authorize, land back on `/dashboard`. 3. Inspect the `account` table — a row with `providerId = 'github'` should be linked to your `user` row. 4. Sign up with email first, then sign in with GitHub using the same email — they merge into a single `user` (account-linking is on for trusted providers). ## Common pitfalls * **Trailing slash on the callback URL.** GitHub matches it character-for-character. `…/callback/github/` (with slash) ≠ `…/callback/github` (without). Use the version without the trailing slash. * **Single OAuth App for dev + prod.** GitHub's free OAuth Apps only allow one callback URL. Make two apps — `MyProduct (dev)` and `MyProduct (prod)` — with separate client IDs. * **Email permission not granted.** Some users have all their GitHub emails set to private. Better Auth still works — it falls back to the `noreply` email. If you rely on hitting their primary inbox, surface a "please complete your profile" flow on first sign-in. * **Org-restricted accounts.** If a user's GitHub org enforces SSO, your OAuth App needs to be approved by an org admin before the user can authorize it. * **Client secret leaked once shown.** GitHub does not show secrets twice. If you lost it, regenerate — invalidates the old one immediately. ## Official docs * Creating an OAuth App — [docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) * Authorizing OAuth Apps — [docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) * Scopes for OAuth Apps — [docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) * Better Auth GitHub provider — [better-auth.com/docs/authentication/github](https://www.better-auth.com/docs/authentication/github) # Google OAuth Source: https://docs.vibestrap.dev/auth/google-oauth Add "Continue with Google" sign-in by creating an OAuth 2.0 Client in Google Cloud and dropping two env vars. The "Continue with Google" button on `/login` and `/register` light up automatically once `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set. There is no provider code to write — Better Auth handles the OAuth dance, and the auto-link logic in `src/lib/auth.ts` merges Google sign-ins with existing email accounts. ## Prerequisites * A Google account. * Your production domain decided (you can develop locally first). * 10 minutes — most of it is filling in the OAuth consent screen. ## Step-by-step setup 1. **Open Google Cloud Console** at [console.cloud.google.com](https://console.cloud.google.com) and create (or pick) a project. Each product gets its own project. 2. **Configure the OAuth consent screen.** Under APIs & Services → OAuth consent screen, pick "External" user type. Fill in app name, support email, and a logo. The scopes you need are the defaults: `userinfo.email`, `userinfo.profile`, `openid`. Add your production domain as an authorized domain. 3. **Create OAuth 2.0 credentials.** APIs & Services → Credentials → Create Credentials → OAuth client ID. Application type: **Web application**. 4. **Set authorized redirect URIs.** Better Auth's callback path is `/api/auth/callback/google`. Add both your local and production URIs: ``` http://localhost:3000/api/auth/callback/google https://your-domain.com/api/auth/callback/google ``` If you preview on Vercel, add the preview URL too. 5. **Copy the client ID + secret into `.env.local`:** ```bash theme={null} GOOGLE_CLIENT_ID=1234567890-abc...apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-... # Mirror the ID for client-side One-Tap (see /docs/auth/one-tap) NEXT_PUBLIC_GOOGLE_CLIENT_ID=1234567890-abc...apps.googleusercontent.com ``` 6. **Enable the button.** In `src/config/site.ts`: ```ts theme={null} features: { enableGoogleLogin: true, /* ... */ } ``` 7. **Restart dev server.** Env vars are read at startup — `pnpm dev` after each `.env.local` edit. ## What happens behind the scenes When the user clicks "Continue with Google", `authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })` runs. Better Auth redirects to Google, Google bounces back to `/api/auth/callback/google`, and the callback handler: 1. Exchanges the auth code for an access + ID token. 2. Looks up the email in `account` (`providerId = 'google'`). If found, sign in. 3. If not found but the email matches an existing `user`, link a new `account` row (account-linking is on for trusted providers — see `src/lib/auth.ts`). 4. If no match, create a new `user` + `account` and fire the post-create hook (which grants register-gift credits and promotes to admin if applicable). You can watch this happen in the dev console — Better Auth logs each step with the `[better-auth]` prefix. ## Verify it works 1. Visit `/login` — the "Continue with Google" button should be visible. 2. Click it. You bounce to `accounts.google.com`, pick an account, and land back on `/dashboard`. 3. Check the `account` table — there should be a row with `providerId = 'google'` linked to your `user` row. 4. Sign out and sign back in with Google. Same `user` id, no duplicate row. ## Common pitfalls * **`redirect_uri_mismatch` error.** You typed the redirect URI wrong in the Google console. Trailing slashes matter; `http` vs `https` matters; localhost port matters. Copy-paste from the list above. * **Localhost works, production doesn't.** You only added the localhost URI. Add the production URI to the same OAuth client (you can have many redirect URIs per client). * **OAuth consent screen stuck "Testing".** While in Testing, only the test users you list can sign in. Click **Publish App** for general availability. Production apps usually do not need verification unless you ask for sensitive scopes. * **`NEXT_PUBLIC_GOOGLE_CLIENT_ID` missing.** Causes One-Tap to silently no-op. See [One-Tap docs](/auth/one-tap). * **Different domains in dev vs preview vs prod.** Each gets its own redirect URI entry. There's no wildcard support. * **Refresh tokens not requested.** Better Auth uses the standard `online` access type — fine for sign-in. If you later need to call Google APIs on the user's behalf, you'll need offline access and a refresh token; pass `accessType: 'offline'` in your provider config. ## Official docs * Using OAuth 2.0 for Web Server Apps — [developers.google.com/identity/protocols/oauth2/web-server](https://developers.google.com/identity/protocols/oauth2/web-server) * OAuth consent screen setup — [support.google.com/cloud/answer/10311615](https://support.google.com/cloud/answer/10311615) * Better Auth Google provider — [better-auth.com/docs/authentication/google](https://www.better-auth.com/docs/authentication/google) # Google One-Tap Source: https://docs.vibestrap.dev/auth/one-tap Frictionless one-click sign-in for users with an active Google session — already wired via Better Auth's One-Tap plugin. Google One-Tap is the little popup card that appears top-right when a signed-out visitor lands on your home page and they have an active Google session. One click and they're signed in — no redirect, no form. Vibestrap wires it via Better Auth's One-Tap plugin and a tiny client component (`src/components/auth/one-tap.tsx`). It uses [FedCM](https://developers.google.com/privacy-sandbox/3pcd/fedcm), Google's newer browser API that doesn't depend on third-party cookies — which means it'll keep working as Chrome phases those out. ## Prerequisites * [Google OAuth](/auth/google-oauth) already set up. One-Tap reuses the same OAuth client, so `GOOGLE_CLIENT_ID` must already exist. * A `NEXT_PUBLIC_GOOGLE_CLIENT_ID` env var — the public mirror of the same ID. * HTTPS in production. (HTTP works on `localhost` for dev.) ## Step-by-step setup 1. **Mirror the client ID into the public env.** Better Auth's One-Tap plugin needs the ID on the client too — add to `.env.local`: ```bash theme={null} NEXT_PUBLIC_GOOGLE_CLIENT_ID=1234567890-abc...apps.googleusercontent.com ``` Same value as `GOOGLE_CLIENT_ID`. It's safe to expose; it's a public identifier. 2. **Turn the feature on.** In `src/config/site.ts`: ```ts theme={null} features: { enableOneTap: true, /* ... */ } ``` 3. **Confirm the component is mounted.** It already lives in `src/app/layout.tsx` (or the locale layout) inside the `NextIntlClientProvider` so it's alive across every page. The component renders nothing — it just listens for an idle moment to call `authClient.oneTap()`. 4. **Restart `pnpm dev`.** Public env vars are baked at build/dev start. ## How it actually works `src/components/auth/one-tap.tsx` is a 30-line `'use client'` component: ```tsx theme={null} const { data: session, isPending } = useSession(); useEffect(() => { if (isPending || session) return; authClient.oneTap?.().catch(() => {}); }, [isPending, session]); ``` If the user is signed in, it does nothing. If not, it asks Better Auth's plugin to prompt Google. Errors are swallowed because every reason a One-Tap can fail (ineligible browser, dismissed, no Google session) is non-actionable for the user. The "Continue with Google" button on `/login` and the One-Tap popup are independent — both can be active at the same time. One-Tap just makes the home page lighter. The matching client config is in `src/lib/auth-client.ts`: ```ts theme={null} oneTapClient({ clientId: googleClientId, autoSelect: false, // user must click — never auto-pick cancelOnTapOutside: true, // dismissable context: 'signin', }) ``` `autoSelect: false` is deliberate — auto-signing someone in without their click is exactly the kind of dark pattern you want to avoid, and Google penalises it on `accounts.google.com`. Leave it off. ## Verify it works 1. Make sure you have a Google account signed in (in another tab) and you are not signed in to your Vibestrap site. 2. Open the home page in an incognito window with that Google session present. 3. Within a second or two, the One-Tap card should slide in from the top-right. 4. Click it — you're signed in, no redirect. If nothing appears, open DevTools → Network → filter for `accounts.google.com` to see whether the FedCM request was sent. ## Common pitfalls * **Works locally, dead in production.** Almost always missing `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your hosting provider's env config (Vercel, Netlify, etc.). Public env vars are baked at build time — set them, then redeploy. * **HTTPS required.** Production hosts must serve over HTTPS. `localhost` is the one HTTP exception browsers allow. * **Brave or Safari shows nothing.** FedCM is gated behind a setting in Brave (Shields) and is still rolling out in Safari. Don't treat the One-Tap as the only way in — keep the regular button. * **User has multiple Google accounts.** Instead of an instant sign-in card, they see an account chooser. Same plugin, just a different UI from Google's side — nothing to fix on your end. * **Card appears but click does nothing.** Usually means your OAuth client's authorized JavaScript origins don't include the current host. Add it in the Google Cloud Console under your OAuth 2.0 Client ID → Authorized JavaScript origins. ## Official docs * One-Tap features overview — [developers.google.com/identity/gsi/web/guides/features](https://developers.google.com/identity/gsi/web/guides/features) * FedCM migration guide — [developers.google.com/identity/gsi/web/guides/fedcm-migration](https://developers.google.com/identity/gsi/web/guides/fedcm-migration) * Display the One-Tap prompt — [developers.google.com/identity/gsi/web/guides/display-one-tap](https://developers.google.com/identity/gsi/web/guides/display-one-tap) * Better Auth One-Tap plugin — [better-auth.com/docs/plugins/one-tap](https://www.better-auth.com/docs/plugins/one-tap) # Auth overview Source: https://docs.vibestrap.dev/auth/overview How Vibestrap wires Better Auth — what's enabled, what plugins ship, and the role and session model. Auth is the work every product needs and no product is differentiated by — yet a homegrown setup typically eats 1-2 weeks of an indie launch and ships with at least one subtle bug. vibestrap wires [Better Auth](https://www.better-auth.com) 1.4 in completely: email + password, verification, forgot-password, change-password, Google * GitHub OAuth, Google One-Tap, admin role, and an OpenAPI reference for every endpoint. You write zero auth code. This page is a map of what's already wired so you know what to touch and what to leave alone. The provider-specific setup pages (Google OAuth, GitHub OAuth, One-Tap) go deeper. ## What's wired Look at `src/lib/auth.ts` — that's the single source of truth. In short: * **Email + password** with `minPasswordLength: 8` and `autoSignIn: true` after registration. * **Email verification** sent on signup, link expires in 24 h, auto-signs in after verify. * **Reset password** via emailed link, handled by `sendForgotPasswordEmail` from `src/mail`. * **Social providers** auto-enabled when their env vars are present (`GOOGLE_*`, `GITHUB_*`). * **Account linking** on for trusted providers (`google`, `github`) — same email lands on the same user. * **Admin plugin** with default role `user` and admin role `admin`. * **One-Tap plugin** mounted when `siteConfig.features.enableOneTap` and `GOOGLE_CLIENT_ID` are set. * **openAPI plugin** so you can browse the auth REST surface at `/api/auth/reference`. * **Delete user** enabled — required for the GDPR data-deletion flow. ## Prerequisites * A Postgres database reachable via `DATABASE_URL`. * `pnpm db:push` already run (creates `user`, `session`, `account`, `verification`). * A `BETTER_AUTH_SECRET` of at least 16 characters (`openssl rand -hex 32` works). * `BETTER_AUTH_URL` or `NEXT_PUBLIC_APP_URL` pointing at your site. * For email flows: `RESEND_API_KEY` + a verified `RESEND_FROM_EMAIL`. ## Step-by-step setup 1. **Generate a secret.** Drop it into `.env.local`: ```bash theme={null} BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_URL=http://localhost:3000 ``` 2. **Push the schema.** This creates the four Better Auth tables alongside Vibestrap's own. ```bash theme={null} pnpm db:push ``` 3. **Pick the auth methods you want.** In `src/config/site.ts`: ```ts theme={null} features: { enableCredentialLogin: true, enableEmailVerification: true, enableGoogleLogin: true, enableGithubLogin: true, enableOneTap: true, } ``` 4. **Wire the providers you turned on.** Each has its own page: * [Google OAuth](/auth/google-oauth) * [GitHub OAuth](/auth/github-oauth) * [One-Tap](/auth/one-tap) 5. **(Optional) Promote yourself to admin.** Add your email to `ADMIN_EMAILS` (comma-separated) — the post-signup hook will set `role = 'admin'` automatically: ```bash theme={null} ADMIN_EMAILS=you@example.com,cofounder@example.com ``` ## The role system Two roles only: `user` (default) and `admin`. The `admin` plugin handles the heavy lifting; Vibestrap adds one extra: a `databaseHooks.user.create.after` hook in `src/lib/auth.ts` checks the new user's email against `ADMIN_EMAILS` and promotes them. Server actions can gate on role using the `adminActionClient` from `src/lib/safe-action.ts` — see the admin pages under `/admin` for examples. ## Session config Defaults that ship in `src/lib/auth.ts`: | Setting | Value | Why | | -------------------- | ------ | ----------------------------------------- | | `expiresIn` | 7 days | Shortish — re-auth catches stolen tokens. | | `updateAge` | 1 day | Slides the expiry forward on activity. | | `cookieCache.maxAge` | 1 hour | Avoids a DB lookup on every page load. | Cookie-cache means a recently-revoked session can still feel valid for up to an hour. If you need instant revocation (think enterprise) drop `cookieCache` to a shorter TTL or disable it. ## Audit fields The `user` schema has `lastLoginAt`, `lastLoginIp`, `lastLoginUserAgent` — populated on every successful sign-in via Better Auth's session hook. Useful for security emails ("New sign-in from …") and for the admin user list. Don't query them on the hot path; index them only if you actually run reports. ## Verify it works 1. `pnpm dev`, visit `/register`, create an account. 2. Check the dev console — you should see the verification email logged by Resend. 3. Click the verify link → you land on `/dashboard` already signed in. 4. Visit `/api/auth/reference` to browse the openAPI doc and confirm endpoints respond. ## Common pitfalls * **`BETTER_AUTH_SECRET` too short.** Anything under 16 chars throws at startup. * **`BETTER_AUTH_URL` mismatch in prod.** Cookies are scoped to this URL — get the scheme + host exactly right or sessions won't stick. * **Email verification stuck.** Check that `RESEND_FROM_EMAIL`'s domain is verified in Resend, otherwise the API silently drops the message. * **Promoted admin doesn't see admin UI.** The hook only runs on signup. For an existing user, run an SQL update or re-create them. * **Schema drift after upgrading Better Auth.** Re-run `pnpm db:push` (dev) or generate a migration (prod). ## Official docs * Better Auth — [better-auth.com/docs](https://www.better-auth.com/docs) * Admin plugin — [better-auth.com/docs/plugins/admin](https://www.better-auth.com/docs/plugins/admin) * One-Tap plugin — [better-auth.com/docs/plugins/one-tap](https://www.better-auth.com/docs/plugins/one-tap) * openAPI plugin — [better-auth.com/docs/plugins/open-api](https://www.better-auth.com/docs/plugins/open-api) # Configuration Source: https://docs.vibestrap.dev/configuration How to customize Vibestrap via src/config/site.ts and the env layer. Vibestrap separates configuration into three layers, each with a clear purpose. Knowing which layer to touch will save you a lot of time. ## The three layers | Layer | File | What it controls | | ---------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------- | | **Brand & business config** | `src/config/site.ts` | Product name, pricing, plans, providers active flag, social links — anything a buyer should change. | | **Secrets & runtime config** | `.env.local` (validated by `src/env.ts`) | API keys, database URL, signing secrets — anything that varies per environment. | | **Customer-facing copy** | `messages/en.json` + `messages/zh.json` | Every string the user sees. Bilingual; both files must have the same keys. | If you find yourself editing component source to change copy, stop — you should be editing `messages/{en,zh}.json` instead. ## What `siteConfig` controls The whole file is \~250 lines. Here are the load-bearing fields: ```ts theme={null} siteConfig.name // brand name, used in + emails + footer siteConfig.url // canonical URL — drives sitemap, OG, mail links siteConfig.shortDescription // hero subtitle, OG image siteConfig.product // pricing card: standard / promo, price IDs per provider siteConfig.payment.provider // 'stripe' | 'creem' | 'nowpayments' siteConfig.customerService // { enable, provider: 'crisp' | 'tawk' | … } siteConfig.affiliate // { enable, provider, internalCommissionPct } siteConfig.newsletter // { enable, provider: 'resend' | 'beehiiv' } siteConfig.analytics // { vercel, googleAnalytics, posthog, plausible, umami } siteConfig.credits // ledger config: registerGift, monthlyFree, … siteConfig.demoPlans // four-tier sample (one-time + monthly + yearly) siteConfig.i18n // locales + default locale siteConfig.features // feature flags (blog / newsletter / OAuth providers) ``` Every field has inline comments — read it once cover-to-cover, you'll know what you can change without grep-ing the codebase. ## What lives outside `siteConfig` These have their own homes — by design. | Concern | Where | | -------------------------- | -------------------------------------------------- | | Env vars / secrets | `.env.local`, validated by `src/env.ts` | | Database schema | `src/db/{auth,app,affiliate,ai,license}.schema.ts` | | AI provider list / pricing | `src/ai/index.ts` + `src/ai/pricing.ts` | | Marketing copy | `messages/{en,zh}.json` | | Mail templates | `src/mail/templates/` | | Theme tokens | `src/app/globals.css` (`@theme` block) | | Sidebar nav for /docs | `src/config/docs-nav.ts` | ## Adding a new feature flag 1. Add to `siteConfig.features`: ```ts theme={null} features: { ..., enableMyFeature: true } ``` 2. Reference in code: ```tsx theme={null} import { siteConfig } from '@/config/site'; if (siteConfig.features.enableMyFeature) { ... } ``` That's it — no extra plumbing. ## Adding an env var 1. Add a Zod-validated entry in `src/env.ts` (server section if it's secret; client section if it has the `NEXT_PUBLIC_` prefix and is safe to ship to the browser). 2. If it's a public var, add it to `experimental__runtimeEnv` too (Next.js requires this). 3. Document it in [Env reference](/env-reference). 4. Use `import { env } from '@/env'` to read it — never `process.env.X` directly. ## Best-practice checklist * ✅ Brand changes happen in `siteConfig` and `messages/`. No component-source edits. * ✅ Secrets only in `.env.local`. The `.env.example` documents the shape; the real `.env.local` is gitignored. * ✅ When swapping a provider (Stripe → Creem), change `siteConfig.payment.provider` AND set the matching `*_PRICE_*` env vars. The rest of the app doesn't care which provider is active. * ✅ Both `messages/en.json` and `messages/zh.json` always have the same keys. `node scripts/check-i18n.mjs` enforces this. # Fumadocs collections (blog + changelog) Source: https://docs.vibestrap.dev/content/fumadocs How Vibestrap's blog and changelog use Fumadocs MDX, and where they differ from the Mintlify docs site. Vibestrap uses two MDX systems, one for each kind of content: * **The `/docs` site** (this site, at `docs.vibestrap.dev`) is on **Mintlify**. It lives under `docs/` and `docs/docs.json` at the repo root. Setup walkthrough: [Deploy docs on Mintlify](/deployment/mintlify). * **The `/blog` and `/changelog`** routes on the marketing app are on **[Fumadocs](https://fumadocs.vercel.app/)**. Their MDX lives under `content/blog/` and `content/changelog/`. This page covers those two. The split is intentional: docs benefit from Mintlify's purpose-built navigation / search / AI / multilingual; blog and changelog are part of the marketing site and benefit from being in the same Next.js app (shared theme, shared analytics, locale-aware routing). ## How the collections are defined `source.config.ts`: ```ts theme={null} export const blog = defineCollections({ type: 'doc', dir: 'content/blog', schema: frontmatterSchema.extend({ /* date, author, categories, … */ }), }); export const changelog = defineCollections({ type: 'doc', dir: 'content/changelog', schema: frontmatterSchema.extend({ version: z.string(), date: ... }), }); export const pages = defineCollections({ type: 'doc', dir: 'content/pages', schema: frontmatterSchema, }); ``` Each definition lists a `dir` and a Zod schema for frontmatter. The build generates `.source/server.ts`, which exports typed arrays consumed by `src/lib/source.ts`. ## Add a new blog post Blog frontmatter has the most fields: ```md theme={null} --- title: Welcome to Vibestrap description: One-line summary. date: 2026-04-27 author: Vibestrap categories: [release, indie-hacking] --- ``` Drop the file at `content/blog/<slug>.mdx` plus `<slug>.zh.mdx`. The blog index page reads everything via `pickByLocale(blog, locale)` and sorts by `date` descending — no extra config needed. ## Add a changelog entry Same shape as blog, with a required `version` field: ```md theme={null} --- title: v1.1 — credit packs description: Small but mighty. version: 1.1.0 date: 2026-05-15 --- ``` Files go under `content/changelog/`. The version field powers the release title and sort order on `/changelog`. ## The `pages` collection `content/pages/` is a catch-all for one-off MDX pages that don't belong in blog or changelog. Currently holds a placeholder. Use it when you need MDX rendering without sidebar nav. ## Customize MDX components `src/mdx-components.tsx` is the central override point for blog + changelog rendering. It defers styling to Tailwind's `prose` plugin. Add custom shortcodes here: ```tsx theme={null} export function getMDXComponents(components?: MDXComponents): MDXComponents { return { Callout: ({ children }) => <div className="callout">{children}</div>, ...components, }; } ``` Then `<Callout>...</Callout>` becomes available in any blog / changelog MDX file. (The Mintlify docs site has its own component library — see [Mintlify Components](https://mintlify.com/docs/components).) ## Verify it works ```bash theme={null} pnpm exec fumadocs-mdx # regenerate .source/ pnpm dev # visit your new post pnpm build # catches any frontmatter schema errors ``` `pnpm build` runs `fumadocs-mdx` automatically — invalid frontmatter fails the build with a Zod error pointing at the file. ## Common pitfalls 1. **Forgetting to regenerate `.source/`** — if you add a file and the page 404s in dev, run `pnpm exec fumadocs-mdx`. 2. **Invalid frontmatter** — Zod rejects unknown shapes. Date must parse, `version` is required for changelog, `published: false` hides blog posts. 3. **MDX autolinks** — writing `<https://example.com>` breaks the MDX parser. Use `[example.com](https://example.com)` or bare URL text. 4. **Wrong collection dir** — files outside the configured `dir` are silently ignored. Double-check the path matches `source.config.ts`. 5. **Don't put docs here** — anything you'd want under `/docs` belongs in `docs/` at the repo root (Mintlify), not `content/docs/`. ## Official docs * [fumadocs.vercel.app](https://fumadocs.vercel.app/) — Fumadocs reference * [Fumadocs MDX](https://fumadocs.vercel.app/docs/mdx) — collection config * [MDX](https://mdxjs.com/) — Markdown + JSX syntax * [Zod](https://zod.dev/) — frontmatter schema syntax # Bilingual content & i18n Source: https://docs.vibestrap.dev/content/i18n How Vibestrap handles English + Chinese copy, MDX variants, and locale-aware lookups. Vibestrap ships fully bilingual (English + Chinese) out of the box. Two systems work together: `next-intl` for UI strings (under `messages/`) and a thin naming-convention layer for MDX content (under `content/`). Once you internalize the conventions there is no extra ceremony — just write twice. ## Prerequisites None. The plumbing is wired up; you only add files. ## How locales are routed Routing is `as-needed` (`src/i18n/routing.ts`): * English (the default) lives at the root: `/docs/quickstart` * Chinese is prefixed: `/zh/docs/quickstart` * Locale list comes from `siteConfig.i18n.locales` (`['en', 'zh']`) Every locale-prefixed page must call `setRequestLocale(locale)` at the top — without it, server components fall back to the default locale and your `t(...)` calls go silent. ```tsx theme={null} const { locale } = await params; setRequestLocale(locale); ``` ## Naming convention for MDX For each piece of content, ship two files side by side: ``` content/blog/welcome-to-vibestrap.mdx # English content/blog/welcome-to-vibestrap.zh.mdx # Chinese ``` The `.zh.mdx` suffix is the only signal — there is no separate folder, no JSON manifest. The helpers in `src/lib/source-helpers.ts` strip the suffix to compute the canonical slug. ## Locale-aware lookups Three helpers cover everything: ```ts theme={null} import { pickByLocale, findBySlug, canonicalSlugs } from '@/lib/source'; // All blog posts, locale-preferred (zh falls back to en if missing) const posts = pickByLocale(blog.docs, locale); // Single post by slug const post = findBySlug(blog.docs, slug, locale); // Build params for generateStaticParams (en files only) const slugs = canonicalSlugs(blog.docs); ``` `pickByLocale('zh')` returns the `.zh.mdx` if it exists, otherwise the English fallback so your /zh/ pages never 404 on missing translations. ## UI strings — both files, same keys Translations live in `messages/en.json` and `messages/zh.json`. The two files must have identical key shapes; the structural diff is enforced by `scripts/check-i18n.mjs`. ```tsx theme={null} import { useTranslations } from 'next-intl'; const t = useTranslations('Home.hero'); return <h1>{t('title')}</h1>; ``` When you add a new key to `en.json`, add it to `zh.json` in the same place — even if the Chinese is a placeholder. The validator will yell otherwise. ## Adding a new locale 1. Append to `siteConfig.i18n.locales` (e.g. `['en', 'zh', 'ja']`). 2. Create `messages/ja.json` mirroring `en.json`. 3. For any MDX you want translated, ship `slug.ja.mdx`. 4. Add a new branch in `pickByLocale` if you want non-en fallbacks. That's it — routing picks up the new locale automatically. ## Verify it works ```bash theme={null} node scripts/check-i18n.mjs # structural parity + literal t() validation pnpm typecheck # types are happy pnpm dev # visit /docs and /zh/docs ``` The validator reports keys that exist in only one file plus any `t('foo.bar')` call that doesn't resolve under the namespace declared in the same file. ## Common pitfalls 1. **Missing `.zh.mdx`** — the `/zh/` page silently falls back to English. Run the dev server and click around in Chinese mode to catch this. 2. **Date drift between variants** — when you update an English blog post, also bump the date in the `.zh.mdx`. Otherwise the index sort order diverges. 3. **Template `t()` calls** — `t(\`Foo.\$\`)\` cannot be statically verified. The validator only checks the prefix exists. Smoke-test the page. 4. **Missing `setRequestLocale(locale)`** — locale-prefixed pages without this call render with the default locale's messages. Easy to miss. 5. **Using `next/link` directly** — always import `Link` and `useRouter` from `@/i18n/navigation` so locale prefixes survive client navigation. ## Official docs * [next-intl.dev](https://next-intl.dev/) — full reference for the i18n layer * [next-intl App Router](https://next-intl.dev/docs/getting-started/app-router) — request config & routing * [Next.js i18n routing](https://nextjs.org/docs/app/building-your-application/routing/internationalization) — Next.js fundamentals # Marketing blocks Source: https://docs.vibestrap.dev/content/marketing How the home page is composed from block components and how to extend it. The Vibestrap home page is a stack of self-contained "block" components in `src/components/blocks/`. Each block owns its layout, pulls its copy from `messages/{en,zh}.json`, and is independently re-orderable. Composition lives in a single file: `src/app/[locale]/(marketing)/page.tsx`. ## Prerequisites None. The blocks are already wired in. ## The block lineup Current order in `page.tsx`: ```tsx theme={null} <Hero /> // Headline + primary CTA + sparkle badge <LogoCloud /> // "As featured in" / proof bar <Features /> // Grid of feature cards <UseCases /> // Tool-station example use cases <WhyVibestrap /> // Differentiators vs. building from scratch <Quickstart /> // 3-4 step "ship it today" flow <Pricing /> // Single-product pricing card <FAQ /> // Accordion of common questions <NewsletterCard /> // Email capture <CTA /> // Final conversion push ``` Each component is a single file in `src/components/blocks/`. None of them take props — all variation is via i18n keys. ## How a block reads its copy Every block calls `useTranslations` with a `Home.<block>` namespace: ```tsx theme={null} import { useTranslations } from 'next-intl'; export function Hero() { const t = useTranslations('Home.hero'); return <h1>{t('title')}</h1>; } ``` Find the matching keys in `messages/en.json` under `Home.hero`, and the same keys in `messages/zh.json`. Editing copy is editing JSON — never touch component source for words. ## Re-order or remove blocks Open `src/app/[locale]/(marketing)/page.tsx` and rearrange the JSX. To drop a block, delete the line. To move pricing above features, swap the order. There is no nav data structure — visual order is JSX order. ## Add a new block 1. Create `src/components/blocks/<my-block>.tsx`. Default-export a server component that calls `useTranslations('Home.myBlock')`. 2. Add `Home.myBlock` to both `messages/en.json` and `messages/zh.json`. 3. Import and render in `page.tsx` at the desired position. 4. Run `node scripts/check-i18n.mjs` to confirm key parity. A minimal block: ```tsx theme={null} import { useTranslations } from 'next-intl'; export function MyBlock() { const t = useTranslations('Home.myBlock'); return ( <section className="border-b py-24"> <h2 className="text-3xl font-bold">{t('title')}</h2> </section> ); } ``` ## Theme tokens Brand colors and radii are CSS variables defined in the `@theme` block of `src/app/globals.css` (Tailwind v4 directive). Change `--primary` once and every block re-skins. Three swappable themes (`cream` / `white` / `ash`) live under `.theme-*` selectors — switch with `siteConfig.ui.theme`. ## Verify it works ```bash theme={null} pnpm dev # eyeball /, /zh, and resize to 360px node scripts/check-i18n.mjs # confirm key parity pnpm typecheck && pnpm lint # types + style pnpm build # production check ``` Open Chrome DevTools, toggle device mode to iPhone SE (360px), and scroll the home page top to bottom. Every block must look right. ## Common pitfalls 1. **Hardcoded English in components** — always use `useTranslations`. If you write a literal string, the Chinese site shows English. The reviewer will catch it but you'll already have wasted commits. 2. **Block order matters for visual rhythm** — alternate dense and breathing sections (e.g. Features then Quickstart, not two grids in a row). 3. **Mobile responsiveness at 360px** — the iPhone SE width is the floor. Test it. Hero subtitles love to overflow on narrow screens. 4. **Adding keys to `en.json` only** — the validator blocks the build. Always add to both locale files. 5. **Importing `next/link` directly** — use `Link` from `@/i18n/navigation` so locale prefixes are preserved on click. ## Official docs * [tailwindcss.com](https://tailwindcss.com/) — utility classes and the v4 `@theme` directive * [ui.shadcn.com](https://ui.shadcn.com/) — the component primitives blocks use * [Lucide icons](https://lucide.dev/) — icon set imported throughout the blocks * [next-intl useTranslations](https://next-intl.dev/docs/usage/messages) — i18n inside blocks # Customization Source: https://docs.vibestrap.dev/customization What buyers customize, where to change it, and what stays out of the way. 90% of customization happens in three files: `src/config/site.ts`, `src/app/globals.css`, and `messages/{en,zh}.json`. The rest is swapping logo SVGs, editing email templates, and re-composing marketing blocks. This page is the map. ## Quick swaps via `siteConfig.ui` Two pre-built switches you flip without writing a single line of CSS — they exist because the underlying change is cross-cutting and easy to get wrong: ```ts theme={null} ui: { theme: 'cream' as 'cream' | 'white' | 'ash', // cream — warm editorial paper (default; Loops / Resend / Clerk feel) // white — clinical pure white (Stripe / Linear / Vercel feel) // ash — cool gray-blue, airy (Apple / Bolt feel) navStyle: 'underline-fade' as | 'tone' // Stripe / Resend — color-only shift | 'underline-fade' // Notion / Loops — underline fades in (default) | 'pill' // Linear / Vercel — rounded background | 'underline-appears' // NYT — instant underline on hover | 'split', // v0 — pill on hover, line on active } ``` Theme classes live in `src/app/globals.css`; nav recipes live in `src/components/layout/nav-styles.ts` (each preset documents the source site it's modeled on). Add a new preset by extending the file — the TypeScript union narrows automatically. ## Customize with AI Vibestrap ships intentionally **few** pre-built presets. Most block-level changes — the hero, pricing card, footer, button variant, card visual — are faster for an AI agent to generate than for a preset system to maintain. For the common ones, [`STYLES.md` → AI swap recipes](https://github.com/xiaohu0x/vibestrap/blob/main/STYLES.md#ai-swap-recipes--single-block-customization) ships copy-paste prompt templates that name the exact files and constraints. Open the recipe for the block you want to change, paste the prompt into Claude Code / Cursor / Codex, and the change usually lands in 2–5 minutes. Recipes available: * Swap the **hero** (terminal demo → centered headline / split-screen / video bg) * Restyle the **pricing card** (single → two-card monthly+yearly / 3-tier / slider) * Swap the **feature-card** visual (hard-border → soft-shadow / glass / gradient) * Compact the **footer** (4-col → minimal one-row / mega-with-newsletter) * Restyle the **primary button** (solid → outlined / gradient / ghost) For full re-skins (cream-mono → dark tech, etc.) see the seven complete style recipes earlier in `STYLES.md` — same file, top of the document. ## Brand identity Edit `src/config/site.ts` — the central config: ```ts theme={null} export const siteConfig = { name: 'YourProduct', description: 'Your one-paragraph pitch.', shortDescription: 'Your one-line tagline.', url: 'https://your-domain.com', links: { github: 'https://github.com/yourorg/yourrepo', twitter: 'https://twitter.com/yourhandle', contact: 'mailto:hello@your-domain.com', }, mail: { fromName: 'YourProduct', supportEmail: 'support@your-domain.com', }, }; ``` Keep this file under 250 lines. Domain-heavy logic (pricing math, AI provider settings) belongs in its own module — see `src/ai/pricing.ts` for the pattern. ## Colors (custom palette beyond the presets) Tailwind v4 reads tokens from `@theme` in `src/app/globals.css`. Every color is [OKLCH](https://oklch.com) — perceptually uniform, predictable hue rotation, copy-paste-friendly with the OKLCH color picker. Vibestrap is **light-only** — there is no `.dark` class and no system-mode toggle. If you want a dark variant, see the dark-mode reference doc for a 5-step opt-in recipe. The default `cream` theme: ```css theme={null} .theme-cream { --background: oklch(0.96 0.012 85); /* warm off-white paper */ --foreground: oklch(0.18 0 0); /* near-black */ --primary: oklch(0.18 0 0); --primary-foreground: oklch(0.97 0.008 85); --accent-tech: oklch(0.55 0.2 250); /* electric blue — logo cursor + progress bar */ --accent-tech-light: oklch(0.72 0.16 240); /* full set in globals.css */ } ``` Two ways to customize: 1. **Tweak a preset** — edit the OKLCH values inside `.theme-cream` (or `.theme-white` / `.theme-ash`) in `globals.css`. shadcn/ui components consume these tokens by name, so a single edit re-skins everything. 2. **Add a new preset** — duplicate one of the `.theme-*` blocks under a new name, then extend the union in `siteConfig.ui.theme` to include it. The TypeScript types pick it up automatically. Pick a brand hue with the OKLCH picker. Always swap `--accent-tech` and `--accent-tech-light` together — the progress-bar gradient interpolates between them. ## Fonts The default stack is **Geist Sans** + **Geist Mono**, loaded in `src/app/layout.tsx` via `next/font/google`: ```tsx theme={null} import { Geist, Geist_Mono } from 'next/font/google'; const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] }); const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] }); ``` Swap in any `next/font/google` import. Update `--font-sans` / `--font-mono` in `globals.css` to match the CSS variable name and you're done — no Tailwind config edit needed. ## Marketing copy All on-page copy lives in `messages/en.json` and `messages/zh.json` under the `Home` namespace. Both files **must** have identical key structure — next-intl throws at build time if keys diverge. ```json theme={null} "Home": { "hero": { "title": "Your hero title", "subtitle": "Your supporting line." } } ``` Hot-reload picks edits up instantly. When you add a new message key, add it to both files in the same commit. ## Hero / features layout The home page is a stack of independent blocks composed in `src/app/[locale]/(marketing)/page.tsx`: ```tsx theme={null} <Hero /> <LogoCloud /> <Features /> <UseCases /> <WhyVibestrap /> <Quickstart /> <Pricing /> <FAQ /> <NewsletterCard /> <CTA /> ``` Reorder or delete any of them. Each block is a self-contained component in `src/components/blocks/` reading from `messages/{locale}.json` — no prop wiring across boundaries. ## Pricing Two distinct pricing surfaces in `siteConfig`: **For the Vibestrap product itself** (what you sell to your buyers): ```ts theme={null} product: { standardPriceCents: 9900, // $99 promo: { active: true, priceCents: 4900 }, // $49 limited offer } ``` Toggle `promo.active` to flip between the two. The hero, pricing block, and checkout all read from `activePriceCents()` so a single edit propagates. **For your buyer's downstream app** (sample subscription + credit pack pricing they show their own users): ```ts theme={null} demoPlans: [ { id: 'lifetime_promo', type: 'one_time', priceCents: 4900, priceIdEnv: 'STRIPE_PRICE_VIBESTRAP_PROMO', creditsGranted: 1000 }, { id: 'lifetime_standard', type: 'one_time', priceCents: 9900, priceIdEnv: 'STRIPE_PRICE_VIBESTRAP_STANDARD', creditsGranted: 1000 }, { id: 'pro_monthly', type: 'subscription', interval: 'monthly', priceCents: 999, priceIdEnv: 'STRIPE_PRICE_PRO_MONTHLY', creditsGranted: 1000 }, { id: 'pro_yearly', type: 'subscription', interval: 'yearly', priceCents: 9900, priceIdEnv: 'STRIPE_PRICE_PRO_YEARLY', creditsGranted: 12000 }, ] ``` These render as a 2×2 grid in `/settings/credits` showing the four common pricing patterns. Replace prices, env names, and credit grants with whatever your buyer's product charges. ## Logo The default header and footer use the `Sparkles` icon from `lucide-react`. Replace it in two places: ```tsx theme={null} // src/components/layout/header.tsx import { Sparkles } from 'lucide-react'; // → swap for your own SVG component or <Image src="/logo.svg" .../> <Sparkles className="size-5 text-primary" /> ``` Same edit in `src/components/layout/footer.tsx`. For the OG image, replace `public/og.png` (1200x630, PNG or JPG). ## Email templates React Email templates live in `src/mail/templates/`: * `verify-email.tsx` — signup verification. * `welcome.tsx` — sent after the user verifies. * `forgot-password.tsx` — password reset link. Edit them like any React component. Branding (`siteConfig.name`, `siteConfig.mail.supportEmail`) is already pulled in. To preview locally before the email-dev script ships, trigger the auth flow and watch the dev console for the rendered HTML. ## Adding a locale 1. Add the locale code to `src/config/site.ts`: ```ts theme={null} i18n: { defaultLocale: 'en', locales: ['en', 'zh', 'ja'] } ``` 2. Create `messages/ja.json` mirroring the key structure of `en.json`. 3. Translate every `*.zh.mdx` to `*.ja.mdx` for any docs you ship. 4. Done. URLs become `/ja/...` automatically. ## See also * [Configuration](/configuration) — every `siteConfig` field. * [Architecture](/architecture) — how blocks, themes, and providers wire together. * [Env reference](/env-reference) — keys that flip features on / off. # Adding dark mode back Source: https://docs.vibestrap.dev/dark-mode Vibestrap ships light-only. Here's the five-step recipe to re-enable a light/dark toggle. Vibestrap intentionally ships **light-mode only**. The reason: every component change becomes a two-mode tax, the marketing site reads better as a single confident theme, and most of the premium scaffolds we benchmark against (Stripe, Loops, Resend, Clerk, tuwa.ai) don't ship dark mode either. That said, dark mode is a real preference for many developers. If your end-product needs it, here's the five-step recipe to put it back. ## Step 1 — Reinstall `next-themes` ```bash theme={null} pnpm add next-themes ``` ## Step 2 — Wrap with `ThemeProvider` Edit `src/components/providers.tsx`: ```tsx theme={null} 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from 'next-themes'; import { useState } from 'react'; import { Toaster } from 'sonner'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false }, }, }) ); return ( <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange> <QueryClientProvider client={queryClient}> {children} <Toaster position="top-center" richColors /> </QueryClientProvider> </ThemeProvider> ); } ``` Note: drop the `theme="light"` prop from `<Toaster>` — sonner will follow the system theme automatically through `next-themes`. ## Step 3 — Re-add `.dark` block in `globals.css` Add the variant directive at the top of `src/app/globals.css` (right after the `@plugin` line): ```css theme={null} @custom-variant dark (&:is(.dark *)); ``` And add a `.dark { ... }` block right after `:root { ... }` defining the inverse palette. For the editorial cream-mono baseline, that means something like: ```css theme={null} .dark { --background: oklch(0.16 0.01 85); --foreground: oklch(0.96 0.012 85); --card: oklch(0.20 0.012 85); --card-foreground: oklch(0.96 0.012 85); --popover: oklch(0.20 0.012 85); --popover-foreground: oklch(0.96 0.012 85); --primary: oklch(0.96 0.012 85); --primary-foreground: oklch(0.16 0.01 85); --secondary: oklch(0.26 0.012 85); --secondary-foreground: oklch(0.96 0.012 85); --muted: oklch(0.26 0.012 85); --muted-foreground: oklch(0.7 0.005 80); --accent: oklch(0.26 0.012 85); --accent-foreground: oklch(0.96 0.012 85); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 14%); --ring: oklch(0.55 0.2 250); } ``` Tune values to your skin — these mirror the cream-mono editorial defaults. ## Step 4 — Restore the toggle component Recreate `src/components/layout/theme-toggle.tsx`: ```tsx theme={null} 'use client'; import { Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { Button } from '@/components/ui/button'; export function ThemeToggle() { const { setTheme, resolvedTheme } = useTheme(); const next = resolvedTheme === 'dark' ? 'light' : 'dark'; return ( <Button variant="ghost" size="icon" aria-label="Toggle theme" onClick={() => setTheme(next)} > <Sun className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Moon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> </Button> ); } ``` Then mount it in `src/components/layout/header.tsx`: ```tsx theme={null} import { ThemeToggle } from './theme-toggle'; // … <div className="flex items-center gap-1"> <LocaleSwitcher /> <ThemeToggle /> <UserNav /> </div> ``` ## Step 5 — Restore `dark:` Tailwind variants where they matter The places in Vibestrap that used dark-variant compensations were: * Markdown prose pages — add `dark:prose-invert` back to the `<div className="prose ...">` wrappers under `src/app/[locale]/(marketing)/{terms,privacy,refund,license,about,changelog,blog/[slug]}/page.tsx`. * Status colors in `src/components/ai/*` — `text-emerald-600` etc. benefit from `dark:text-emerald-400` for readable contrast on dark. * `src/components/ui/alert.tsx` — destructive variant readability. * `src/components/turnstile-field.tsx` — pass `resolvedTheme` to the Turnstile widget so its embedded UI matches. Search for the `dark:*` usages you need with: ```bash theme={null} grep -rn "text-emerald-600\|text-amber-600\|prose " src/ ``` …and add a paired `dark:*` class alongside. ## Verification After the five steps, run the gates: ```bash theme={null} pnpm typecheck && pnpm lint && pnpm check:i18n && pnpm build ``` Toggle the theme in the browser and confirm: * Marketing pages render cleanly in both themes. * Status / accent colors are readable in dark. * Sonner toasts pick up the active theme. * Turnstile widget colors match. ## Why we don't ship it ourselves Inherited maintenance: every component change becomes "does this work in dark too?". Most buyers ship to mainstream consumer audiences who don't toggle dark mode on marketing sites. Editorial / cream-mono aesthetics — Vibestrap's default skin — are light-first by design. If you go through the recipe above and discover gaps in our documentation, please send us a note — we'll improve this page rather than re-add dark mode to the default branch. # Deploy to Cloudflare Workers Source: https://docs.vibestrap.dev/deployment/cloudflare Migrate from Vercel to Workers for sub-50ms global latency. Cloudflare Workers is the upgrade path when you outgrow Vercel — high-traffic sites, sub-50ms global p99 latency, or just cheaper egress at scale. The migration is a single adapter swap (OpenNext for Cloudflare) plus a switch to a serverless-friendly Postgres pooler. Plan on an afternoon if it's your first Workers deploy; less if you've shipped Workers before. ## Prerequisites * A Cloudflare account with Workers enabled (the free plan is enough for a staging deploy). * `wrangler` CLI authenticated: `pnpm dlx wrangler login`. * Your Cloudflare account ID (dashboard → right sidebar). * A serverless-friendly Postgres pooler URL — Workers cannot hold long-lived TCP connections, so direct DB URLs are out. Good options: **Neon serverless driver** (HTTP-based), **Supabase pgbouncer** (transaction mode), or a hosted PgBouncer. * Your existing env values from the Vercel deploy (you've already shipped on Vercel, right?). [Vercel deploy guide](/deployment/vercel) first. The repo ships a `wrangler.toml.example` at the root — copy it, don't write one from scratch. ## Step-by-step ### 1. Install the OpenNext Cloudflare adapter ```bash theme={null} pnpm add -D @opennextjs/cloudflare ``` This bundles your Next.js app into a Worker-compatible build under `.open-next/worker.js`. ### 2. Create your wrangler.toml ```bash theme={null} cp wrangler.toml.example wrangler.toml ``` Edit two fields: * `name` — your Worker's name (lowercase, dash-separated). * `account_id` — uncomment and paste your Cloudflare account ID. The shipped config already sets `compatibility_date`, `nodejs_compat` flag, the `.open-next/assets` binding, and an `[assets]` block. Don't change those unless you know why. ### 3. Switch to a serverless-friendly DB pooler Workers run for milliseconds and tear down — the classic `pg` driver dies on cold starts. Pick one: ``` # Neon — uses HTTP, no connection pool needed DATABASE_URL=postgresql://user:pass@ep-xyz.neon.tech/db?sslmode=require # Supabase — pgbouncer in transaction mode (port 6543) DATABASE_URL=postgresql://user:pass@db.xyz.supabase.co:6543/postgres?pgbouncer=true ``` If you're staying on Drizzle (you are — it's the only ORM in the scaffold), you may need to swap the driver in `src/db/index.ts` to `drizzle-orm/neon-http` or `drizzle-orm/postgres-js` configured for pooled mode. The `pg` driver does not work on Workers. ### 4. Push secrets via wrangler **Never** commit secrets to `wrangler.toml`. Push each one: ```bash theme={null} wrangler secret put DATABASE_URL wrangler secret put BETTER_AUTH_SECRET wrangler secret put STRIPE_SECRET_KEY wrangler secret put STRIPE_WEBHOOK_SECRET wrangler secret put RESEND_API_KEY # …repeat for every server-side env var you use ``` Public vars (the `NEXT_PUBLIC_*` ones) live under `[vars]` in `wrangler.toml` — they're shipped in the client bundle anyway, so no harm. ### 5. Build and deploy ```bash theme={null} pnpm exec opennextjs-cloudflare build pnpm exec wrangler deploy ``` The first build is slow (\~3-5 minutes) because OpenNext ahead-of-time bundles every page. Subsequent builds incremental-cache better. The deploy URL prints to stdout — it'll be `https://<your-worker-name>.<your-subdomain>.workers.dev`. Custom domains attach through the Cloudflare dashboard (Workers & Pages → your worker → Custom Domains). ### 6. Re-point payment webhooks Same drill as Vercel: in your payment provider's dashboard, change the webhook endpoint URL to the new Workers domain. Verify the signing secret you pushed via `wrangler secret put` matches the value the dashboard expects. ## Verify it works Identical checklist to the Vercel deploy: * `https://your-worker.workers.dev/` — home renders * `https://your-worker.workers.dev/api/ping` — `{ ok: true }` * `https://your-worker.workers.dev/sitemap.xml` — populated * A real \$1 test charge → `payment` row appears within 5s. Workers gives you per-request CPU-time metrics in the dashboard. If you see sustained > 50ms CPU times on simple pages, you have a misconfigured DB driver holding connections. ## Common pitfalls * **Long-lived DB connections.** The number-one mistake. Workers cannot hold a pool — every request is a new isolate, and TCP sockets vanish at the end. Use Neon's HTTP driver or Supabase's pgbouncer URL. Symptom: random 522 / 524 errors under load. * **Missing `nodejs_compat` flag.** Already present in the shipped config; if you removed it, webhook signature verification (`node:crypto`) explodes at runtime. * **Node-only deps.** Audit your dependencies with `pnpm dlx knip` before deploying. Anything that imports `fs`, `child_process`, `net`, or `worker_threads` will silently break. The scaffold itself is clean — third-party libs you add are the risk. * **KV consistency confusion.** Workers KV is *eventually* consistent. Don't use it as the source of truth for auth sessions or payment idempotency. Postgres remains the source of truth for everything ledger-shaped. * **Wrangler `vars` vs `secrets`.** `[vars]` are public (shipped in source). `wrangler secret put` is encrypted. Putting `STRIPE_SECRET_KEY` in `[vars]` is a wallet-emptying mistake. * **Deploy size limits.** Free Workers plan caps at 1 MB compressed. OpenNext hits this fast — bump to the Paid plan (\$5/mo, 10 MB) before you ship anything with images or fonts. ## Official docs * Cloudflare Workers: [developers.cloudflare.com/workers](https://developers.cloudflare.com/workers/) * OpenNext Cloudflare adapter: [opennext.js.org/cloudflare](https://opennext.js.org/cloudflare) * Wrangler CLI reference: [developers.cloudflare.com/workers/wrangler](https://developers.cloudflare.com/workers/wrangler/) * Neon serverless driver: [neon.tech/docs/serverless/serverless-driver](https://neon.tech/docs/serverless/serverless-driver) * Supabase pgbouncer: [supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler) # Deploy with Docker Source: https://docs.vibestrap.dev/deployment/docker Build, run, and ship Vibestrap as a Docker container — locally with compose, in production on any host. Vibestrap ships a production-grade container setup: a 3-stage Alpine `Dockerfile` (deps → builder → runner), a `docker-compose.yml` with Postgres + app, and a single release image (\~349MB, the standalone Next.js server with `dumb-init` as PID 1). The runtime image deliberately does **not** bundle `drizzle-kit` — migrations are a developer-driven step, run from your laptop against the production `DATABASE_URL`. That keeps the image lean and removes any race-condition risk during rolling updates. ## Prerequisites * **Docker 24+**. BuildKit is enabled by default and is required for the pnpm-store `--mount=type=cache` directive in the deps stage. * The Compose plugin (`docker compose version` should print a version). * For local development, that's it — compose ships its own Postgres 17. * For production, plan on a managed Postgres (Neon, Supabase, RDS, Crunchy, Railway). The bundled compose Postgres is dev-only — no backups, no HA. ## Quickstart: docker compose Local stack is two services — `postgres` and `app`. Migrations are not run by compose; you trigger them from the host with `pnpm db:push` (dev) or `pnpm db:migrate` (when you've checked in real migration files). ```bash theme={null} # 1. start Postgres on its own docker compose up -d postgres # 2. push the schema from the host (dev shortcut, no migration files) pnpm db:push # 3. boot the app docker compose up app ``` Open [localhost:3000](http://localhost:3000), sign up at `/register`, you're in. To stop and keep data: `docker compose down`. To wipe Postgres entirely: `docker compose down -v`. <Note> In dev `pnpm db:push` reflects `src/db/*.schema.ts` straight onto the local DB — no migration files needed. Production should always use `pnpm db:migrate` against committed migrations. </Note> ## Optional API keys The defaults in `docker-compose.yml` cover the bare minimum (auth + DB). To enable Stripe, Resend, OAuth providers, AI keys, etc., create a `.env.docker`: ```bash theme={null} cp .env.example .env.docker # edit .env.docker — fill in only what you actually want enabled ``` Compose reads it via the `env_file` directive (declared `required: false`, so a missing file doesn't error). Anything in `.env.docker` overrides the defaults in the `environment:` block. Restart with `docker compose up --build` to pick up changes. `.env.docker` is in both `.gitignore` and `.dockerignore` — it never ends up in an image. ## Building the production image One image per release: ```bash theme={null} docker build \ --build-arg NEXT_PUBLIC_APP_URL=https://your-domain.com \ --build-arg NEXT_PUBLIC_APP_NAME=your-product \ --target runner \ -t ghcr.io/your-org/vibestrap:v1.0.0 . ``` The `runner` target serves traffic and needs `NEXT_PUBLIC_*` baked in at build time (Next.js inlines those values into the client bundle). Push it to your registry and reference it by tag from your orchestrator (K8s manifests, Nomad job, ECS task, whatever). ## Why NEXT\_PUBLIC\_APP\_URL must be a build-arg This is the gotcha that trips everyone the first time. Next.js inlines every `NEXT_PUBLIC_*` value into the client JavaScript bundle **at build time** — they're not read from `process.env` in the browser, they're substituted as string literals before bundling. If you don't pass `--build-arg NEXT_PUBLIC_APP_URL=https://...` when building your production image, the default from the Dockerfile (`http://localhost:3000`) gets baked into the JS that ships to your users. Symptoms: * OAuth redirect URLs in the client point at `http://localhost:3000/...` * Absolute URLs in shareable / SEO metadata are localhost * Anything that calls `process.env.NEXT_PUBLIC_APP_URL` from a client component returns `http://localhost:3000` in production There is no runtime fix — you have to rebuild the image with the right build-arg. ## Running the production runner ```bash theme={null} docker run --env-file .env.production -p 3000:3000 \ ghcr.io/your-org/vibestrap:v1.0.0 ``` Minimum runtime env vars on the runner: | Var | Notes | | -------------------- | ------------------------------------------------------ | | `DATABASE_URL` | Pooled Postgres URL. `?sslmode=require` for managed. | | `BETTER_AUTH_SECRET` | 32+ random chars. `openssl rand -base64 32`. | | `BETTER_AUTH_URL` | Your public origin. OAuth callbacks fail without it. | | `ADMIN_EMAILS` | Comma-separated emails granted `role=admin` on signup. | Add provider keys (Stripe, Resend, OAuth, AI) as needed — see [`env-reference`](/env-reference) for the full list. ## Database migrations Migrations are intentionally **out of the runtime image**. The runner only contains what's needed to serve traffic — `drizzle-kit` lives in `devDependencies` and is invoked from your laptop: ```bash theme={null} DATABASE_URL='postgres://user:pass@prod-host:5432/db?sslmode=require' \ pnpm db:migrate ``` Run this **before** `docker run` / `kubectl apply` rolls out an image that depends on the new schema. Why this way: * The runtime image stays lean — no `drizzle-kit`, no migration files. * 1 replica means there's no race-condition risk; you control exactly when schema moves. * Cognitive cost is lower than wiring an init container or one-shot job for a scaffold that runs at this scale. <Warning> The migration step runs `drizzle-kit migrate` against committed migration files in `src/db/migrations/`. Generate them with `pnpm db:generate` first, commit the SQL, then run `pnpm db:migrate` against prod. </Warning> If your production DB is in a private VPC and your laptop can't reach it, see the kubernetes guide's "one-off pod" fallback — it spins up a temporary container inside the cluster with the right network access to run the same command. ## PaaS one-liners Railway, Render, Fly.io, Coolify, Dokploy — all auto-detect the root `Dockerfile`. The `runner` target is the last stage, so no extra config is needed for the build to land on the right image. Run `pnpm db:migrate` from your laptop (or a one-off container with the source repo) before promoting a release that touches schema. ## Multi-platform / Apple Silicon Building on an M-series Mac for an x86 Linux server needs `buildx`: ```bash theme={null} docker buildx create --use --name vibestrap-builder docker buildx build \ --platform linux/amd64,linux/arm64 \ --target runner \ --build-arg NEXT_PUBLIC_APP_URL=https://your-domain.com \ -t ghcr.io/your-org/vibestrap:v1.0.0 \ --push . ``` This produces a manifest list — Docker on each host pulls the variant matching its CPU arch. Skip this and an arm64-built image won't start on amd64. ## What changed under the hood Notable production-grade upgrades in the current setup: * **`dumb-init` as PID 1.** Properly forwards SIGTERM to the Node process so `docker stop` finishes in \~0.3s instead of waiting out the 10s grace timeout before SIGKILL. Critical for fast rolling deploys. * **BuildKit cache mount for the pnpm store** (`--mount=type=cache,id=pnpm-store`). CI builds drop from \~3min cold to \~90s on warm cache — the content-addressable pnpm store is reused across builds. * **Native-deps toolchain** (`libc6-compat`, `python3`, `make`, `g++`) in the deps stage. Avoids `npm install` failures on Alpine when packages compile from source (better-sqlite3, sharp variants, etc.). * **Build-time placeholders** for `DATABASE_URL` and `BETTER_AUTH_SECRET`. Scoped to the `pnpm build` RUN command so they don't persist in image layers — avoids the `SecretsUsedInArgOrEnv` linter warning while still letting `next build` pass module-level Zod (`@t3-oss/env-nextjs`) validation. * **`mkdir .next && chown nextjs:nodejs`** in the runner stage. Pre-creates the cache dir owned by the non-root user so prerender / image-optimization writes succeed at runtime, even on platforms with restricted PSPs. ## Common pitfalls * **Forgetting `--build-arg NEXT_PUBLIC_APP_URL`.** All client-side absolute URLs end up pointing at `http://localhost:3000` in production. No runtime fix — rebuild. * **Rolling out a new image before running `pnpm db:migrate`.** The new code may reference columns that don't exist yet — boot crashes with Zod or Drizzle errors. Always migrate first, then deploy. * **Mounting `.env` into `/app/.env` at runtime.** Next.js's standalone server doesn't read `.env` files at runtime. Pass env via `-e` or `--env-file` to `docker run`, or use your platform's secret store. * **Building on Apple Silicon for amd64 prod without `buildx`.** A plain `docker build` on M-series produces an arm64 image that won't start on x86 Linux. Use the multi-platform recipe above. ## Official docs * [Docker docs](https://docs.docker.com/) * [Docker Compose](https://docs.docker.com/compose/) * [Next.js Docker example (official)](https://github.com/vercel/next.js/tree/canary/examples/with-docker) * [Drizzle migrations](https://orm.drizzle.team/docs/migrations) # Deploy on Kubernetes Source: https://docs.vibestrap.dev/deployment/kubernetes Single-file production manifest with a CI workflow that builds one image, pushes to Harbor, and auto-bumps the manifest. The Kubernetes setup is intentionally minimal: one YAML file, one helper script, and one CI workflow. The whole thing fits on one screen, reads top to bottom, and is easy to extend later. ``` k8s/ ├── README.md └── prod/ ├── k8s-prod.yaml Namespace + Deployment + Service + Ingress ├── create-secrets.sh builds the `vibestrap-secrets` Secret from .env.prod └── .env.prod your production env (gitignored — see .env.example) ``` ## What ships in the manifest `k8s/vibestrap.yaml` defines four resources, in this order: 1. **Namespace** — `vibestrap` 2. **Deployment** — Next.js app, 1 replica, probes on `/api/ping`, sane resource requests/limits 3. **Service** — ClusterIP fronting the pods on port 80 → 3000 4. **Ingress** — nginx + cert-manager TLS, with `www → apex` redirect baked in A single image is pulled from Harbor: `harbor.funkro.com/vibestrap/vibestrap`. There is no in-cluster migration Job — see [Database migrations](#database-migrations) below. ## What the CI does for you `.github/workflows/docker-build-push.yml` runs on: * Every push to `main` * Every tag matching `v*` * Manual dispatch from the GitHub Actions UI On each run it: <Steps> <Step title="Builds the image"> The runner stage of the Dockerfile becomes `harbor.funkro.com/vibestrap/vibestrap:<version>`, also re-tagged as `:latest`. </Step> <Step title="Pushes to Harbor"> Authenticated with `HARBOR_USERNAME` + `HARBOR_PASSWORD` from GitHub Actions secrets. </Step> <Step title="Computes the version tag"> Tag pushes use the git tag (e.g. `v1.2.0`). Branch pushes use `main-<sha7>`. Both forms are immutable, unlike `:latest`. </Step> <Step title="Auto-bumps the manifest"> The workflow rewrites the `image:` line in `k8s/vibestrap.yaml` to point at the new version and commits back to `main` with `[skip ci]`. The manifest in git always reflects what is actually in the registry. </Step> </Steps> <Note> No `kubeconfig` lives in CI. The deploy itself is still your hands-on-keyboard step — by design. </Note> ### GitHub Actions secrets Add these once in **Settings → Secrets and variables → Actions**: | Secret | What goes in it | | ----------------- | ----------------------------------------- | | `HARBOR_USERNAME` | Your Harbor account username | | `HARBOR_PASSWORD` | Harbor password, or a robot account token | ## First-time setup Three things need to exist in the cluster before the first `kubectl apply`. ### 1. Harbor pull secret So the cluster can pull from your private registry: ```bash theme={null} kubectl create namespace vibestrap kubectl create secret docker-registry harbor-secret \ --namespace vibestrap \ --docker-server=harbor.funkro.com \ --docker-username='<YOUR_HARBOR_USERNAME>' \ --docker-password='<YOUR_HARBOR_PASSWORD>' ``` <Warning> K8s pull secrets are namespace-scoped — `harbor-secret` in `default` or any other namespace **cannot** be used by a pod in `vibestrap`. Always pass `--namespace vibestrap`. The most common deploy failure is `ImagePullBackOff` because this secret was created in the wrong place. </Warning> If you've already created `harbor-secret` for another app in this cluster, copy it into the `vibestrap` namespace instead of retyping credentials: ```bash theme={null} kubectl get secret harbor-secret -n <other-ns> -o yaml \ | sed '/namespace:/s/<other-ns>/vibestrap/; /resourceVersion:\|uid:\|creationTimestamp:/d' \ | kubectl apply -f - ``` ### 2. App secrets — only two values are required The runtime image refuses to start without `DATABASE_URL` and `BETTER_AUTH_SECRET`. **Every other env var is optional and no-ops gracefully when blank** — you can deploy first, configure features later by re-running the script and `kubectl rollout restart`. ```bash theme={null} cp .env.example k8s/.env # edit k8s/.env. ONLY two lines need real values: # DATABASE_URL=postgres://user:pass@host:5432/db # BETTER_AUTH_SECRET=<openssl rand -base64 32> # Leave the rest blank until you actually want that feature. ./k8s/create-secrets.sh ``` The helper script strips comments, blank lines, and accidental quotes (kubectl treats quotes as part of the value, which breaks Better Auth and Stripe SDKs), then runs `kubectl create secret generic ... --from-env-file` for you. The Deployment pulls every variable in via `envFrom: secretRef`. <Warning> `k8s/.env` is gitignored. Don't commit it. Re-run `create-secrets.sh` whenever you add or change a value. </Warning> ### 3. ingress-nginx + cert-manager The Ingress assumes `ingressClassName: nginx` and a `letsencrypt-prod` ClusterIssuer. If you don't have them yet: <CodeGroup> ```bash ingress-nginx theme={null} helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx --create-namespace ``` ```bash cert-manager theme={null} helm repo add jetstack https://charts.jetstack.io helm install cert-manager jetstack/cert-manager \ --namespace cert-manager --create-namespace \ --set installCRDs=true ``` </CodeGroup> Then create a `letsencrypt-prod` ClusterIssuer following the [cert-manager docs](https://cert-manager.io/docs/configuration/acme/). ## Database migrations Migrations are **not** run by the cluster. The runtime image doesn't include `drizzle-kit`, and there's no Job to babysit. Instead, before each release that touches schema, run from your laptop: ```bash theme={null} DATABASE_URL='postgres://user:pass@prod-host:5432/db?sslmode=require' \ pnpm db:migrate ``` Then `kubectl apply` the new image. With 1 replica there's no race-condition window — schema moves only when you tell it to. ### When prod DB is in a private VPC If your laptop can't reach the prod DB directly, spin up a one-off pod inside the cluster that has the right network access. The exact `kubectl run` command lives in `k8s/README.md`; the shape is: ```bash theme={null} kubectl run vibestrap-migrate --rm -it --restart=Never \ --namespace vibestrap \ --image=node:22-alpine \ --env="DATABASE_URL=$DATABASE_URL" \ -- sh -c "cd /tmp && git clone <repo> app && cd app && \ corepack enable && pnpm install && pnpm db:migrate" ``` This is the heavy fallback — only reach for it when you genuinely can't reach the DB from outside the cluster. ## Deploy Once the CI workflow has bumped the manifest, every deploy is: ```bash theme={null} git pull # only if the release includes a schema change DATABASE_URL='postgres://...' pnpm db:migrate kubectl apply -f k8s/vibestrap.yaml # watch the rollout kubectl rollout status deployment/vibestrap -n vibestrap ``` If you only changed env vars (no new code), refresh the Secret and roll the Deployment: ```bash theme={null} ./k8s/create-secrets.sh kubectl rollout restart deployment/vibestrap -n vibestrap ``` ## When you outgrow this The manifest is intentionally minimal — add these only when you actually need them. Each is small (10–30 lines) and orthogonal. | Add | When | Where | | --------------------------- | ---------------------------------------------------------------------------- | --------------------------------- | | `HorizontalPodAutoscaler` | Traffic varies enough that fixed replicas wastes money or starves under load | New file under `k8s/` | | `PodDisruptionBudget` | Running ≥3 replicas and you want zero-downtime node drains | New file | | `NetworkPolicy` | Multi-tenant cluster, want to restrict egress to DB and outbound APIs | New file | | `topologySpreadConstraints` | Multi-zone cluster, want zone-failure tolerance | Inline in the Deployment spec | | Multiple environments | When you actually run more than the `vibestrap` namespace | `k8s/staging/` parallel directory | ## Troubleshooting | Symptom | Likely cause | Check | | ------------------------------------------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------- | | `ImagePullBackOff` | Harbor credentials wrong, or `harbor-secret` missing in `vibestrap` namespace | `kubectl get secret harbor-secret -n vibestrap` | | Pod `CrashLoopBackOff` on first start | Missing required env (`DATABASE_URL`, `BETTER_AUTH_SECRET`, …) | `kubectl logs <pod> -n vibestrap` — Zod prints the missing key | | Pod boots but DB queries throw "column does not exist" | Forgot to run `pnpm db:migrate` before applying the new image | Run the migration from your laptop, then restart the Deployment | | TLS cert pending forever | DNS not pointing at the ingress LB, or ClusterIssuer missing | `kubectl describe certificate vibestrap-tls -n vibestrap` | | Stripe webhook 400 "invalid signature" | `STRIPE_WEBHOOK_SECRET` mismatched | Re-copy from Stripe, refresh secret, restart Deployment | ## Official docs * [Kubernetes docs](https://kubernetes.io/docs/) * [cert-manager](https://cert-manager.io/docs/) * [nginx-ingress](https://kubernetes.github.io/ingress-nginx/) * [Harbor](https://goharbor.io/docs/) # Deploy docs on Mintlify Source: https://docs.vibestrap.dev/deployment/mintlify Connect this repository to Mintlify, point docs.your-domain.com at it, ship in under an hour. This site you're reading right now is on Mintlify. The configuration and the MDX content both live under the `docs/` directory in the repo: `docs/docs.json` is the config, `docs/<page>.mdx` is the English content, and `docs/zh/<page>.mdx` is the Chinese mirror. When you fork Vibestrap, replicating this setup for your own product is about 30 minutes of clicks plus a DNS propagation wait. ## Prerequisites * A GitHub repository for your fork of Vibestrap. * A domain you own (e.g. `your-product.com`) — the docs will live at `docs.your-product.com`. * Access to your DNS provider's dashboard. ## What ships in the repo already The Vibestrap repo is pre-wired for Mintlify, so you don't have to set this up from scratch: * `docs/docs.json` — Mintlify's config file. Defines name, theme, colors, navigation, footer, navbar links, and SEO defaults. * `docs/*.mdx` and `docs/<group>/*.mdx` — 36 English pages organized into 9 navigation groups. * `docs/zh/...` — Chinese mirror of every English page. * `docs/logo/{light,dark}.svg` and `docs/favicon.svg` — placeholder branding you should replace with your own. You'll edit these as you go — don't worry about them yet. ## Step 1: Sign up for Mintlify 1. Go to [mintlify.com/start](https://mintlify.com/start). 2. Sign up with the GitHub account that owns your fork. 3. Pick the **Hobby** plan — it's free and supports custom domains. ## Step 2: Connect your repository 1. In the Mintlify dashboard, click **Connect GitHub**. 2. Install the **Mintlify GitHub App** when prompted. Scope it to just your Vibestrap fork (you can add more repos later). 3. Select the repository. ## Step 3: Configure the source In the Mintlify dashboard go to **Settings → Git settings** and set: * **Repository**: `<your-org>/vibestrap` * **Branch**: `main` * **Subdirectory**: `docs` ← this is the critical one. Mintlify reads `docs/docs.json` as the config and treats every page slug as relative to that subdirectory. Save. Mintlify will trigger the first build. ## Step 4: Verify the preview build 1. Watch the **Overview → Deployments** page in the dashboard. The first build typically takes 1–2 minutes. 2. When it finishes, click the preview URL — something like `your-org.mintlify.app`. 3. Open it. You should see the full Vibestrap docs site with the navigation sidebar, language switcher, and search bar. If the build fails, the dashboard shows a Zod / parsing error pointing at the file. The most common causes: * A page listed in `docs/docs.json → navigation` doesn't exist on disk. * An MDX file uses `<https://example.com>` autolink syntax (Mintlify rejects it — use `[example.com](https://example.com)` instead). * Frontmatter is missing `title` or `description`. ## Step 5: Custom domain 1. In Mintlify dashboard → **Settings → Custom domain**. 2. Enter `docs.your-product.com`. 3. Mintlify shows the DNS record you need to add. It looks like: ``` Type Host Value CNAME docs cname.mintlify-dns.com. ``` 4. Add that CNAME record at your DNS provider (Cloudflare, Vercel DNS, Namecheap, Route53, etc.). 5. Wait. Propagation usually takes 1–24 hours. Mintlify auto-provisions an SSL certificate via Let's Encrypt once DNS resolves. When `docs.your-product.com` resolves, Mintlify will redirect the `*.mintlify.app` URL to your custom domain. ## Step 6: Update Vibestrap's links Edit `src/config/site.ts` so the marketing app links to your real docs URL: ```ts theme={null} links: { // ... docs: 'https://docs.your-product.com', } ``` This single change updates the header, footer, refund page, and terms page — every link to the docs in the marketing site uses `siteConfig.links.docs`. Commit and push. Vercel re-deploys the marketing site, and your "Docs" nav link now opens your branded Mintlify domain. ## Editing flow afterwards * Every push to `main` that touches `docs/` or `docs/docs.json` triggers a Mintlify re-deploy automatically. There's no extra CI step. * Local preview: `pnpm dlx mintlify dev` from the repo root. The CLI reads `docs/docs.json` and serves at `localhost:3000` — same port as `pnpm dev`, so don't run both at once. * Bilingual content: any new page must be created in **both** `docs/<path>.mdx` and `docs/zh/<path>.mdx`. Add the slug to **both** language groups in `docs/docs.json`. Mintlify won't auto-link them. ## Branding checklist Replace the placeholder Vibestrap branding with yours: 1. **Logo**: edit `docs/logo/light.svg` and `docs/logo/dark.svg`. Keep the 220×48 viewBox so the navbar height stays consistent. 2. **Favicon**: replace `docs/favicon.svg`. 3. **Theme color**: edit `docs/docs.json → colors.primary` (hex). 4. **Site name**: edit `docs/docs.json → name`. 5. **Navbar links + primary CTA**: edit `docs/docs.json → navbar`. 6. **Footer**: edit `docs/docs.json → footer.socials` and `footer.links`. 7. **SEO**: edit `docs/docs.json → seo.metatags`. ## Common pitfalls 1. **Wrong source directory**: if the dashboard shows "no pages found", double-check the **Source directory** setting is `docs`, not `/` or `content/docs`. 2. **Page in `docs/` but not in `docs/docs.json`**: the page renders but is orphaned (no sidebar entry, no search hit). Add it to the matching language group in `docs/docs.json → navigation.languages`. 3. **Page in `docs/docs.json` but not in `docs/`**: the build fails. Either create the file or remove the slug from the navigation. 4. **Custom-domain TLS pending**: after adding the CNAME, Mintlify shows a "TLS provisioning" badge for up to 24 hours. If still pending after that, re-check the CNAME record matches exactly (no extra dot, no trailing slash, target is `cname.mintlify-dns.com.`). 5. **Mixing English and Chinese pages in one group**: Mintlify treats `navigation.languages[i].groups` strictly — only put `en/*` pages under the `en` language entry, only `zh/*` under the `zh` entry. Cross-language links should be plain markdown links, not navigation entries. ## Official docs * [mintlify.com/docs](https://mintlify.com/docs) — full Mintlify reference * [docs.json schema reference](https://mintlify.com/docs/organize/settings) — every configuration option * [Navigation](https://mintlify.com/docs/organize/navigation) — group, tab, anchor, dropdown, language structures * [Internationalization](https://mintlify.com/docs/guides/internationalization) — multilingual setup * [Custom domain](https://mintlify.com/docs/customize/custom-domain) — DNS records and SSL provisioning * [Components](https://mintlify.com/docs/components) — built-in MDX components (Tabs, Steps, Callouts, Cards, etc.) # Deploy to Vercel Source: https://docs.vibestrap.dev/deployment/vercel Five-minute Vercel deploy — push, set env, hit deploy. Vercel is the path of least resistance. The repo's preset matches Vercel's defaults (Next.js 15, pnpm, Node runtime), so a fresh project import "just works" once env vars are in place. Plan on 5 minutes from `git push` to live URL — the bulk of the time is pasting secrets and creating the database. ## Prerequisites * A Postgres database with a **pooled** connection string. Neon, Supabase, Railway, Crunchy — any of them. Serverless functions don't keep connections warm, so the pooler URL (port 6543 on Supabase, the `-pooler` host on Neon) is required. * A `BETTER_AUTH_SECRET` of 32+ random characters: `openssl rand -base64 32`. * Your active payment provider's keys. Whichever provider you set in `siteConfig.payment.provider` needs its `*_SECRET_KEY`, `*_WEBHOOK_SECRET` and the relevant `*_PRICE_*` ids populated. * Optional but recommended: a `RESEND_API_KEY` for transactional email. Without one, the mail facade no-ops gracefully and signups skip the verification step. * A GitHub repo Vercel can read. SSO, monorepo, fork — all fine. Run `pnpm typecheck && pnpm lint && pnpm build` locally before pushing. CI runs the same three; Vercel will refuse a broken build at the same gate. ## Step-by-step ### 1. Push to GitHub ```bash theme={null} git push origin main ``` If you keep a private repo, grant Vercel access in the GitHub app settings. ### 2. Import on Vercel In the Vercel dashboard: Add New → Project → pick the repo. The Framework Preset auto-resolves to **Next.js**. Leave the build command blank (Vercel reads `pnpm build` from `package.json`). Output directory is `.next` (default). ### 3. Set env vars Settings → Environment Variables. The **required** baseline is: ``` DATABASE_URL=postgres://user:pass@host:6543/db?sslmode=require BETTER_AUTH_SECRET=<32+ random chars> BETTER_AUTH_URL=https://your-domain.com NEXT_PUBLIC_APP_URL=https://your-domain.com ADMIN_EMAILS=you@example.com ``` Add your **active** payment provider's keys (Stripe shown): ``` STRIPE_SECRET_KEY=sk_live_… STRIPE_WEBHOOK_SECRET=whsec_… STRIPE_PRICE_VIBESTRAP_PROMO=price_… NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_… ``` Optional but common: `RESEND_API_KEY`, `GOOGLE_CLIENT_ID/SECRET`, `TURNSTILE_SECRET_KEY` + `NEXT_PUBLIC_TURNSTILE_SITE_KEY`. See [Env reference](/env-reference) for the full list. ### 4. Migrate the production database **Before** the first deploy, sync your schema. You have two options: ```bash theme={null} # Option A — fastest, dev-only style. Diffs schema against DB and applies. DATABASE_URL=<prod-url> pnpm db:push ``` ```bash theme={null} # Option B — committed migrations. Use this once you have real data. pnpm db:generate # writes a SQL file under drizzle/ git add drizzle/ && git commit DATABASE_URL=<prod-url> pnpm db:migrate ``` Once you have paying users, **never** run `db:push` against production again — it can drop columns silently. Switch to Option B and run `db:migrate` from a deploy hook or a one-shot script, not from your running app. ### 5. Configure payment webhooks In your payment provider's dashboard, add a webhook endpoint pointing at: ``` https://your-domain.com/api/webhooks/stripe https://your-domain.com/api/webhooks/creem https://your-domain.com/api/webhooks/nowpayments ``` Pick the route matching your active provider. Subscribe to: `checkout.session.completed`, `invoice.paid`, `customer.subscription.updated`, `customer.subscription.deleted` (Stripe naming — adapt for others). Copy the signing secret into `STRIPE_WEBHOOK_SECRET` (or equivalent). ### 6. Deploy Push or click Deploy. The first build takes 2-3 minutes. Vercel's logs surface env-var errors loudly — `src/env.ts` throws at startup if anything required is missing. ## Verify it works Hit each URL once. Anything red means something's misconfigured. * `https://your-domain.com/` — home renders, hero copy is your locale * `https://your-domain.com/zh/` — Chinese hero * `https://your-domain.com/api/ping` — returns `{ ok: true }` * `https://your-domain.com/sitemap.xml` — every public route listed * `https://your-domain.com/robots.txt` — disallows `/admin`, `/api/`, `/settings` * `https://your-domain.com/register` — signup works, you receive the verification email Then run a real \$1 test charge through your live keys (refund yourself afterwards): the `payment` and `credit_transaction` rows should both appear within \~5 seconds of completing checkout. If they don't, check the webhook logs in your payment dashboard for 4xx responses. ## Common pitfalls * **`BETTER_AUTH_URL` mismatch.** It must be the full prod URL with scheme, no trailing slash. Auth callbacks silently fail when this drifts from the actual domain. * **Missing env vars in prod.** Vercel separates Production / Preview / Development scopes. Set vars to "All Environments" unless you know you need per-env values. * **Webhook signing-secret mismatch.** A stale `STRIPE_WEBHOOK_SECRET` returns 400 on every event. Rotate the secret in the dashboard, paste it into Vercel, redeploy. * **Postgres connection limits.** Serverless functions burn connections fast. Use the pooler URL (port 6543 on Supabase, `-pooler.region` host on Neon). Hit "max connections" errors? You're using the direct URL. * **Forgotten `NEXT_PUBLIC_APP_URL`.** Used by sitemap, OG images, OAuth redirects, payment success URLs. Must match `BETTER_AUTH_URL` in production. * **Skipping `db:push` before first deploy.** App boots, then 500s on the first query because the tables don't exist. ## Rollback Vercel keeps every deployment forever. Promote any previous build from the Deployments list — instant revert. If a migration is the culprit, restore from your DB provider's point-in-time snapshot (Neon and Supabase both ship this on every plan). ## Official docs * Vercel Next.js guide: [vercel.com/docs/frameworks/nextjs](https://vercel.com/docs/frameworks/nextjs) * Env vars: [vercel.com/docs/projects/environment-variables](https://vercel.com/docs/projects/environment-variables) * Cron jobs (for credit-expiry sweeps): [vercel.com/docs/cron-jobs](https://vercel.com/docs/cron-jobs) * Drizzle migrations: [orm.drizzle.team/docs/migrations](https://orm.drizzle.team/docs/migrations) # Environment variables Source: https://docs.vibestrap.dev/env-reference Every env var the scaffold reads, what it does, and whether you can skip it. The scaffold validates env vars at startup with [@t3-oss/env-nextjs](https://env.t3.gg) and Zod. The full schema lives in `src/env.ts` — open it for the source of truth. This page mirrors that file, organized by category, so you can copy-paste your way to a working `.env.local` (and prod env) in one pass. A few conventions: * **Required** vars throw at startup if missing or empty. * **Optional** vars default to empty string and the consuming module no-ops gracefully. * `NEXT_PUBLIC_*` vars are **shipped to the browser**. Never put secrets in them. * Set `SKIP_ENV_VALIDATION=true` to bypass validation in CI build steps that don't need real values (Vercel preview builds, Docker image bakes, etc.). ## Core The bare minimum for the app to boot. | Variable | Required | Default | Description | | ---------------------- | -------- | ----------------------- | ---------------------------------------------------------------------------------------- | | `NODE_ENV` | no | `development` | `development` / `production` / `test`. Set automatically by your runtime. | | `DATABASE_PROVIDER` | no | `postgres` | Reserved for future drivers. Only `postgres` is supported today. | | `DATABASE_URL` | **yes** | — | Postgres connection string. Use the **pooled** URL in production. | | `BETTER_AUTH_SECRET` | **yes** | — | 16+ char secret for session signing. Generate: `openssl rand -base64 32`. | | `BETTER_AUTH_URL` | no | — | Full URL (with scheme) of your deployed app. Required in production for OAuth callbacks. | | `NEXT_PUBLIC_APP_URL` | no | `http://localhost:3000` | Used by sitemap, OG images, OAuth redirects. Set to your prod URL. | | `NEXT_PUBLIC_APP_NAME` | no | `Vibestrap` | Display name in titles, OG metadata, emails. | | `ADMIN_EMAILS` | no | `''` | Comma-separated list of admin emails. Auto-promoted to `role: 'admin'` on signup. | ## OAuth Each provider is independent — set both id + secret to enable, leave blank to hide. | Variable | Required | Default | Description | | ------------------------------ | -------- | ------- | ----------------------------------------------------------------------------- | | `GOOGLE_CLIENT_ID` | no | `''` | Google OAuth client id. Enables "Sign in with Google". | | `GOOGLE_CLIENT_SECRET` | no | `''` | Google OAuth secret (server-only). | | `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | no | `''` | Mirror of `GOOGLE_CLIENT_ID`. Needed for client-side One-Tap. Safe to expose. | | `GITHUB_CLIENT_ID` | no | `''` | GitHub OAuth app id. Enables "Sign in with GitHub". | | `GITHUB_CLIENT_SECRET` | no | `''` | GitHub OAuth secret. | ## Mail Resend is the default. The mail facade no-ops gracefully if `RESEND_API_KEY` is empty. | Variable | Required | Default | Description | | ----------------------- | -------- | ----------------------- | ------------------------------------------------------------------------------- | | `RESEND_API_KEY` | no | `''` | Resend API key (`re_…`). Powers verification + welcome + reset-password emails. | | `RESEND_FROM_EMAIL` | no | `onboarding@resend.dev` | `From:` address. Switch to your verified domain in production. | | `RESEND_REPLY_TO_EMAIL` | no | `''` | Optional `Reply-To:` header. | | `RESEND_AUDIENCE_ID` | no | `''` | Resend Audience id for newsletter (when `newsletter.provider = 'resend'`). | ## Newsletter — Beehiiv Only used if `siteConfig.newsletter.provider = 'beehiiv'`. | Variable | Required | Default | Description | | ------------------------ | -------- | ------- | ------------------------- | | `BEEHIIV_API_KEY` | no | `''` | Beehiiv v2 API key. | | `BEEHIIV_PUBLICATION_ID` | no | `''` | Publication id (`pub_…`). | ## Payments — Stripe Default provider. Only the `*_PRICE_*` ids you actually sell need to be set. | Variable | Required | Default | Description | | ------------------------------------ | --------------- | ------- | --------------------------------------------------- | | `STRIPE_SECRET_KEY` | yes (if active) | `''` | `sk_test_…` / `sk_live_…`. | | `STRIPE_WEBHOOK_SECRET` | yes (if active) | `''` | `whsec_…` from the Stripe dashboard webhook config. | | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | no | `''` | `pk_…`. Needed for Stripe.js / Elements. | | `STRIPE_PRICE_VIBESTRAP_PROMO` | no | `''` | Price id for the Vibestrap promo tier (\$49). | | `STRIPE_PRICE_VIBESTRAP_STANDARD` | no | `''` | Price id for the Vibestrap standard tier (\$99). | | `STRIPE_PRICE_PRO_MONTHLY` | no | `''` | Demo Pro plan, monthly recurring. | | `STRIPE_PRICE_PRO_YEARLY` | no | `''` | Demo Pro plan, yearly recurring. | ## Payments — Creem Only used if `siteConfig.payment.provider = 'creem'`. | Variable | Required | Default | Description | | -------------------------------- | --------------- | ------- | --------------------------- | | `CREEM_API_KEY` | yes (if active) | `''` | Creem API key. | | `CREEM_WEBHOOK_SECRET` | yes (if active) | `''` | Webhook signing secret. | | `CREEM_PRICE_VIBESTRAP_PROMO` | no | `''` | Price id for promo tier. | | `CREEM_PRICE_VIBESTRAP_STANDARD` | no | `''` | Price id for standard tier. | ## AI providers Set `AI_PROVIDER` to switch between providers. The `mock` provider streams fake tokens without keys, so demo pages run offline out of the box. | Variable | Required | Default | Description | | --------------------- | -------- | ------------------------------ | --------------------------------------------------------------------- | | `AI_PROVIDER` | no | `mock` | `mock` / `openrouter` / `openai` / `anthropic` / `replicate` / `fal`. | | `OPENROUTER_API_KEY` | no | `''` | OpenRouter key (gateway to most LLMs). | | `OPENROUTER_BASE_URL` | no | `https://openrouter.ai/api/v1` | Override only if self-hosting a gateway. | | `OPENAI_API_KEY` | no | `''` | OpenAI key (`sk-…`). | | `OPENAI_BASE_URL` | no | `https://api.openai.com/v1` | Override for Azure OpenAI or compat endpoints. | | `ANTHROPIC_API_KEY` | no | `''` | Anthropic key (`sk-ant-…`). | | `ANTHROPIC_BASE_URL` | no | `https://api.anthropic.com` | Override for Bedrock / proxy. | | `REPLICATE_API_TOKEN` | no | `''` | Replicate API token for image / audio models. | | `FAL_KEY` | no | `''` | fal.ai key for fast image generation. | ## Storage (S3 / R2) Stub by default — wire only if you need uploads or signed downloads. | Variable | Required | Default | Description | | ---------------------- | -------- | ------- | ---------------------------------------------------- | | `S3_ENDPOINT` | no | `''` | Endpoint URL. AWS S3, Cloudflare R2, MinIO all work. | | `S3_REGION` | no | `''` | Bucket region. R2 uses `auto`. | | `S3_ACCESS_KEY_ID` | no | `''` | Access key id. | | `S3_SECRET_ACCESS_KEY` | no | `''` | Secret access key. | | `S3_BUCKET` | no | `''` | Bucket name. | | `S3_PUBLIC_URL` | no | `''` | Public CDN base URL for served objects. | ## Anti-bot — Cloudflare Turnstile Set both keys to enable Turnstile on signup, forgot-password, and newsletter forms. | Variable | Required | Default | Description | | -------------------------------- | -------- | ------- | -------------------------------- | | `TURNSTILE_SECRET_KEY` | no | `''` | Server-side verification secret. | | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | no | `''` | Public site key for the widget. | ## Customer service One widget at a time, picked by `siteConfig.customerService.provider`. Each widget self-gates on its env vars. | Variable | Required | Default | Description | | ------------------------------------ | -------- | -------------------------- | --------------------------------------------- | | `NEXT_PUBLIC_CRISP_WEBSITE_ID` | no | `''` | Crisp website id (UUID). | | `NEXT_PUBLIC_TAWK_PROPERTY_ID` | no | `''` | tawk.to property id. | | `NEXT_PUBLIC_TAWK_WIDGET_ID` | no | `''` | tawk.to widget id. | | `NEXT_PUBLIC_INTERCOM_APP_ID` | no | `''` | Intercom workspace id. | | `NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN` | no | `''` | Chatwoot website token. | | `NEXT_PUBLIC_CHATWOOT_BASE_URL` | no | `https://app.chatwoot.com` | Self-hosted Chatwoot URL if you run your own. | ## Affiliate Set the matching var(s) for whichever provider is active in `siteConfig.affiliate.provider`. The `internal` provider needs no env vars. | Variable | Required | Default | Description | | -------------------------------- | -------- | ------- | ------------------------- | | `NEXT_PUBLIC_AFFONSO_PROGRAM_ID` | no | `''` | Affonso program id. | | `NEXT_PUBLIC_REWARDFUL_API_KEY` | no | `''` | Rewardful public API key. | ## Analytics Each script renders only when its env var is set. Mix and match freely. | Variable | Required | Default | Description | | --------------------------------- | -------- | -------------------------- | --------------------------------------------------------- | | `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` | no | `''` | GA4 measurement id (`G-…`). | | `NEXT_PUBLIC_POSTHOG_KEY` | no | `''` | PostHog project key. | | `NEXT_PUBLIC_POSTHOG_HOST` | no | `https://us.i.posthog.com` | EU users: `https://eu.i.posthog.com`. | | `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | no | `''` | Plausible site domain. | | `NEXT_PUBLIC_PLAUSIBLE_HOST` | no | `https://plausible.io` | Self-hosted Plausible URL if applicable. | | `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | no | `''` | Umami website id. | | `NEXT_PUBLIC_UMAMI_HOST` | no | `https://cloud.umami.is` | Self-hosted Umami URL if applicable. | | `NEXT_PUBLIC_CLARITY_PROJECT_ID` | no | `''` | Microsoft Clarity project id (heatmaps + session replay). | ## Search-engine site verification Each emits a `<meta>` tag in `<head>` via Next.js `metadata.verification`, proving ownership in the matching webmaster console. Server-side env vars (no `NEXT_PUBLIC_` prefix). Empty values render no tag. | Variable | Required | Default | Description | | -------------------------- | -------- | ------- | ----------------------------------------------------------------------------------- | | `GOOGLE_SITE_VERIFICATION` | no | `''` | Google Search Console — `content` value of the `google-site-verification` meta tag. | | `BING_SITE_VERIFICATION` | no | `''` | Bing Webmaster Tools — `content` value of the `msvalidate.01` meta tag. | | `YANDEX_SITE_VERIFICATION` | no | `''` | Yandex Webmaster — `content` value of the `yandex-verification` meta tag. | ## GitHub invite delivery Token used by the buyer flow to add a paid customer as a read-only collaborator on your private source repo. See [GitHub invite delivery](/operations/github-invite-delivery) for the full setup. | Variable | Required | Default | Description | | --------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------- | | `GITHUB_INVITE_TOKEN` | no | `''` | Fine-grained PAT scoped to one repo with Metadata:read + Administration:write. Empty = invite UI disables itself. | ## Misc | Variable | Required | Default | Description | | ----------------------- | -------- | ------- | --------------------------------------------------------------------------- | | `NEXT_PUBLIC_DEMO_MODE` | no | `false` | Set `true` to hide real payment + auth and show the read-only demo overlay. | ## See also * [Vercel deployment](/deployment/vercel) — where to paste these in production. * [Cloudflare deployment](/deployment/cloudflare) — `wrangler secret put` for each secret. * [Configuration](/configuration) — `siteConfig` toggles that switch which providers read which keys. # Going live Source: https://docs.vibestrap.dev/going-live Step-by-step from a fresh clone to a live commercial site — every credential, every dashboard URL, every env var name. This is the "I cloned Vibestrap, now what do I actually have to do to put real money in my bank account" guide. It covers every external service you'll touch, every dashboard URL, and every env var name — in the order that makes sense. ## Mental model Vibestrap follows a **fill-what-you-use** model. The app boots with **two** real values (`DATABASE_URL` + `BETTER_AUTH_SECRET`); everything else is optional and gracefully no-ops when blank. Don't have a Stripe account yet? The pricing page just hides the checkout button. No Resend key? Password reset still renders, just doesn't send. But here's the trap most people fall into: <Warning> **The app booting is not the same as the app working.** Zod validates env-var *structure* (`min(1)`, `.url()`, etc.) — not *semantics*. A pod can be `1/1 Running` while the website returns 500 because the placeholder `DATABASE_URL` doesn't point at a real database. So when you set things up, **test the actual user flows** (sign up, log in, checkout) — don't trust just `kubectl get pods`. </Warning> ### The 3 config layers Vibestrap's configuration lives in three places, each for a reason: | Layer | Lives in | What it controls | | ----------------- | ------------------------------------ | ----------------------------------------- | | Brand & product | `src/config/site.ts` | Name, copy, pricing display, social links | | Translations | `messages/{en,zh}.json` | All user-facing strings | | Secrets & toggles | `k8s/.env` (or `.env.local` for dev) | API keys, DB URLs, provider switches | This guide is about the third layer. For the other two see [Configuration](/configuration) and [Customization](/customization). ### Where you'll edit values The flow on a live cluster: ```bash theme={null} # 1. edit k8s/.env on your laptop (gitignored — never committed) vim k8s/.env # 2. push the env file into the cluster as a Secret ./k8s/create-secrets.sh # 3. pods don't auto-reload secrets — restart the deployment kubectl rollout restart deployment/vibestrap -n vibestrap ``` For local dev you put the same names in `.env.local` and `pnpm dev` picks them up automatically — no restart dance. ## Required: the 4 values without which the site shows 500 Skip any of these and the site won't render. Do these first. <Steps> <Step title="DATABASE_URL — your Postgres"> **Why**: every page that reads or writes data goes through this. Without a real DB, every non-static route returns 500. The marketing homepage might load; `/login`, `/register`, `/pricing`, `/admin` will not. **Where to sign up**: [neon.tech](https://neon.tech) — serverless Postgres, generous free tier, indie-friendly. (Alternatives: Supabase, Railway, RDS — anything that speaks Postgres 15+.) <Steps> <Step title="Create the project"> 1. Sign up at [https://neon.tech](https://neon.tech) 2. Create a new project — pick a region close to your K8s cluster (e.g. `us-east-2` if your nodes are there) 3. Wait \~10 seconds for provisioning to finish </Step> <Step title="Grab the connection string"> 1. In the left sidebar, click **Connection Details** 2. **Important**: select **Pooled connection** (handles serverless and burst traffic way better than direct) 3. Copy the URL it shows 4. Make sure it ends with `?sslmode=require` — Neon enforces TLS, your app will fail to connect without it </Step> <Step title="Paste into k8s/.env"> ```bash theme={null} DATABASE_URL=postgres://neondb_owner:abc...@ep-xxx-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require ``` </Step> </Steps> | Variable | Example | | -------------- | ------------------------------------------------------------------------------------------ | | `DATABASE_URL` | `postgres://neondb_owner:...@ep-xxx-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require` | <Warning> **First-time setup requires a manual database step.** The runtime image doesn't auto-apply migrations — same model as ShipAny / mksaas. Right after you set `DATABASE_URL`, run **one** of these from your laptop against the production URL: ```bash theme={null} # Recommended for production — applies the SQL files in # src/db/migrations/ in order, tracks history in __drizzle_migrations. DATABASE_URL='postgres://...prod...' DATABASE_PROVIDER=postgres pnpm db:migrate # Or for the absolute fastest path (no migration history kept). DATABASE_URL='postgres://...prod...' DATABASE_PROVIDER=postgres pnpm db:push ``` Skip this and the app will boot but every page that hits the DB returns 500 with `relation "user" does not exist`. Each schema change in the future is the same flow: `pnpm db:generate` to write a new migration file, commit it, then `pnpm db:migrate` against the prod URL before applying the new image. </Warning> </Step> <Step title="BETTER_AUTH_SECRET — auth signing key"> **Why**: signs session cookies and JWT tokens. If it's left as the placeholder, anyone who reads the public template repo can forge sessions for any user on your site. This is a security hole that would end your business on day one. **Where to sign up**: nowhere — generate it on your laptop. <Steps> <Step title="Generate"> ```bash theme={null} openssl rand -base64 32 ``` Output is 44 chars and ends with `=`. </Step> <Step title="Paste into k8s/.env"> ```bash theme={null} BETTER_AUTH_SECRET=S8sFO3lhBaqfDD0p308BqjBNQ6TssHA9i2p4JoMYSb4= ``` </Step> </Steps> | Variable | Example | | -------------------- | ---------------------------------------------- | | `BETTER_AUTH_SECRET` | `S8sFO3lhBaqfDD0p308BqjBNQ6TssHA9i2p4JoMYSb4=` | <Warning> Never commit this. Never reuse the same secret across staging and prod. Never paste it in chat or screenshots. If it leaks, regenerate it — every user gets logged out, but that's the only safe move. </Warning> </Step> <Step title="NEXT_PUBLIC_APP_URL — your domain"> **Why**: this value gets baked into the **client bundle** at build time. It's used for OG images, sitemap URLs, OAuth redirect URIs, and links inside emails. If it points at `localhost`, every shared link will be broken. | Variable | Example | | --------------------- | ----------------------- | | `NEXT_PUBLIC_APP_URL` | `https://vibestrap.dev` | <Note> Must be the production https URL. Don't include a trailing slash. Don't use `http://`. </Note> </Step> <Step title="BETTER_AUTH_URL — auth callback root"> **Why**: Better Auth's OAuth callback signature relies on this. If wrong, sign-in callbacks reject as "invalid origin" and users see a confused error page. In 99% of cases this should be the same as `NEXT_PUBLIC_APP_URL`. The only reason to split them is if your auth lives on a different subdomain. | Variable | Example | | ----------------- | ----------------------- | | `BETTER_AUTH_URL` | `https://vibestrap.dev` | </Step> </Steps> After these four, your pod boots and the homepage renders. Now make it actually useful. ## Strongly recommended: the things buyers expect to work Without these, the site loads but key features are broken: users can't recover passwords, can't sign in via Google or GitHub, can't pay you. Set them up before launch — not after the first user complaint. ### Resend (transactional email) **Why**: password reset, email verification, welcome emails. Without it, the "forgot password" button is purely decorative. **Where to sign up**: [resend.com](https://resend.com) — free tier covers 100 emails per day, 3,000 per month. Plenty for early launch. <Steps> <Step title="Sign up + verify your own email"> Standard signup flow. </Step> <Step title="Add and verify your domain"> 1. Left sidebar → **Domains** → **Add Domain** 2. Enter your domain (e.g. `vibestrap.dev`) 3. Resend shows 3 DNS records (SPF, DKIM, DMARC) — **don't close the page** 4. Add those DNS records at your registrar (Cloudflare, Namecheap, GoDaddy, …) 5. Click **Verify DNS Records**; wait 5–30 min until status shows **Verified** </Step> <Step title="Create an API key"> 1. Left sidebar → **API Keys** → **Create API Key** 2. Permission: "Sending access" is enough 3. Copy the `re_xxx` key — it's only shown once </Step> </Steps> | Variable | Where it comes from | Example | | ----------------------- | --------------------------------- | -------------------------------------------- | | `RESEND_API_KEY` | Resend → API Keys | `re_a1b2c3d4...` | | `RESEND_FROM_EMAIL` | Any email at your verified domain | `Larry from Vibestrap <hello@vibestrap.dev>` | | `RESEND_REPLY_TO_EMAIL` | Inbox you actually read | `larry@vibestrap.dev` | <Note> The human-readable name in `RESEND_FROM_EMAIL` (e.g. `Larry from Vibestrap`) is optional, but it noticeably improves deliverability — bare `hello@…` looks like a bot. </Note> ### Admin access You'll want to reach `/admin` yourself. | Variable | Example | | -------------- | ----------------------------------------------- | | `ADMIN_EMAILS` | `wisehackerlarry@gmail.com,partner@example.com` | Comma-separated. Matched against the user's email at sign-in time. The first matching user gets the `admin` role on first login and from then on can access `/admin`. ### Google OAuth **Why**: roughly 60% of indie devs prefer "sign in with Google" over typing a password. The conversion lift on a signup form with this button alone is huge. **Where to set up**: [console.cloud.google.com](https://console.cloud.google.com) <Steps> <Step title="Pick or create a Google Cloud project"> Top-left dropdown → **New Project**, or pick an existing one. </Step> <Step title="Configure the OAuth consent screen"> 1. Left menu → **APIs & Services** → **OAuth consent screen** 2. User type: **External** → Create 3. Fill **App name**, **User support email**, **Developer contact email** 4. Save and continue through the scopes / test users screens (defaults are fine) </Step> <Step title="Create the OAuth client"> 1. Left menu → **APIs & Services** → **Credentials** 2. **+ Create Credentials** → **OAuth 2.0 Client ID** 3. Application type: **Web application** 4. Name: anything (e.g. "Vibestrap") 5. **Authorized JavaScript origins**: `https://vibestrap.dev` 6. **Authorized redirect URIs**: `https://vibestrap.dev/api/auth/callback/google` (this exact path — Better Auth uses it) 7. Click **Create** → modal shows Client ID + Client Secret → copy both immediately </Step> </Steps> | Variable | Example | | ------------------------------ | ----------------------------------------------------------------- | | `GOOGLE_CLIENT_ID` | `1234567890-abc...apps.googleusercontent.com` | | `GOOGLE_CLIENT_SECRET` | `GOCSPX-abcdef...` | | `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | same as `GOOGLE_CLIENT_ID` (mirror, used for client-side One-Tap) | <Warning> The redirect URI must be byte-for-byte exact: `https` vs `http`, trailing slash, capitalization, all matter. Most "OAuth doesn't work" reports trace back to a typo here. If you ever change your domain, update this URI on the same day. </Warning> ### GitHub OAuth **Why**: Vibestrap targets developers, and "sign in with GitHub" converts \~80% of devs who land on your signup page. Worth 5 minutes of setup. **Where to set up**: [github.com/settings/developers](https://github.com/settings/developers) → **OAuth Apps** → **New OAuth App** <Steps> <Step title="Fill the form"> * **Application name**: Vibestrap (or whatever you want users to see) * **Homepage URL**: `https://vibestrap.dev` * **Authorization callback URL**: `https://vibestrap.dev/api/auth/callback/github` * Click **Register application** </Step> <Step title="Grab credentials"> 1. Copy the **Client ID** shown on the app page 2. Click **Generate a new client secret** → copy it immediately (only shown once) </Step> </Steps> | Variable | Example | | ---------------------- | --------------- | | `GITHUB_CLIENT_ID` | `Iv1.abc123...` | | `GITHUB_CLIENT_SECRET` | `ghs_abcdef...` | ### Stripe **Why**: take money. Without this, the entire pricing page is decorative. **Where to set up**: [dashboard.stripe.com](https://dashboard.stripe.com) <Steps> <Step title="Activate your Stripe account"> Provide tax + banking info. Required before Stripe gives you live keys. Test mode works without activation, but you can't actually receive payouts. </Step> <Step title="Grab the API keys"> 1. **Developers → API keys** 2. Copy **Secret key** (`sk_live_...`) 3. Copy **Publishable key** (`pk_live_...`) </Step> <Step title="Create the product and prices"> 1. **Products → + Add product** 2. Name it "Vibestrap" (or whatever your product is called) 3. Add prices under that product. For Vibestrap typically two: * **Standard one-time**: e.g. \$99 * **Promo one-time**: e.g. \$49 (optional sale price) 4. Click into each price; copy the `price_xxx` ID from the URL or the price detail panel </Step> <Step title="Register the webhook"> 1. **Developers → Webhooks → + Add endpoint** 2. **Endpoint URL**: `https://vibestrap.dev/api/webhooks/stripe` 3. Events to send (toggle each): * `checkout.session.completed` * `customer.subscription.created` * `customer.subscription.updated` * `customer.subscription.deleted` * `invoice.payment_succeeded` * `invoice.payment_failed` 4. Click **Add endpoint** 5. On the endpoint detail page, click **Reveal** next to **Signing secret** → copy `whsec_...` </Step> </Steps> | Variable | Example | | ------------------------------------ | ----------------------------------------- | | `STRIPE_SECRET_KEY` | `sk_live_abc...` | | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_live_abc...` | | `STRIPE_WEBHOOK_SECRET` | `whsec_abc...` | | `STRIPE_PRICE_VIBESTRAP_STANDARD` | `price_abc...` (your standard SKU) | | `STRIPE_PRICE_VIBESTRAP_PROMO` | `price_abc...` (optional, for sale price) | <Warning> **The webhook secret is the most-forgotten step.** Without it, Stripe accepts your customer's payment, but your DB never records it — buyer paid you, got nothing. Always test the happy path with Stripe's "Send test webhook" button on the endpoint page **before** going live. If the signature check fails, your `STRIPE_WEBHOOK_SECRET` is wrong. </Warning> ## SEO + analytics — strongly recommended These don't affect whether your site works, but they directly affect whether anyone finds it. ### Google Search Console — site verification **Why**: Google won't reliably index your site without verification. No SEO traffic = no organic growth = paid ads are your only acquisition channel. **Where**: [search.google.com/search-console](https://search.google.com/search-console) <Steps> <Step title="Add the property"> 1. **Add property** → **URL prefix** → enter `https://vibestrap.dev` 2. Choose **HTML tag** verification method </Step> <Step title="Copy just the value"> The page shows something like `<meta name="google-site-verification" content="ABC123..." />`. You only want the bit between the quotes — `ABC123...`. </Step> </Steps> | Variable | Example | | -------------------------- | -------------------------------------------------------- | | `GOOGLE_SITE_VERIFICATION` | `Rj7vQ...` (the `content="..."` value, not the full tag) | ### Microsoft Clarity — heatmaps + session recordings **Why**: free heatmaps, session recordings, and dead-click detection. The best \$0 you'll ever not-spend on user research. **Where**: [clarity.microsoft.com](https://clarity.microsoft.com) <Steps> <Step title="Create a project"> 1. Sign in (Microsoft / Google / Facebook all work) 2. **New project** → name it, paste your URL 3. Skip the install instructions; Vibestrap injects the script automatically 4. Copy the project ID — 10-char alphanumeric, shown on the install instructions page </Step> </Steps> | Variable | Example | | -------------------------------- | ------------ | | `NEXT_PUBLIC_CLARITY_PROJECT_ID` | `abc123xyz0` | ### PostHog — product analytics (optional) **Why**: funnels, retention, feature flags. Free tier covers 1M events/month, more than enough for early-stage products. **Where**: [posthog.com](https://posthog.com) <Steps> <Step title="Create a project"> 1. Sign up; create a new project 2. **Project Settings → Project API Key** → copy `phc_xxx` 3. Note the host: US cloud is `https://us.i.posthog.com`, EU is `https://eu.i.posthog.com` </Step> </Steps> | Variable | Example | | -------------------------- | ------------------------------------ | | `NEXT_PUBLIC_POSTHOG_KEY` | `phc_abc...` | | `NEXT_PUBLIC_POSTHOG_HOST` | `https://us.i.posthog.com` (default) | ## Optional — only when you actually need them A single table for everything else. Don't pre-emptively wire these — they're each "feature off until you fill in the var". | Feature | Set up when | Variables | Where to sign up | | -------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | Bing site verification | You want Bing/DuckDuckGo traffic | `BING_SITE_VERIFICATION` | [bing.com/webmasters](https://www.bing.com/webmasters) | | Yandex verification | Targeting Russian-speaking users | `YANDEX_VERIFICATION` | [webmaster.yandex.com](https://webmaster.yandex.com) | | Resend Audience newsletter | Collecting email subs via Resend | `RESEND_AUDIENCE_ID` | [resend.com](https://resend.com) → Audiences | | Beehiiv newsletter | You already use Beehiiv | `BEEHIIV_API_KEY`, `BEEHIIV_PUBLICATION_ID` | [beehiiv.com](https://beehiiv.com) | | Crisp chat | Want live chat widget | `NEXT_PUBLIC_CRISP_WEBSITE_ID` | [crisp.chat](https://crisp.chat) | | Tawk.to chat | Free Crisp alternative | `NEXT_PUBLIC_TAWK_PROPERTY_ID`, `NEXT_PUBLIC_TAWK_WIDGET_ID` | [tawk.to](https://tawk.to) | | Intercom | Bigger budget, better CRM | `NEXT_PUBLIC_INTERCOM_APP_ID` | [intercom.com](https://intercom.com) | | Chatwoot | Self-hosted support | `NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN`, `NEXT_PUBLIC_CHATWOOT_BASE_URL` | [chatwoot.com](https://chatwoot.com) | | Cloudflare Turnstile | Anti-bot on signup/checkout | `NEXT_PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | [cloudflare.com](https://www.cloudflare.com/products/turnstile/) | | Affonso affiliates | Affiliate program | `NEXT_PUBLIC_AFFONSO_ID` | [affonso.io](https://affonso.io) | | Rewardful affiliates | Stripe-native affiliates | `NEXT_PUBLIC_REWARDFUL_API_KEY` | [rewardful.com](https://rewardful.com) | | Creem (alt to Stripe) | Stripe unavailable in your region | `CREEM_API_KEY`, `CREEM_WEBHOOK_SECRET`, `CREEM_PRODUCT_ID` | [creem.io](https://creem.io) | | AI providers | Your product uses AI | `AI_PROVIDER`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `REPLICATE_API_TOKEN`, `FAL_KEY` | provider websites | | S3 / R2 storage | User uploads, generated images | `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | AWS S3 or [Cloudflare R2](https://developers.cloudflare.com/r2/) | | GitHub invite | Inviting buyers to your private source repo | `GITHUB_INVITE_TOKEN` | fine-grained PAT — see [GitHub invite delivery](/operations/github-invite-delivery) | For long-form setup of any of these, the per-feature pages have full walkthroughs: [Customer service](/growth/customer-service), [Affiliate](/growth/affiliate), [Newsletter](/growth/newsletter), [Turnstile](/growth/turnstile), [GitHub invite delivery](/operations/github-invite-delivery), [AI providers](/ai/providers). ## Apply + verify You've filled in `k8s/.env`. Now push it into the cluster and prove it works. ### Apply your changes ```bash theme={null} # 1. Edit your env file (gitignored — only on your laptop / cluster operator's machine) vim k8s/.env # 2. Refresh the K8s Secret ./k8s/create-secrets.sh # 3. Pods don't auto-reload secrets — restart them kubectl rollout restart deployment/vibestrap -n vibestrap # 4. Watch the rollout finish kubectl rollout status deployment/vibestrap -n vibestrap ``` ### Verify the user flows (don't trust pod status alone) `kubectl get pods` showing `1/1 Running` only proves the process boots. It does not prove the database is reachable, OAuth redirects work, or Stripe webhooks fire. Walk through this list manually: <Steps> <Step title="Homepage renders"> Open `https://vibestrap.dev`. Should render. If it 500s, the most common causes are typoed `DATABASE_URL`, or you forgot to run `pnpm db:migrate` after configuring the database (see the warning at the top of the "Required" section). </Step> <Step title="Email signup works"> Go to `/register`, sign up with email. Check your inbox for the welcome email. Validates: Resend API key + verified domain. </Step> <Step title="Google sign-in works"> Go to `/login`, click **Continue with Google**. Should redirect, prompt, return to your app logged in. Validates: Google OAuth client + redirect URI. </Step> <Step title="GitHub sign-in works"> Same drill, **Continue with GitHub**. Validates: GitHub OAuth app. </Step> <Step title="Forgot-password email arrives"> On `/login`, click "Forgot password". Submit your email. Email should arrive within 30 seconds. Validates: Resend further + auth wiring. </Step> <Step title="Admin dashboard reachable"> Sign in with an email matching `ADMIN_EMAILS`. Visit `/admin`. Should load — not 403. Validates: `ADMIN_EMAILS` parsing. </Step> <Step title="Pricing page → Stripe Checkout"> Visit `/pricing`. Click any checkout button. Should land on a Stripe Checkout page under `checkout.stripe.com`. Validates: Stripe live keys + price IDs are configured. </Step> <Step title="A real test purchase end-to-end"> On the Stripe Checkout page, use Stripe's test card `4242 4242 4242 4242` (only works in test mode — for live mode use a real card and refund yourself, or run this whole test against a sandbox first). Confirm: * payment shows up in Stripe dashboard * webhook fires (Stripe → Webhooks → your endpoint shows a `200`) * a row appears in `payment` table * credits land in your account (visible in `/dashboard`) * visiting `/settings/purchases` shows the GitHub invite form for the buyer-only repo Validates: webhook secret + price-ID-to-credit wiring + buyer-flow gate. </Step> </Steps> If any step fails, look at the pod logs: ```bash theme={null} kubectl logs -n vibestrap -l app=vibestrap --tail=50 ``` Zod and Better Auth print clear error messages — most of the time the answer is right there in the log. <CardGroup> <Card title="Configuration reference" icon="sliders" href="/configuration"> Every config layer in detail. </Card> <Card title="Env reference" icon="list" href="/env-reference"> The full list of every env var Vibestrap reads. </Card> <Card title="Stripe deep dive" icon="credit-card" href="/payments/stripe"> More Stripe specifics: subscriptions, refunds, dispute handling. </Card> <Card title="Kubernetes deployment" icon="cloud" href="/deployment/kubernetes"> The cluster setup, Harbor registry, secret rotation. </Card> </CardGroup> That's it. You're live. Now go tell people. # Affiliate program Source: https://docs.vibestrap.dev/growth/affiliate Pay people to bring you customers — pick a SaaS provider, or run the built-in commission tracker that works with any payment gateway. Affiliate programs are a growth flywheel — your customers bring you more customers, often at a fraction of paid-ad CAC. vibestrap ships the plumbing so you can run one in days instead of weeks: cookie capture, signup attribution, commission calculation, and a one-line provider switch. Pick **internal** (zero monthly fee, your data, works with any payment gateway including Creem and NOWPayments) or hand off to **Rewardful** / **Affonso** (managed dashboards, \~\$30-50/mo, Stripe-only). Switching is one config line — never a rewrite. ## Prerequisites * For SaaS providers: an account with the provider and the public API key / program ID from their dashboard. * For internal: nothing. The `affiliate_referral` and `affiliate_commission` tables are already in the schema (`src/db/affiliate.schema.ts`) and migrate with `pnpm db:push`. * A payment provider already configured (Stripe, Creem, or NOWPayments). ## Step-by-step (internal) 1. **Enable the internal provider** in `src/config/site.ts`: ```ts theme={null} affiliate: { enable: true, provider: 'internal', internalCommissionPct: 20, // % of payment amount referralCookie: 'vbs_ref', // ?ref=CODE → cookie name referralCookieDays: 60, }, ``` 2. **Push the schema** if you haven't already — the affiliate tables ship in the default schema: ```bash theme={null} pnpm db:push ``` 3. **That's it.** The flow is automatic: * A visitor lands on `?ref=CODE` → middleware sets the `vbs_ref` cookie. * They sign up → `recordSignupReferral(userId)` writes a row to `affiliate_referral` (idempotent on `userId`). * They pay → the payment webhook calls `recordCommission()` which reads the referral and inserts an `affiliate_commission` row at your default %. 4. **Build a payout dashboard** — Vibestrap doesn't ship one. Query `affiliate_commission WHERE status = 'pending'`, group by `referrer_code`, pay out via Stripe Connect / wire / whatever, then `UPDATE … SET status = 'paid', paid_at = now()`. ## Step-by-step (SaaS providers) 1. **Pick a provider** in `src/config/site.ts`: ```ts theme={null} affiliate: { enable: true, provider: 'rewardful' }, ``` 2. **Set the matching env var** in `.env.local`: ```bash theme={null} NEXT_PUBLIC_AFFONSO_PROGRAM_ID=... # for Affonso NEXT_PUBLIC_REWARDFUL_API_KEY=... # for Rewardful ``` 3. **Connect the provider to your payment gateway** in their dashboard (Stripe Connect for Affonso/Rewardful). Both SaaS providers are Stripe-only — they read payment events directly from Stripe and Vibestrap doesn't relay anything for them. 4. **Restart `pnpm dev`** to pick up the public env var. ## Pick the right provider | Provider | When to pick | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | internal | You want zero ongoing fees, full control over commission logic, and your data in your DB. Works with any payment provider — pick this if you're on Creem or NOWPayments. | | Affonso | Stripe-only. Lifetime tracking, low monthly cost. | | Rewardful | Stripe-only. Most polished Stripe-native option. Pick if you want a managed dashboard for your affiliates. | ## Verify it works (internal) 1. Open the site at `https://your.app/?ref=alice` in incognito. 2. In devtools → Application → Cookies, confirm `vbs_ref=alice`, expires in 60 days. 3. Sign up. In your DB: ```sql theme={null} SELECT * FROM affiliate_referral WHERE referrer_code = 'alice'; ``` 4. Buy something. Then: ```sql theme={null} SELECT * FROM affiliate_commission WHERE referrer_code = 'alice'; ``` You should see one row with `commission_cents = amount_cents * 0.20`. ## Common pitfalls 1. **Safari ITP blocks cookies.** First-party cookies set by your own server on a `?ref=` landing are fine, but if you use cross-domain redirects (e.g. landing on a marketing subdomain → app subdomain) the cookie will be dropped. Keep the referral landing on the same eTLD+1 as the signup. 2. **Refunds don't reverse commissions.** v0.1 doesn't auto-reverse — you need to manually insert a refund row (negative `commission_cents`, status `cancelled`) or `UPDATE … SET status = 'cancelled'` in your payout query. 3. **Self-referrals.** Nothing blocks a user from setting their own code as the referrer. Add a guard in `recordCommission` if this matters: `if (referral.userId === input.userId) return;`. 4. **Switching from internal to a SaaS provider.** The SaaS providers track on their own backend — historical commissions in `affiliate_commission` stay where they are; only new payments will be tracked by the SaaS provider. 5. **Cookie name collision.** If your buyers add their own analytics that also uses `vbs_*` prefixed cookies, change `referralCookie` to something unique to your brand. ## Going beyond vibestrap stops at "wires connected, ready to extend" — by design. The internal provider gives you data; how you turn it into a product is yours to shape: * **Payout admin UI** — query `affiliate_commission` directly while you have under five affiliates; build a `/admin` page when manual SQL hurts. * **Affiliate self-service dashboard** — they ask for it before you need to build it. A copy-able referral link is enough at first. * **Auto payouts** — Stripe Connect, PayPal Mass Pay, Wise — pick when you have a monthly-or-tighter cadence. The API surface won't move under you: `recordSignupReferral`, `recordCommission`, and the two affiliate tables. Build on top — don't rewrite. ## Official docs * Affonso: [affonso.io/docs](https://affonso.io/docs) * Rewardful: [help.rewardful.com](https://help.rewardful.com/) * Internal schema: `src/db/affiliate.schema.ts` * Internal helpers: `src/affiliate/track.ts` # Customer service Source: https://docs.vibestrap.dev/growth/customer-service Drop a chat widget on every page — pick from Crisp, Tawk.to, Intercom, or Chatwoot with a single config flip. Live chat is a credibility signal — a product without it looks like a hobby project, a product with it looks like there's a team behind it. But every chat tool ships its own SDK, auth model, and unmount quirks; rolling your own integration burns days when you should be shipping features. vibestrap mounts a `<CustomerService />` widget in the locale layout that switches between four major providers (Crisp, Tawk.to, Intercom, Chatwoot) with a single config flip and a public env var. Providers self-gate on their env vars, so an unconfigured one renders nothing instead of crashing the page. ## Prerequisites * An account with one of: Crisp, Tawk.to, Intercom, or Chatwoot. * The site/property/app ID from that provider's dashboard. * Your production domain allow-listed in the provider's settings (most reject embedded widgets from unknown origins). ## Step-by-step 1. **Pick a provider** in `src/config/site.ts`: ```ts theme={null} customerService: { enable: true, provider: 'crisp', // 'crisp' | 'tawk' | 'intercom' | 'chatwoot' }, ``` 2. **Add the matching env vars** to `.env.local`. All are `NEXT_PUBLIC_*` — they need to ship to the browser so the loader script can boot. ```bash theme={null} # Crisp NEXT_PUBLIC_CRISP_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Tawk.to (both required) NEXT_PUBLIC_TAWK_PROPERTY_ID=000000000000000000000000 NEXT_PUBLIC_TAWK_WIDGET_ID=000000000 # Intercom NEXT_PUBLIC_INTERCOM_APP_ID=abcd1234 # Chatwoot (token required, base URL defaults to app.chatwoot.com) NEXT_PUBLIC_CHATWOOT_WEBSITE_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx NEXT_PUBLIC_CHATWOOT_BASE_URL=https://app.chatwoot.com ``` 3. **Allow-list your domain** in the provider's dashboard (Crisp → Settings → Website Settings, Tawk → Property → Widget → Restrictions, etc). 4. **Restart `pnpm dev`** — public env vars are baked at build time. ## Pick the right provider | Provider | When to pick | | -------- | ------------------------------------------------------------------------------------------------------------------------- | | Crisp | Generous free tier (2 seats, unlimited contacts), nice dashboard, fastest setup. Good default for indie hackers. | | Tawk.to | Fully free forever — no plan limits at all. Trade-off: branded widget unless you pay \$19/mo to remove it. | | Intercom | Enterprise standard. Pick this if you have a real support team and want product tours, ticketing, and outbound campaigns. | | Chatwoot | Open-source, self-hostable. Pick this if you need data sovereignty or want to host the chat backend on your own infra. | ## Verify it works 1. Open the site in an incognito window — the bubble should appear bottom-right within \~1 second of page load. 2. Send a test message; check the inbox in the provider's dashboard. 3. Visit `/login` or `/register` — the widget is intentionally hidden on auth pages (the `<CustomerService />` component lives in the app layout, not the auth layout). 4. View source: you should see exactly one loader script tag for your provider and zero for the others. ## Common pitfalls 1. **Missing `NEXT_PUBLIC_` prefix.** Anything the browser reads needs the prefix; otherwise Next.js strips it from the bundle and the widget silently fails to mount. 2. **Forgot to allow-list your domain.** Most providers reject the widget on unknown origins (you'll see an empty bubble or a CORS error in devtools). 3. **CSP blocking the script.** If you've added a Content-Security-Policy header, add the provider's CDN to `script-src` and `connect-src` (e.g. `https://client.crisp.chat`, `https://embed.tawk.to`, `https://widget.intercom.io`, your Chatwoot base URL). 4. **Two widgets at once.** Setting `enable: true` plus a stale env var from a previous provider doesn't double-render — the switch in `src/customer-service/index.tsx` only mounts the active one. But leftover env vars are harmless; clean them up. 5. **Widget on auth pages.** It's hidden by design (separate layout). If you want it everywhere, mount `<CustomerService />` in the root layout instead of the app layout. ## Add a new provider The pattern is intentionally tiny — a single client component that reads its env var and returns a `<Script>` tag (or `null` when unset). Look at any of `src/customer-service/provider/*.tsx` for the shape, then: 1. Add the provider to the union in `src/customer-service/types.ts`. 2. Add a new `case` to the switch in `src/customer-service/index.tsx`. 3. Add the env var to `src/env.ts` under `client.NEXT_PUBLIC_*`. ## Official docs * Crisp: [docs.crisp.chat](https://docs.crisp.chat/) * Tawk.to: [help.tawk.to](https://help.tawk.to/) * Intercom: [intercom.com/help/en](https://www.intercom.com/help/en/) * Chatwoot: [chatwoot.com/docs](https://www.chatwoot.com/docs) # Newsletter Source: https://docs.vibestrap.dev/growth/newsletter Email signups behind one facade — Resend Audiences (default) or Beehiiv with double opt-in and broadcast scheduling. Newsletter signups are the cheapest growth channel a SaaS has — high-intent users raising their hand for follow-up, no paid-ad cost. But a homegrown signup form is 50 lines of validation, anti-bot, audience sync, and "what happens after they click subscribe?". vibestrap ships the whole flow: a signup card on the homepage, a Turnstile-gated server action, audience write, and a confirmation email out of the gate. Switch between **Resend** (default — syncs to an Audience you broadcast from) and **Beehiiv** (full publishing platform with double opt-in) by flipping one config line. ## Prerequisites * For Resend: a Resend account, API key, and an Audience created in the dashboard. * For Beehiiv: a Beehiiv publication and an API key from Settings → Integrations → API. * The newsletter feature flag (`siteConfig.newsletter.enable`) set to `true`. ## Step-by-step (Resend) 1. **Create an Audience** at [resend.com/audiences](https://resend.com/audiences). Copy its ID — looks like `aud_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. 2. **Set env vars** in `.env.local`: ```bash theme={null} RESEND_API_KEY=re_xxxxxxxxxxxx RESEND_AUDIENCE_ID=aud_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` 3. **Confirm the config** in `src/config/site.ts` (Resend is the default): ```ts theme={null} newsletter: { enable: true, provider: 'resend', }, ``` 4. **Restart `pnpm dev`**. Submit the footer signup form — the contact should appear in your Resend Audience within seconds. ## Step-by-step (Beehiiv) 1. **Get your publication ID** from Beehiiv → Settings → Integrations. It's shaped like `pub_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. 2. **Set env vars** in `.env.local`: ```bash theme={null} BEEHIIV_API_KEY=bv_xxxxxxxxxxxx BEEHIIV_PUBLICATION_ID=pub_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` 3. **Switch the provider** in `src/config/site.ts`: ```ts theme={null} newsletter: { enable: true, provider: 'beehiiv', }, ``` 4. **Configure double opt-in** inside Beehiiv (Settings → Subscribers → Confirmation). Vibestrap passes `reactivate_existing: true` and `send_welcome_email: false`, leaving Beehiiv to send the confirmation email per your publication's policy. ## Pick the right provider | Provider | When to pick | | -------- | ---------------------------------------------------------------------------------------------------------- | | Resend | You already use Resend for transactional mail, you want one less vendor, and a basic Audience is enough. | | Beehiiv | You want a real newsletter product — landing pages, paid subs, referrals, broadcast scheduling, analytics. | ## Verify it works 1. Submit the footer signup form with a fresh email. 2. **Resend**: check the Audience in the Resend dashboard — the contact should be there with `unsubscribed: false`. 3. **Beehiiv**: check Subscribers → All — the email should appear with status `pending` (until they click the double-opt-in link) or `active` if you've disabled confirmation. 4. Submit the same email again. Both providers handle this gracefully (Resend dedupes; Beehiiv reactivates) — no error toast should appear. ## Common pitfalls 1. **Resend: missing `RESEND_AUDIENCE_ID`.** The provider silently no-ops when either env var is unset (logs `[newsletter/resend] Skipped` in dev). Form submissions look successful but nothing is stored. Always check both env vars are set in production. 2. **Beehiiv: `reactivate_existing` behaviour.** If a contact previously unsubscribed, Vibestrap reactivates them on the next signup. Some jurisdictions require a fresh opt-in for re-subscription — flip the flag in `src/newsletter/provider/beehiiv.ts` if your legal team objects. 3. **GDPR for EU traffic.** Use Beehiiv with double opt-in enabled (the default) and add a consent checkbox to the form. Resend Audiences default to single opt-in; you'll need to wire your own confirmation email if you sell to EU customers. 4. **Same key, different env.** Public Resend keys are per-environment. The same `RESEND_AUDIENCE_ID` may not exist in both your dev and prod Resend projects — keep two audiences or use the same project across both. 5. **Switching providers.** Existing subscribers in the old provider stay there. There's no automatic migration — export from the old, import to the new. ## Add an inline form anywhere The form is a server action. Mount it wherever — a blog sidebar, a CTA section, a modal. Look at the home page footer for the existing usage, copy the component, and the server action will route through the active provider automatically. ## Official docs * Resend Audiences: [resend.com/docs/dashboard/audiences/introduction](https://resend.com/docs/dashboard/audiences/introduction) * Resend API: [resend.com/docs](https://resend.com/docs) * Beehiiv API v2: [developers.beehiiv.com](https://developers.beehiiv.com/) * Provider source: `src/newsletter/provider/{resend,beehiiv}.ts` # Turnstile (anti-bot) Source: https://docs.vibestrap.dev/growth/turnstile Cloudflare Turnstile gate on signup, forgot-password, and newsletter forms — privacy-friendly, free, no cookies. Public forms (signup, forgot-password, newsletter) get scraped by bots within hours of going live — fake signups poison your audience, password-reset spam burns through your email quota, and your Stripe risk score takes a hit. vibestrap protects all three with [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/), a CAPTCHA replacement that's privacy-friendly, free at any volume, and doesn't drop any cookies. The widget is wired into the signup, forgot-password, and newsletter forms; the server-side helper at `src/lib/turnstile.ts` verifies the token before the action runs. If env vars are unset, verification gracefully returns `{ ok: true, skipped: true }` so dev and unconfigured deployments keep working. ## Why Turnstile (vs reCAPTCHA) * **Privacy-friendly.** No cookies, no Google tracking, GDPR-friendly out of the box. * **Free at any scale** — no usage cap. * **Lower user friction** — most challenges are invisible (managed mode). * **Cloudflare ecosystem** — if you already proxy through Cloudflare, this is the obvious choice. ## Prerequisites * A Cloudflare account (free tier is fine). * Your domain registered as a Turnstile site at [dash.cloudflare.com](https://dash.cloudflare.com/) → Turnstile → Add Site. * The site key and the secret key from that site's settings page. ## Step-by-step 1. **Create a Turnstile site** in the Cloudflare dashboard. Add your production domain plus `localhost` for local dev. Pick "Managed" mode for the best UX (Cloudflare decides when to challenge). 2. **Set both env vars** in `.env.local`: ```bash theme={null} NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA... # public, ships to client TURNSTILE_SECRET_KEY=0x4AAAAAAA... # server-only, never exposed ``` 3. **Confirm the feature flag** in `src/config/site.ts` (already on by default): ```ts theme={null} features: { enableTurnstile: true, }, ``` 4. **Restart `pnpm dev`.** The widget renders on the signup, forgot-password, and newsletter forms. The server actions automatically call `verifyTurnstile(token)` and reject the request when the token is missing or invalid. ## Verify it works 1. Open `/register` in incognito — you should see the Turnstile widget (usually invisible, or a quick "verifying" flash). 2. Open devtools → Network. On submit, the form posts a `cf-turnstile-response` field. The server action calls `https://challenges.cloudflare.com/turnstile/v0/siteverify` internally; you'll see no extra outbound request from the browser. 3. Try submitting with `NEXT_PUBLIC_TURNSTILE_SITE_KEY` removed — the widget disappears. Server-side, `turnstileEnabled()` returns `false` and verification is skipped (returns `{ ok: true, skipped: true }`). 4. Tamper with the token via devtools → server action returns an error, form does not submit. ## Common pitfalls 1. **Token can only be used once.** If a server action retries internally with the same token (e.g. after a transient DB error), the second call will fail. Either reset the widget on retry or short-circuit at the action boundary. 2. **Token expires after \~5 minutes.** Long-lived form pages (e.g. a multi-step signup) need to refresh the widget before submit. Turnstile's JS will auto-refresh if you call `turnstile.render` with `expired-callback`. 3. **Don't trust the client widget alone.** A green widget without server verification is theatre. `verifyTurnstile()` is what actually protects you; never bypass it in your own server actions. 4. **Missing token in production.** Common cause: `TURNSTILE_SECRET_KEY` set, but `NEXT_PUBLIC_TURNSTILE_SITE_KEY` not deployed (env var not added to the hosting platform). Result: the widget doesn't render → no token → server rejects every signup. Always deploy both vars together. 5. **Localhost not allow-listed.** Add `localhost` (and `127.0.0.1` if you use it) to your Turnstile site's domain list, or local signups will silently fail. ## How it's wired ```ts theme={null} // src/lib/turnstile.ts (excerpt) export async function verifyTurnstile(token: string | undefined | null) { if (!siteConfig.features.enableTurnstile || !env.TURNSTILE_SECRET_KEY) { return { ok: true, skipped: true }; } if (!token) return { ok: false, skipped: false, errors: ['missing-token'] }; // POST to Cloudflare siteverify, return parsed result. } ``` Server actions call `verifyTurnstile(input.cfTurnstileToken)` first thing and short-circuit on `!ok`. Adding it to a new form means: render the widget on the client, pass the token through the action's input schema, and call `verifyTurnstile` in the action body. ## Disable Turnstile for a deployment Either: * Set `siteConfig.features.enableTurnstile = false` (compile-time off), or * Leave both env vars unset (runtime skip). Either path makes `verifyTurnstile` return `{ ok: true, skipped: true }` so the rest of the action proceeds normally. ## Official docs * Turnstile docs: [developers.cloudflare.com/turnstile](https://developers.cloudflare.com/turnstile/) * Server-side verification: [developers.cloudflare.com/turnstile/get-started/server-side-validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) * Test keys: [developers.cloudflare.com/turnstile/troubleshooting/testing](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) # Welcome Source: https://docs.vibestrap.dev/index A paid Next.js scaffold for indie hackers building AI tool-stations. Indie hackers building paid AI tool-stations spend the first 2-4 weeks of every project on the same SaaS plumbing — auth, payments, credits, email, i18n, analytics — before they ever touch the AI feature that actually differentiates them. vibestrap collapses that into a couple of days. You get a battle-tested SaaS foundation (auth, four payment providers, credits, mail, customer service, analytics) plus an AI-native layer no other scaffold ships: six providers behind one facade, cost observability, prompt versioning, hooks, UI primitives, and three working demos you can deploy as-is. You bought it once, you'll never be charged again, every future release is yours for free. ## How these docs are organized * **[Getting started](/installation)** — install, configure, deploy. * **[Authentication](/auth/overview)** — Better Auth, OAuth, One-Tap. * **[Payments](/payments/overview)** — four payment providers behind one facade. * **[AI primitives](/ai/providers)** — providers, hooks, prompts, observability, UI library. * **[Growth](/growth/customer-service)** — customer service, affiliate, newsletter, anti-bot. * **[Content & i18n](/content/i18n)** — bilingual MDX, Fumadocs, marketing blocks. * **[Operations](/operations/analytics)** — analytics, tooling, admin, GitHub invite delivery. * **[Deployment](/deployment/vercel)** — Vercel and Cloudflare Workers walkthroughs. * **[Reference](/architecture)** — architecture, customization, every env var. Every page has a Chinese counterpart — switch the locale in the header to read in 中文. ## Need help? * Email: `support@vibestrap.dev` * The full plan and stack rationale lives in `internal-plans/2026-04-27-vibestrap-design.md` (in the repo). # Installation Source: https://docs.vibestrap.dev/installation Clone the repo, install dependencies, push the schema, run dev. Vibestrap is a standard Next.js 15 monorepo using pnpm. If you've shipped a Next.js app before, none of this will surprise you. ## Prerequisites * **Node.js** ≥ 22 — the build uses Edge runtime features that require recent V8. * **pnpm** ≥ 10 — `corepack enable` then `corepack prepare pnpm@latest --activate`, or follow [pnpm.io/installation](https://pnpm.io/installation). * **PostgreSQL** — local Postgres for dev (Postgres.app on macOS, `brew install postgresql`, or Docker). For production: [Neon](https://neon.tech), [Supabase](https://supabase.com), [Railway](https://railway.app), [Crunchy Bridge](https://www.crunchybridge.com) — any of them work. ## 1. Clone & install ```bash theme={null} git clone https://github.com/your-fork/vibestrap.git cd Vibestrap pnpm install ``` The lockfile is committed — don't run `pnpm install --no-frozen-lockfile` unless you mean to upgrade. CI uses `--frozen-lockfile`. ## 2. Configure environment ```bash theme={null} cp .env.example .env.local ``` Open `.env.local` and set at minimum: ```bash theme={null} DATABASE_URL=postgres://localhost/vibestrap BETTER_AUTH_SECRET=<32+ random chars, e.g. `openssl rand -base64 32`> ``` Every other env is optional in dev — the corresponding module either no-ops or logs a friendly warning when its keys are missing. See [Env reference](/env-reference) for the full list. ## 3. Push the schema ```bash theme={null} pnpm db:push ``` This diffs your Drizzle schema (`src/db/*.schema.ts`) against the database and applies the changes directly. Use it for dev. For production, see [Deploying to Vercel](/deployment/vercel) → "Database migrations". ## 4. Run dev ```bash theme={null} pnpm dev ``` Open `http://localhost:3000`. The marketing site renders, the AI chat demo works (`/demos/chat` runs on the offline mock provider), and you can sign up an admin user via `/register`. ## 5. Sanity-check the install ```bash theme={null} pnpm typecheck && pnpm lint && pnpm test ``` All three should pass on a fresh install. If anything fails, check [SUPPORT.md](https://github.com/xiaohu0x/vibestrap/blob/main/SUPPORT.md) for the common-pitfalls list. ## What's next * [Quickstart](/quickstart) — wire your own product in 10 minutes * [Configuration](/configuration) — what `src/config/site.ts` controls * [Deploying to Vercel](/deployment/vercel) — push to prod # Analytics & Webmaster Tools Source: https://docs.vibestrap.dev/operations/analytics Fan out to Vercel, GA4, PostHog, Plausible, Umami, and Clarity — plus one-line site verification for Google Search Console, Bing Webmaster, and Yandex Webmaster. Every provider self-gates on its env var, mix and match freely. Analytics is one of those "I'll add it later" decisions that quietly snowballs into hours of integration work — and for AI products you usually want product analytics (PostHog) and traffic analytics (Plausible / GA) from day one, not day 60. vibestrap mounts an `<Analytics />` block in the root layout that fans out to **six** providers (Vercel, GA4, PostHog, Plausible, Umami, Clarity) plus a `metadata.verification` block for Google / Bing / Yandex webmaster tools. Every loader self-gates on its env var, so you only pay for what you've configured — set the env var, restart, done. ## Analytics providers ### Prerequisites * An account with at least one of: Vercel, Google Analytics 4, PostHog, Plausible, Umami, or Microsoft Clarity. * The site / property / website ID from that provider's dashboard. * A production domain (some providers reject events from `localhost` or sample them out). ### Step-by-step 1. **Pick which providers you want** in `src/config/site.ts`. All six are `true` by default; flip to `false` to disable even if the env var is set. ```ts theme={null} analytics: { vercel: true, googleAnalytics: true, posthog: true, plausible: true, umami: true, clarity: true, }, ``` 2. **Add the matching env vars** to `.env.local`. All are `NEXT_PUBLIC_*` — the loader runs in the browser so they must ship to the client bundle. ```bash theme={null} # GA4 — Measurement ID NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # PostHog — project key + host (host defaults to us.i.posthog.com) NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # Plausible — your domain + host (host defaults to plausible.io) NEXT_PUBLIC_PLAUSIBLE_DOMAIN=yourdomain.com NEXT_PUBLIC_PLAUSIBLE_HOST=https://plausible.io # Umami — website ID + host (host defaults to cloud.umami.is) NEXT_PUBLIC_UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000 NEXT_PUBLIC_UMAMI_HOST=https://cloud.umami.is # Microsoft Clarity — project ID from clarity.microsoft.com NEXT_PUBLIC_CLARITY_PROJECT_ID=xxxxxxxxxx ``` 3. **Vercel Analytics is zero-config** when deployed on Vercel — the `@vercel/analytics/next` component auto-injects the script. Locally it's a no-op. Disable by setting `analytics.vercel = false`. 4. **Restart `pnpm dev`** — `NEXT_PUBLIC_*` env vars are baked at build time. ### Pick the right provider | Provider | When to pick | | ----------------- | -------------------------------------------------------------------------------------- | | Vercel Analytics | You're on Vercel and want a zero-config baseline. Free tier is plenty. | | GA4 | You need attribution into Google Ads / a marketing team that already uses it. | | PostHog | Product analytics, feature flags, session replays — all in one. Generous free tier. | | Plausible | Privacy-first, GDPR-friendly, no cookie banner needed. Lightweight script. | | Umami | Open-source, self-hostable. Same privacy story as Plausible but free if you self-host. | | Microsoft Clarity | Heatmaps + session recordings, free with no traffic cap. Pairs well with GA4. | ### Verify it works 1. Open the site in an incognito window. 2. Open devtools → Network and filter by the provider's hostname (e.g. `posthog.com`, `plausible.io`, `clarity.ms`). You should see at least one request fire on page load. 3. Click around — each provider's dashboard should show a real-time visitor within 30 seconds. 4. View the page source: you should see one `<script>` tag per enabled provider, none for disabled ones. ### Common pitfalls 1. **Missing `NEXT_PUBLIC_` prefix.** Anything the browser reads needs the prefix. Without it, Next.js strips the variable from the client bundle and the loader silently mounts as `null`. 2. **Ad-blockers eating your scripts.** uBlock Origin and Brave block GA4, PostHog, Clarity, and Umami by default. To recover, use a proxy domain — Plausible supports a `/js/script.outbound-links.js` self-host route, and PostHog has a reverse-proxy guide. 3. **GA4 needs an EU consent banner.** GDPR requires explicit opt-in for GA4 tracking. Same for Clarity (session replay = personal data). Either gate the script behind a consent prompt or use Plausible / Umami which are cookie-free by design. 4. **Dashboard pollution from localhost.** Dev traffic shows up in production dashboards if you don't filter it. Either set `analytics.posthog = false` in dev, or use the providers' "exclude IP" / "disable on localhost" filters. 5. **Plausible domain mismatch.** `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` must exactly match the site you registered in Plausible — including or excluding `www`. Mismatched events are dropped silently. ## Search-engine site verification To prove ownership in **Google Search Console**, **Bing Webmaster Tools**, or **Yandex Webmaster**, each console asks you to add a meta tag to your site's `<head>`. Vibestrap renders these via Next.js `metadata.verification` in `src/app/layout.tsx`, so you just paste the value into an env var. ### Step-by-step 1. **Add your domain** in each console: * Google Search Console → [search.google.com/search-console](https://search.google.com/search-console) * Bing Webmaster Tools → [bing.com/webmasters](https://www.bing.com/webmasters) * Yandex Webmaster → [webmaster.yandex.com](https://webmaster.yandex.com/) 2. **Pick the "HTML tag" verification method** in each console. You'll see a meta tag like: ```html theme={null} <!-- Google --> <meta name="google-site-verification" content="ABC123..." /> <!-- Bing --> <meta name="msvalidate.01" content="DEF456..." /> <!-- Yandex --> <meta name="yandex-verification" content="GHI789..." /> ``` 3. **Copy ONLY the `content` value** (not the whole tag) into `.env.local`: ```bash theme={null} GOOGLE_SITE_VERIFICATION="ABC123..." BING_SITE_VERIFICATION="DEF456..." YANDEX_SITE_VERIFICATION="GHI789..." ``` These are **server-side** env vars (no `NEXT_PUBLIC_` prefix) — Next.js reads them when generating the root metadata at build time. 4. **Toggle individual tags** in `src/config/site.ts` if you want to suppress one even though the env var is set: ```ts theme={null} verification: { google: true, bing: true, yandex: true, }, ``` 5. **Deploy**, then click **Verify** in each console. ### Verify it works ```bash theme={null} curl -s https://yourdomain.com | grep -E 'google-site-verification|msvalidate|yandex-verification' ``` You should see one `<meta>` tag per env var that's set. Empty env vars produce no tag (no empty `content=""` pollution). ### Common pitfalls 1. **Don't paste the whole `<meta>` tag** — only the `content` value. Pasting the tag itself produces broken HTML in the rendered head. 2. **Verification is one-time.** Once the console confirms, you can leave the env var set; it doesn't cost anything to keep the meta tag rendered. 3. **CDN cache.** If you deploy and the console says "tag not found", purge your CDN cache (Cloudflare, Vercel) — the verifier hits the live HTML. 4. **DNS is an alternative.** All three consoles also support DNS TXT-record verification, which doesn't touch your HTML. If you prefer that route, leave the env vars empty and add the TXT records in your DNS provider. ## Official docs * Vercel Analytics: [vercel.com/docs/analytics](https://vercel.com/docs/analytics) * Google Analytics 4: [support.google.com/analytics](https://support.google.com/analytics) * PostHog: [posthog.com/docs](https://posthog.com/docs) * Plausible: [plausible.io/docs](https://plausible.io/docs) * Umami: [umami.is/docs](https://umami.is/docs) * Microsoft Clarity: [learn.microsoft.com/clarity](https://learn.microsoft.com/en-us/clarity/) * Google Search Console: [support.google.com/webmasters](https://support.google.com/webmasters) * Bing Webmaster Tools: [bing.com/webmasters/help](https://www.bing.com/webmasters/help) * Yandex Webmaster: [yandex.com/support/webmaster](https://yandex.com/support/webmaster/) # Cookie consent Source: https://docs.vibestrap.dev/operations/cookie-consent GDPR / CCPA / global compliance banner powered by vanilla-cookieconsent. Opt-in by default, gates analytics scripts, ships with en + zh i18n out of the box. Cookie consent is the work every public website needs and almost every indie postpones until a buyer's legal team flags it. vibestrap ships a working banner so you don't have to choose between "shipping fast" and "compliant on day one": * 🍪 **Opt-in by default** — analytics scripts (Google Analytics, Microsoft Clarity, PostHog) wait for the user's "Accept" before loading. No data collected before consent. * ⚖️ **Globally-framed compliance** — covers GDPR (EU/UK), CCPA / CPRA (California), LGPD (Brazil), PIPL (China) under one "Your Privacy Choices" mechanism; opt-out is one click from the banner or footer. * 🧹 **Cleanup on revoke** — when a user toggles analytics off after accepting, we erase the cookies that were set. GDPR Article 7(3). * 🌍 **Bilingual** — en / zh follow vibestrap's existing i18n, no extra language detection. * 🎨 **Two-button banner** — Accept / Reject only, no third "Customize" button. Granular per-category control lives behind the footer "Cookie Preferences" link (Hick's law: fewer choices = faster decision). * 🛠️ **Built on [`vanilla-cookieconsent`](https://github.com/orestbida/cookieconsent)** (9k+ stars, MIT). Easy to retheme via CSS variables. Architecture is intentionally split into three layers: | Layer | File | What it does | | -------- | -------------------------------------------------- | --------------------------------------------- | | Library | `vanilla-cookieconsent` | DOM injection, GDPR / CCPA logic, persistence | | Wrapper | `src/components/cookie-consent/cookie-consent.tsx` | React Provider, language hookup, lifecycle | | Consumer | `useConsent()` in `src/analytics/analytics.tsx` | Gates the actual tracking scripts | ## Prerequisites * Nothing to install — `vanilla-cookieconsent` is already a dependency. * `siteConfig.cookieConsent.enable` defaults to `true` (the banner ships on). ## Step-by-step ### 1. Confirm it's enabled in `src/config/site.ts` ```ts theme={null} cookieConsent: { enable: true, // mounts the banner revision: 1, // bump this when cookies change to force re-consent expiresAfterDays: 365, }, ``` ### 2. Verify the analytics gating In `src/analytics/analytics.tsx` you'll see scripts split into two buckets: ```ts theme={null} {/* No consent needed — cookie-free providers */} {siteConfig.analytics.vercel && <VercelAnalytics />} {siteConfig.analytics.plausible && <PlausibleScript />} {siteConfig.analytics.umami && <UmamiScript />} {/* Consent-gated — drops cookies */} {siteConfig.analytics.googleAnalytics && allowAnalytics && <GoogleAnalytics />} {siteConfig.analytics.posthog && allowAnalytics && <PostHogScript />} {siteConfig.analytics.clarity && allowAnalytics && <ClarityScript />} ``` `allowAnalytics` is `true` only after the user clicks Accept (or already consented in a previous visit, within the 12-month expiry window). ### 3. Bump revision when cookies change If you add a new analytics provider or switch one out, bump `siteConfig.cookieConsent.revision` by one. Every previously-consented user will see the banner again so they can re-consent under the new policy. This is GDPR best practice. ### 4. Verify in the browser ```bash theme={null} pnpm dev ``` In dev mode, the banner is intentionally skipped so you don't see it on every reload (see `process.env.NODE_ENV` check in `cookie-consent.tsx`). To see the live banner: ```bash theme={null} pnpm build && pnpm start # or visit production ``` You should see: * A bottom-right toast on first visit * Two buttons: **Accept all** / **Reject all** * A footer with: Privacy Policy · Cookie Policy · Your Privacy Choices ## User journey — every scenario, aligned to industry leaders The following matrix maps every common user flow to vibestrap's behavior and the corresponding behavior at Stripe / Microsoft / Vercel / Cloudflare / Linear / OpenAI. The implementation matches the **industry norm everywhere** — no proprietary or surprising behavior. | Scenario | vibestrap behavior | Industry standard | | --------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------- | | **First-ever visit** | Banner appears bottom-right | Same (Stripe / Vercel / Linear / OpenAI) | | **Visit after Accept (within 13 months)** | No banner, GA + Clarity load | Same | | **Visit after Reject (within 13 months)** | No banner, GA + Clarity stay disabled | Same | | **Visit after consent expires (>13 months)** | Banner reappears (treated as new visit) | Same — Stripe/MS use 13mo, others 6-12mo | | **Refresh / navigate without choosing** | Banner stays visible until user picks | Same | | **Click X / close icon without choosing** | Treated as "Reject all" — necessary only | Same — GDPR forbids treating it as Accept | | **Cookie revision bumped (policy change)** | Banner reappears (forced re-consent) | Same — Stripe/MS use version field too | | **Cleared browser data** | Banner reappears (no cookie present) | Same | | **Incognito / private window** | Banner appears every session | Same | | **Cross-device** | Each device decides independently | Same — consent is per-browser-storage | | **Browser sends Sec-GPC: 1** (Brave / DuckDuckGo / Firefox+ext) | No banner; auto-Reject (only necessary) | Same — Microsoft + 2024+ US state laws require this | | **Click Privacy Policy in banner** | Navigate to `/privacy`; banner stays visible | Same — must not lose consent state mid-read | | **Click Cookie Preferences in footer** | Re-opens preferences modal | Same — required by GDPR Article 7(3) | | **Toggle Analytics off after accepting** | `_ga` / `_clck` / `MUID` deleted on next page load | Same — required by GDPR Article 7(3) | | **Toggle Analytics on after declining** | GA + Clarity load on next render | Same | | **Reject vs site functionality** | All site features keep working (login, payments, newsletter) | Same — GDPR forbids cookie-walling | | **Affiliate `?ref=xxx` referral with marketing rejected** | Cookie not stored (marketing category off) | Same — Stripe / Vercel apply same gating | ## Cookie expiry rationale (13 months) The default expiry is **395 days (\~13 months)**, matching: * **Stripe** (13 months) * **Microsoft** (13 months) * **Cloudflare** (13 months) This is the upper bound recommended by the UK ICO and France's CNIL — long enough that returning users aren't bannered repeatedly, short enough that consent stays demonstrably fresh. Sites going for shorter windows (Vercel / Linear / Notion at 6 months) are also compliant; we chose 13 to match the most rigorous reference implementations. To adjust, edit `siteConfig.cookieConsent.expiresAfterDays`. ## Customizing the banner copy All banner text lives in `messages/{en,zh}.json` under the `CookieConsent` namespace. To change the title, button labels, or descriptions, edit those keys — both files in lockstep (the i18n audit catches drift). ## Customizing the banner UI `vanilla-cookieconsent` exposes \~30 CSS variables for colors, radii, shadows, and font sizes. Override them by editing the imported CSS or adding a custom CSS layer: ```css theme={null} :root { --cc-bg: #fff; --cc-primary-color: var(--accent-tech); /* match vibestrap brand */ --cc-border-radius: 0.5rem; /* see: https://cookieconsent.orestbida.com/advanced/customization.html */ } ``` For a complete UI override, set `disablePageInteraction: false` and write your own Tailwind-styled container — but the default already matches Linear / Stripe-style toasts. ## Withdrawal entry point GDPR Article 7(3) requires that withdrawing consent be as easy as giving it. vibestrap renders a **Cookie Preferences** link in the footer's Legal section that re-opens the modal. This is mounted by `src/components/cookie-consent/cookie-preferences-link.tsx` and is automatically wired — don't remove it. ## Common pitfalls 1. **Banner doesn't show in dev.** This is intentional — dev mode short-circuits to "everything accepted" so you can test analytics without bannering yourself on every reload. Use `pnpm build && pnpm start` to see the real banner. 2. **Forgot to bump revision after adding a new tracker.** Existing users won't be re-prompted, and they'll be tracked under their old consent (which didn't cover the new tracker). Always bump `siteConfig.cookieConsent.revision` in the same PR that adds a tracker. 3. **Analytics still loading after Decline.** Check that the script render in `src/analytics/analytics.tsx` is actually behind `allowAnalytics` — easy to add a new tracker and forget the gate. The convention: anything that drops a cookie is gated, anything cookie-free (Vercel / Plausible / Umami) renders unconditionally. 4. **Cookie banner shows broken HTML.** If you customized the banner `footer` string (links to Privacy Policy / Privacy Choices), make sure the HTML is valid — the lib renders it raw. 5. **Locale mismatch.** The banner reads from `<html lang="…">` (set by next-intl). If your custom layout sets a different `lang` attribute, the banner falls back to English. ## "Your Privacy Choices" — global framing The banner footer links to `/privacy#privacy-choices`, a globally-framed section in the privacy policy that covers opt-out rights for everyone: GDPR (EU / UK), CCPA / CPRA (California), LGPD (Brazil), PIPL (China). The opt-out **mechanism** is identical for every visitor (banner, footer link, or email); the section then names each region's specific right (e.g. California's legally-named "Do Not Sell My Personal Information") so a regulator searching for the phrase still finds it. This mirrors how Apple, Microsoft, and Stripe have moved away from California-only "Do Not Sell" headers toward a global "Your Privacy Choices" frame with named sub-rights. If you target California heavily and want a dedicated form for opt-out requests, add a `/privacy/privacy-choices` page; the inline section is the indie-stage default. ## Disabling cookie consent entirely If you're cookie-free (only running Vercel Analytics / Plausible / Umami) and want to skip the banner, set: ```ts theme={null} // src/config/site.ts cookieConsent: { enable: false, revision: 1, expiresAfterDays: 365 }, ``` This still mounts the React Context (so `useConsent()` keeps working), but treats every user as having consented to everything. Skip the banner UI entirely. ## Official resources * vanilla-cookieconsent docs: [cookieconsent.orestbida.com](https://cookieconsent.orestbida.com/) * GDPR Article 7 (consent): [gdpr-info.eu/art-7-gdpr](https://gdpr-info.eu/art-7-gdpr/) * CCPA / CPRA: [oag.ca.gov/privacy/ccpa](https://oag.ca.gov/privacy/ccpa) # GitHub invite delivery Source: https://docs.vibestrap.dev/operations/github-invite-delivery How buyers get the source — payment-gated, read-only collaborator invites to your private Org repo. When a customer purchases Vibestrap, the source isn't shipped as a downloadable zip. Instead, they enter their GitHub username on `/settings/purchases` and get invited as a **read-only (`pull`) collaborator** on your private Org repo. They accept the invite, `git clone`, and from then on `git pull` brings them every release you push. This is deliberately not a "License key + signed download URL" flow. Source zips age out the moment you ship a hotfix; a GitHub invite ages with you. ## How the gate works Three layers, deliberately decoupled. The whole flow has exactly one business rule: **the user must have a paid Vibestrap purchase**. Everything else is plumbing. ``` [Buyer enters GitHub username] → button click ↓ sendGitHubInviteAction (src/actions/send-github-invite.ts) ├─ next-safe-action validates the input shape ├─ Gate: SELECT FROM payment WHERE userId=? AND status='paid' │ AND scene IN (product.id, 'vibestrap-lifetime') ├─ Returns { ok: false, reason: 'not_paid' } if no row └─ Calls inviteCollaborator(username) ↓ inviteCollaborator (src/github/invite.ts) ├─ PUT /repos/{owner}/{repo}/collaborators/{username} │ with { "permission": "pull" } ├─ Translates GitHub status codes: │ 201 → invited │ 204 / 422 → already_invited │ 404 → invalid_username │ 401 / 403 → forbidden │ 429 → rate_limited └─ Returns InviteResult union ``` The buyer sees a Sonner toast based on the result reason; the page is unchanged otherwise. ## One-time setup ### 1. Use an Organization repo (not a personal one) GitHub's `permission: 'pull'` flag is **silently ignored on personal repos** — every collaborator gets push access regardless. You must host the buyer-facing repo in an Organization. If your dev work happens on a personal repo, treat the Org repo as a release mirror: ```bash theme={null} # Optional: keep dev separate from buyer-facing git push --mirror git@github.com:YourOrg/vibestrap.git ``` Set Vibestrap's config: ```ts theme={null} // src/config/site.ts github: { inviteRepoOwner: 'YourOrg', // ← the Organization name inviteRepoName: 'vibestrap', // ← the private repo } ``` ### 2. Lock down the Org In your Org settings → Member privileges: * **Uncheck** "Allow members to delete or transfer repositories" — even an Administration:write token can't `DELETE /repos` once this is off. * Set **Default repository permission** to `None`. * Optionally disable forking of private repos so buyers can't fork-and-leak. ### 3. Mint a fine-grained PAT Go to [https://github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens) (logged in as the account that's an Org member, ideally a dedicated bot account): * **Resource owner**: your Org * **Repository access**: only the buyer-facing repo * **Permissions**: * Metadata: Read-only (required by GitHub) * Administration: Read and write (the `Add a collaborator` endpoint requires this — it's the narrowest scope GitHub offers for the operation; the Org-level "no delete" lock is what protects you from misuse) * **Expiration**: 1 year. Calendar a 60-day rotation reminder. Copy the token (you only see it once) and paste it into your prod env: ```bash theme={null} GITHUB_INVITE_TOKEN=github_pat_11ABCDEF... ``` The token is read by `src/github/invite.ts` at request time — no rebuild needed when you rotate it. ### 4. Verify locally ```bash theme={null} # In your dev shell GITHUB_INVITE_TOKEN=ghp_xxx pnpm dev # In another shell curl -X PUT \ -H "Authorization: Bearer $GITHUB_INVITE_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/YourOrg/vibestrap/collaborators/your-test-account \ -d '{"permission":"pull"}' ``` 201 = success, 204 = already a collaborator, 404 = wrong username, 403 = token under-scoped. ## Buyer flow on the live site 1. Customer signs up, pays via Stripe. 2. Webhook writes a `payment` row with `status='paid'` and `scene` matching `siteConfig.product.id`. 3. They navigate to `/dashboard` — the page detects the paid row and renders the buyer view, which embeds the invite form. 4. They enter their GitHub username and click **Send invite**. 5. The server action checks the payment row, calls GitHub, and returns a result reason. A Sonner toast surfaces it. 6. They open GitHub email, accept the invitation, then: ```bash theme={null} git clone git@github.com:YourOrg/vibestrap.git ``` If they ever miss the email or change usernames, they re-enter and click again. The invite is idempotent server-side. ## Rotation runbook Every 60 days (before the 90-day expiration emails from GitHub start): 1. Mint a new PAT with the same scope. 2. Update prod env (`GITHUB_INVITE_TOKEN`). 3. Redeploy. 4. Do a smoke test: invite a test account. 5. Once you're confident the new token works, revoke the old one in [https://github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens). ## Troubleshooting * **Buyer reports "not\_paid" toast despite paying** — confirm the `payment.scene` matches `siteConfig.product.id` exactly (or the `'vibestrap-lifetime'` historical alias). Check via SQL. * **Buyer reports "invalid\_username"** — they probably typed the wrong thing. Or copied a `@username` with the `@` prefix. Validate locally. * **All invites fail with "forbidden"** — the PAT expired or was revoked. Mint a new one. * **Invite hits the buyer's GitHub but they never see push access** — that's the point: `permission: 'pull'` on an Org repo gives them clone rights and nothing else. Push attempts return `remote rejected`. * **Why no zip download?** — Source zips don't track future releases. Buyers want `git pull`, not a tarball that ages out the day after they download it. (And implementing a working signed-URL download flow takes the same effort as the GitHub invite, with a worse buyer experience.) ## Migration from the old license-key flow If you previously ran a `license` table and `LICENSE_DOWNLOAD_URL` env: * Both are gone. The schema migration drops `license`; the env var is removed; `/api/license/download` was deleted. * The payment-table check in `sendGitHubInviteAction` replaces the license-row check. * Old buyers (if any) — re-issue invites manually via `curl` or have them request again from `/settings/purchases`. ## Source links * API client: [`src/github/invite.ts`](https://github.com/xiaohu0x/vibestrap/blob/main/src/github/invite.ts) * Server action: [`src/actions/send-github-invite.ts`](https://github.com/xiaohu0x/vibestrap/blob/main/src/actions/send-github-invite.ts) * UI: [`src/app/[locale]/(app)/settings/purchases/`](https://github.com/xiaohu0x/vibestrap/tree/main/src/app/%5Blocale%5D/\(app\)/settings/purchases) * Tests: [`tests/unit/github-invite.test.ts`](https://github.com/xiaohu0x/vibestrap/blob/main/tests/unit/github-invite.test.ts) # Dev tooling Source: https://docs.vibestrap.dev/operations/tooling The opinionated stack — Biome, Knip, Drizzle, Vitest, next-safe-action, server-only, t3-env — chosen so the verification loop stays fast. Vibestrap is opinionated about dev tooling because indie hackers don't have time to babysit a build. Everything is either fast (Biome over ESLint+Prettier) or load-bearing (`server-only` keeps secrets out of the client bundle). This page covers what's installed, what each tool does, and the mistakes that bite. ## Prerequisites * Node.js 20 or newer (see `engines` in `package.json`). * pnpm 10 (`corepack enable && corepack prepare pnpm@10.26.1 --activate`). * A Postgres database for `db:push` / `db:migrate`. ## The stack ### Biome 2 — linter + formatter in one binary Replaces ESLint and Prettier with a single Rust binary that's roughly 10x faster. Config lives in `biome.json`. ```bash theme={null} pnpm lint # biome check . pnpm lint:fix # biome check --write . pnpm format # biome format --write . ``` Style is single quotes, ES5 trailing commas, 100-column width. Toggle rules in `biome.json` under `linter.rules`. ### Knip 6 — unused-export detection Finds dead exports, unused dependencies, and orphaned files. Run before shipping a refactor. ```bash theme={null} pnpm knip ``` Config lives in `knip.config.ts`. Add false-positives to its `ignore` / `ignoreDependencies` lists. ### Drizzle ORM 0.45 — type-safe SQL Schema split across `src/db/{auth,app,affiliate,ai,license}.schema.ts`. IDs are `text` with nanoid prefixes (e.g. `lic_xxx`), never `serial`. ```bash theme={null} pnpm db:push # dev — sync schema to DB without a migration file pnpm db:generate # prod — generate a SQL migration from schema diff pnpm db:migrate # prod — apply pending migrations pnpm db:studio # visual DB explorer at localhost:4983 ``` ### Vitest 4 — unit tests Tests live in `tests/unit/**` and `src/**/*.{test,spec}.ts`. Node environment, globals enabled, `@/` alias resolved. ```bash theme={null} pnpm test # one-shot run pnpm test:watch # watch mode ``` ### next-safe-action 3-tier — typed server actions Every mutation goes through one of three clients in `src/lib/safe-action.ts`: ```ts theme={null} actionClient // public, no auth userActionClient // requires logged-in user (provides ctx.user) adminActionClient // requires user.role === 'admin' ``` Each action declares an input schema with Zod, returns a typed result. ### server-only — runtime guard against client leaks Modules that touch the DB or secrets `import 'server-only'` at the top. If they're accidentally imported into a client component, the build fails with a clear error instead of shipping your `DATABASE_URL` to the browser. Already protected: `src/db/index.ts`, `src/lib/auth.ts`, `src/payment/provider/*`, `src/credits/server.ts`, `src/github/invite.ts`. ### @t3-oss/env-nextjs — typed env vars `src/env.ts` declares every required and optional env var with Zod. The app refuses to boot when a required var is missing, so misconfiguration fails at build time instead of in production at 3 AM. Always import from `@/env` (not `process.env.X`) so you get types and validation. ## Verify it works Before every commit: ```bash theme={null} pnpm typecheck && pnpm lint && pnpm build ``` If any of the three fails, the error message tells you what to fix. Don't push code that fails any of them — the production CI runs the same chain. ## Common pitfalls 1. **Forgetting `import 'server-only'`** in a new module that touches secrets. Add it as the first line of any file that reads `env.STRIPE_SECRET_KEY`, queries the DB, or hits an API key. The build will catch the leak. 2. **`process.env.X` instead of `env.X`.** You skip Zod validation, lose types, and break in production when the var is misspelled. There's no ESLint rule against it — use code review or grep for `process\.env\.`. 3. **Calling a `userActionClient` action from an unauthenticated route.** It throws `UNAUTHORIZED` and returns 401. Wrap the call in a session check or use `actionClient` if the action is genuinely public. 4. **`pnpm db:push` against production.** It diffs the schema and applies destructive changes (drops, renames) without a migration file. Use `db:generate` + `db:migrate` for prod, and pass `--strict` if you want a confirmation prompt before destructive ops. 5. **Knip false positives.** Skill registries and dynamically-imported providers look unused to static analysis. Add them to `knip.config.ts`'s `ignore` list — don't delete the file. ## Official docs * Biome: [biomejs.dev](https://biomejs.dev/) * Knip: [knip.dev](https://knip.dev/) * Drizzle ORM: [orm.drizzle.team](https://orm.drizzle.team/) * Vitest: [vitest.dev](https://vitest.dev/) * next-safe-action: [next-safe-action.dev](https://next-safe-action.dev/) * t3-env: [env.t3.gg](https://env.t3.gg/) # Credits Source: https://docs.vibestrap.dev/payments/credits An immutable ledger with exactly 4 transaction types — wired into every payment provider. Vibestrap ships a credits ledger that's intentionally small: a single mutable balance per user (`userCredit`) plus an append-only log (`creditTransaction`) with **exactly 4 transaction types**. Every payment provider funnels into the same `addCredits` / `consumeCredits` / `refundCredits` primitives, so credit behavior is identical whether the user paid via Stripe or WeChat. ## The 4 transaction types That's it. Resist adding more — encode nuance in `sourceType` instead. | Type | When | Example `sourceType` | | --------- | ---------------------- | ----------------------------------------------------- | | `GRANT` | Credits added | `register_gift`, `subscription`, `one_time`, `manual` | | `CONSUME` | Credits spent | `ai_call`, `image_generation` | | `EXPIRE` | Credits expired (cron) | `expiration_cron` | | `REFUND` | Credits returned | `failed_call`, `manual_reversal` | Schema lives in `src/db/app.schema.ts`; primitives in `src/credits/server.ts`; business wrappers in `src/credits/index.ts`. ## Atomic primitives All three primitives wrap their UPDATE + INSERT in a single transaction so the balance and the log can never drift: ```ts theme={null} export async function addCredits(input: AddCreditsInput): Promise<void> { await ensureUserCredit(input.userId); await db.transaction(async (tx) => { await tx .update(userCredit) .set({ currentCredits: sql`${userCredit.currentCredits} + ${input.amount}` }) .where(eq(userCredit.userId, input.userId)); await tx.insert(creditTransaction).values({ type: 'GRANT', amount: input.amount, ... }); }); } ``` `consumeCredits` checks balance inside the transaction and returns `{ ok: false, reason: 'INSUFFICIENT' }` if the user is short — never throws. `refundCredits` mirrors `addCredits` with `type: 'REFUND'`. ## Business wrappers Most callers don't talk to `addCredits` directly. They use the higher-level helpers in `src/credits/index.ts`: ```ts theme={null} addRegisterGiftCredits(userId); // signup gift grantPlanCredits({ userId, planId, paymentId }); // any demoPlans tier ``` The signup gift comes from `siteConfig.credits.registerGift`. Plan grants come from each entry's `creditsGranted` in `siteConfig.demoPlans` — editing the array shapes the entire grant matrix. ## How payments grant credits The webhook handler `src/payment/handlers/core.ts` has a single dispatch: ```ts theme={null} if (p.scene === siteConfig.product.id) return; // Vibestrap itself — license, not credits await grantPlanCredits({ userId: p.userId, planId: p.scene, paymentId }); ``` `grantPlanCredits` looks up `siteConfig.demoPlans` by id and grants `creditsGranted` on every successful invoice. For yearly subs that means 12× the monthly amount up front; for monthly that means 1× per cycle; for one-time tiers that means once forever. ## Multi-currency `payment.amount` is stored in the smallest unit (cents, fen, …) and `payment.currency` records the ISO code. USD and CNY are both first-class. The credits ledger itself is currency-agnostic — credits are pure integers, and pricing-to-credits mapping happens at grant time using `siteConfig.demoPlans[*].creditsGranted`. ## Expiration `creditTransaction.expirationDate` is set on `GRANT` rows that should expire (e.g. `register_gift` expires after 30 days per `siteConfig.credits.registerGift`). A cron job sweeps these and inserts matching `EXPIRE` rows; if you don't deploy the cron, expiration just doesn't happen — credits never go negative either way. ## Customer-facing UI The credits dashboard renders at `/settings/credits`: * Current balance (`userCredit.currentCredits`) * Transaction history (paginated `creditTransaction` log) * Buy more / upgrade plan CTAs (demo by default — see next section) Billing for subscriptions / portal access lives at `/settings/billing`. ## Going from demo to real checkout Vibestrap ships `/settings/credits` as a **starter** showing what a credits-and-plans purchase page can look like. Out of the box, the Buy buttons display a toast ("Demo only") instead of triggering Stripe — so vibestrap.dev itself can't accidentally charge users for meaningless subscriptions, and so buyers exploring the scaffold can click around safely before configuring their providers. Replacing it with real checkout is a code change, not a config switch: 1. Configure your provider's price IDs in `siteConfig.demoPlans` (and the matching `STRIPE_PRICE_*` env vars). 2. Open `src/app/[locale]/(app)/settings/credits/buy-plan-button.tsx` and replace the body with a `CheckoutButton` that calls `createCheckoutAction` — the `/pricing` page's `<CheckoutButton>` (in `src/components/payment/checkout-button.tsx`) shows the same pattern wired up to real Stripe. 3. Remove the demo banner at the top of `src/app/[locale]/(app)/settings/credits/page.tsx`. 4. Delete the demo i18n keys: `Settings.credits.demoNotice`, `Settings.credits.demoBanner.title`, `Settings.credits.demoBanner.body` (in both `messages/en.json` and `messages/zh.json`). That's it — about ten minutes of mechanical changes. We deliberately don't ship a "demo mode" environment variable because that would be one more switch buyers need to remember to flip; deleting the demo code on the way to production is clearer. ## Verify it works 1. Sign up a new user — registration triggers `addRegisterGiftCredits`. 2. Open Postgres: ```sql theme={null} select type, amount, source_type from credit_transaction where user_id = '<new-user-id>'; ``` You should see `GRANT 50 register_gift`. 3. Buy a `demoPlans` tier (test mode) → expect a second `GRANT` row with `source_type = 'subscription'` (or `'one_time'`) and the tier's `creditsGranted`. 4. Trigger an AI call (or call `consumeCredits` from a test script) — you should see a `CONSUME` row and the balance drop. ## Common pitfalls * **Adding new transaction types** — don't. Use `sourceType` for taxonomy. New types break analytics and admin UIs that enumerate the four. * **Bypassing the transaction** — never UPDATE `userCredit.currentCredits` outside the helpers. The only safe path is `addCredits` / `consumeCredits` / `refundCredits` because they wrap UPDATE + INSERT in one DB transaction. * **Granting credits before inserting the payment row** — the webhook handler inserts payment first, then dispatches credits. Reversing the order makes retries grant duplicates because the idempotency check is on `payment.invoiceId`. * **Stale `siteConfig.demoPlans` references** — `grantPlanCredits` looks up the tier by id; if you delete a tier from config but a paid order still references it, the helper silently returns. Audit before removing tiers. * **Forgetting the cron for expiration** — `expirationDate` is data, not a trigger. No cron means no `EXPIRE` rows ever get inserted. ## Official docs * Drizzle ORM transactions: [orm.drizzle.team/docs/transactions](https://orm.drizzle.team/docs/transactions) * PostgreSQL `SELECT … FOR UPDATE` (used implicitly via Drizzle): [postgresql.org/docs/current/explicit-locking.html](https://www.postgresql.org/docs/current/explicit-locking.html) * Vibestrap source: `src/credits/server.ts`, `src/credits/index.ts`, `src/db/app.schema.ts` # Creem Source: https://docs.vibestrap.dev/payments/creem The mainland-China payment provider — Alipay and WeChat Pay with a clean Stripe-like API. Creem is the right pick if you sell to mainland China. Stripe can't accept Alipay or WeChat Pay at scale; Creem can. The API surface is intentionally Stripe-like, so the integration in Vibestrap mirrors the Stripe provider almost line-for-line — just swap the SDK and env vars. Pick Creem if your buyers are in China (or you want to expand a Western product into China without a separate codebase). ## Prerequisites * A Creem account ([creem.io](https://creem.io)). To go live you need a Chinese business entity (营业执照) and KYC. Sandbox / test mode works without it. * A merchant account on Alipay and/or WeChat Pay, linked to your Creem account. Creem walks you through the binding flow during onboarding. * Postgres up, schema pushed. ## 1. Set the active provider In `src/config/site.ts`: ```ts theme={null} payment: { provider: 'creem' as 'stripe' | 'creem' | 'nowpayments', currency: 'usd', // or 'cny' — see pitfalls below }, ``` For Alipay / WeChat Pay you almost always want `currency: 'cny'` since those rails are CNY-native. Mixing other currencies forces a forex layer. ## 2. Create products in Creem In the Creem dashboard, **Products → New product**. Each product has an ID that you'll pass as the `priceId` to checkout. Creem doesn't separate "products" and "prices" the way Stripe does — one product = one price. For the Vibestrap scaffold itself, create a `promo` product and a `standard` product. ## 3. Set env vars In `.env.local` (variable names match `src/env.ts`): ```bash theme={null} CREEM_API_KEY=creem_sk_... CREEM_WEBHOOK_SECRET=your-signing-secret # Vibestrap product IDs CREEM_PRICE_VIBESTRAP_PROMO=prod_... CREEM_PRICE_VIBESTRAP_STANDARD=prod_... ``` `CREEM_PRICE_*` is named `PRICE` for symmetry with the other providers but holds a Creem product ID under the hood. ## 4. Configure the webhook In the Creem dashboard, **Developers → Webhooks → New endpoint**: * **URL**: `https://your-domain.com/api/webhooks/creem` * **Events** (minimum): `checkout.completed`, `payment.succeeded`, `subscription.created`, `subscription.updated`, `subscription.canceled`. * **Signing secret**: pick any random string and paste it here AND into `CREEM_WEBHOOK_SECRET` in your env. Creem signs the raw body with HMAC-SHA256. For local testing, expose `localhost:3000` via cloudflared or frpc (cloudflared is faster from China than ngrok). ## Verify it works 1. Use Creem's test mode (toggle in dashboard). 2. `pnpm dev`, open `/pricing`, click checkout — Creem's hosted page opens. 3. Pay with the test method shown by Creem (sandbox Alipay or WeChat). 4. Watch the webhook arrive (terminal logs). 5. Check the database: ```sql theme={null} select id, provider, scene, status, amount, currency from payment where provider = 'creem' order by created_at desc limit 1; ``` ## Common pitfalls * **KYC required for production** — Creem requires a Chinese business entity * ICP filing for live merchant accounts. Sandbox mode works without it, but production launches need to complete KYC first. Plan a few weeks of lead time. * **Currency must be CNY for Alipay / WeChat** — Alipay and WeChat Pay are CNY-native rails. If you set `currency: 'usd'` and use those methods, Creem will either refuse the payment or apply forex with a markup. Set `siteConfig.payment.currency = 'cny'` and price in fen (1 yuan = 100 fen) just like cents. * **No customer portal return URL** — `creem.customers.generateBillingLinks` doesn't accept a `returnUrl`. Customers navigate back via your app chrome. * **Webhook secret is whatever you typed** — Creem doesn't generate the signing secret. Use `openssl rand -hex 32`. * **Network egress from Western infra** — if you deploy on Vercel / Cloudflare with the customer in China, payment redirects work but admin pages calling `creem.checkouts.create` from a Western edge can be slow. Consider an Asia region for these specific routes. ## Official docs * Creem Docs: [creem.io/docs](https://creem.io/docs) * API reference: [creem.io/docs/api](https://creem.io/docs/api) * Webhooks guide: [creem.io/docs/webhooks](https://creem.io/docs/webhooks) * Alipay docs (upstream): [docs.open.alipay.com](https://docs.open.alipay.com) * WeChat Pay docs (upstream): [pay.weixin.qq.com/docs](https://pay.weixin.qq.com/docs/) # NOWPayments Source: https://docs.vibestrap.dev/payments/nowpayments Crypto-only hosted invoice gateway — accept BTC/ETH/USDC and 300+ coins with no merchant KYC. NOWPayments is a crypto-only hosted invoice gateway. Use it when you want to accept Bitcoin, Ethereum, USDC/USDT and 300+ other coins without going through a Coinbase-style KYC. The merchant flow is email-only signup; the buyer picks their coin on a hosted page and you receive crypto in your wallet. Pick NOWPayments if you're an indie hacker who wants to add a crypto checkout option alongside (or in place of) a card processor — and don't need auto-debit subscriptions. <Note> **No subscriptions.** NOWPayments has a "recurring billing" feature, but it's just an email-resend of new invoices each cycle — not auto-debit, because on-chain crypto wallets can't pre-authorize recurring withdrawals. The provider in Vibestrap **throws on `type: 'subscription'`** to fail loudly rather than silently book bad UX. Use NOWPayments for one-time / lifetime / credit-pack purchases. Pair with Stripe (USDC) or Helio if you need true crypto subscriptions. **No customer portal.** `createPortalLink` throws — there's no NOWPayments equivalent of Stripe's billing portal. </Note> ## Prerequisites * A NOWPayments account at [nowpayments.io](https://nowpayments.io). **No KYC** required for basic merchant signup — just an email. * A receiving wallet address registered in the NOWPayments dashboard (**Settings → Payment settings → Outcome wallet**). This is where your crypto lands. Use a non-custodial wallet you control (Ledger / Trezor / a trusted CEX deposit address). * An IPN secret generated in **Settings → Store settings → IPN Secret**. * Postgres up, schema pushed. ## 1. Set the active provider In `src/config/site.ts`: ```ts theme={null} payment: { provider: 'nowpayments' as 'stripe' | 'creem' | 'nowpayments', currency: 'usd', }, ``` `currency` stays `'usd'` — you price in USD on your end, and NOWPayments quotes the equivalent crypto amount at checkout time. ## 2. Set env vars In `.env.local` (variable names match `src/env.ts`): ```bash theme={null} NOWPAYMENTS_API_KEY=YOUR_API_KEY # Settings → Store settings → API key NOWPAYMENTS_IPN_SECRET=YOUR_IPN_SECRET # Settings → Store settings → IPN Secret # Vibestrap product prices in USD (string form — "49" means $49) NOWPAYMENTS_PRICE_VIBESTRAP_PROMO=49 NOWPAYMENTS_PRICE_VIBESTRAP_STANDARD=99 ``` <Note> NOWPayments has no concept of a "product" or "price ID". Other providers' `PRICE_*` env vars hold a Stripe `price_xxx` / Creem product id / etc.; NOWPayments' just hold the **dollar amount as a string**. The provider parses it to a number and POSTs it as `price_amount` directly. </Note> ## 3. Configure the IPN webhook NOWPayments calls these "IPN" (Instant Payment Notifications). In **Settings → IPN settings**: * **IPN callback URL**: `https://your-domain.com/api/webhooks/nowpayments` Vibestrap also sends `ipn_callback_url` per-invoice so it works even if the dashboard global is unset — but setting both is harmless and lets failed invoices retry against the right host. ## How the flow works 1. User clicks "Pay with crypto" → server action calls `paymentManager.createCheckout({ priceId: '49', type: 'one_time', ... })`. 2. The provider POSTs to `https://api.nowpayments.io/v1/invoice` with `price_amount: 49`, `price_currency: 'usd'`, an `order_id` packed as `vbs|<userId>|<scene>|<type>` so we can recover context from the IPN, and `ipn_callback_url` set to your `/api/webhooks/nowpayments` route. 3. NOWPayments returns a hosted invoice URL like `https://nowpayments.io/payment?iid=4514933743`. We redirect the user there. 4. The user picks a coin (BTC / ETH / USDC / …), sends the funds. 5. NOWPayments emits multiple IPNs as the payment progresses: `waiting → confirming → confirmed → sending → finished`. The provider only treats `finished` as paid — the rest are normalized to `unknown` and ignored. Other terminal states (`failed`, `expired`, `partially_paid`, `refunded`) are also `unknown` from the handler's perspective — those need manual reconciliation in the NOWPayments dashboard. 6. On `finished`, the standard `processNormalizedEvent` flow runs: payment row inserted (idempotent on `payment.invoiceId`, which we set to the NOWPayments `payment_id`), credits granted, license issued if applicable. ## Webhook signature verification NOWPayments signs the IPN body with **HMAC-SHA512** of the **alphabetically-sorted JSON serialization** of the body, using your IPN secret. The signature arrives in the `x-nowpayments-sig` header. The verification is implemented in `verifyNowpaymentsWebhook` and matches the algorithm shown in the official Node.js sample (`JSON.stringify(params, Object.keys(params).sort())`) and the official PHP WooCommerce plugin's `check_ipn_request_is_valid()` (ksort + hash\_hmac). Tests cover the round-trip plus tampering, malformed JSON, and wrong-length signatures. ## Verify it works 1. Use NOWPayments' **sandbox** at [sandbox.nowpayments.io](https://sandbox.nowpayments.io) for test transactions (separate API key + IPN secret). 2. Expose `localhost:3000` via cloudflared / ngrok, set the resulting URL as your IPN callback in the sandbox dashboard. 3. `pnpm dev`, open `/pricing`, click checkout — NOWPayments hosted invoice page opens. 4. Pay with the sandbox testnet faucet they provide. 5. Watch the IPNs arrive — you'll see several with `payment_status` going from `waiting` to `finished`. 6. Check Postgres after `finished`: ```sql theme={null} select id, provider, scene, status, amount, currency, invoice_id from payment where provider = 'nowpayments' order by created_at desc limit 1; ``` ## Common pitfalls * **`is_fee_paid_by_user: false`** — Vibestrap's provider always sets this so you eat the (small) network fee instead of surprising the buyer with a larger invoice. Toggle in `src/payment/provider/nowpayments.ts` if you'd rather pass it through. * **Sandbox vs production keys are completely separate** — including the IPN secret. Mixing them silently fails signature verification. * **`partially_paid` requires manual reconciliation** — if a buyer underpays (sent the wrong amount, or the price moved during the confirmation window), NOWPayments emits `partially_paid` and holds the funds. The provider ignores it; you'll need to refund or top-up via the dashboard. * **Refunds are out-of-band** — there's no Stripe-style "create refund" API; you initiate refunds in the NOWPayments dashboard. The handler doesn't reverse credit grants automatically. If you need that, listen to the raw `refunded` IPN status and call `refundCredits()` yourself. * **Order ID format is load-bearing** — we encode `vbs|<userId>|<scene>|<type>` so the IPN handler can recover context (NOWPayments has no metadata field). If you change the prefix or delimiter, update `parseOrderId` in `src/payment/provider/nowpayments.ts` to match. * **Price is fixed at invoice creation** — if BTC moves 5% between invoice creation and the buyer paying, NOWPayments locks in the original quote. Buyers occasionally complain they "overpaid" or "underpaid" by a few cents; this is normal crypto-payment behavior, not a bug in Vibestrap. * **No customer portal** — buyers can't self-serve refunds, billing history, or payment method updates. Build your own `/settings/billing` page that reads from the `payment` table if you want a UI. ## Switching from Stripe / Creem Already running another provider? Three steps: 1. Flip `siteConfig.payment.provider` to `'nowpayments'`. 2. Set the four `NOWPAYMENTS_*` env vars. 3. Configure the IPN URL in the NOWPayments dashboard. In-flight Stripe / Creem subscriptions keep working until they cancel naturally — Vibestrap dispatches webhooks based on `event.provider`, and the shared handler is idempotent, so old provider events still hit the same `payment` table without conflict. ## Official docs * NOWPayments: [nowpayments.io](https://nowpayments.io) * API reference: [documenter.getpostman.com/view/7907941/S1a32n38](https://documenter.getpostman.com/view/7907941/S1a32n38) * IPN setup guide: [nowpayments.io/help/ipn-callbacks](https://nowpayments.io/help/ipn-callbacks) * Sandbox: [sandbox.nowpayments.io](https://sandbox.nowpayments.io) * Official Node SDK: [github.com/NowPaymentsIO/nowpayments-api-js](https://github.com/NowPaymentsIO/nowpayments-api-js) # Payments overview Source: https://docs.vibestrap.dev/payments/overview Start with one Stripe checkout, keep provider switching optional. Vibestrap's payment system is designed for template users first: configure a product, pass a `productId`, and let the starter handle checkout, webhooks, orders, credits, licenses, and the billing portal. ## The quick path For most products you only touch three places: 1. Create Products + Prices in Stripe. 2. Paste the price IDs into `.env.local`. 3. Adjust plans and packs in `src/config/site.ts`. The checkout call stays small: ```ts theme={null} await createCheckoutAction({ productId: 'pro' }); await createCheckoutAction({ productId: 'credits_standard' }); ``` For links that should work before login, send buyers to: ```txt theme={null} /checkout?productId=pro ``` Unauthenticated buyers are redirected to login, then returned to `/checkout` and sent to the hosted checkout page automatically. ## Config layers `siteConfig.billing` is the beginner-facing surface: ```ts theme={null} billing: { enabled: true, defaultProductId: 'pro', successPath: '/settings/billing?status=success', cancelPath: '/pricing?status=canceled', } ``` `siteConfig.demoPlans` defines the four demo SaaS tiers your app sells. `siteConfig.payment` is the advanced provider layer: Stripe is enabled by default; Creem and NOWPayments can be added when you need alternate rails. ## Provider facade Provider code still lives behind one contract: ```ts theme={null} interface PaymentProvider { createCheckout(opts): Promise<{ id: string; url: string }>; createPortalLink(opts): Promise<{ url: string }>; } ``` Stripe, Creem, and NOWPayments each normalize webhooks to the same `NormalizedEvent` shape. The shared handler in `src/payment/handlers/core.ts` inserts the payment row, grants credits, issues licenses, and records affiliate commission. ## Idempotency Provider retries are expected. The payment table has unique indexes on both `invoiceId` and `sessionId`; the handler inserts with conflict protection and then grants entitlements idempotently by `paymentId`. That means replaying a webhook should not create duplicate payments, credits, licenses, or affiliate commissions. ## Provider notes | Provider | Use it for | Notes | | ----------- | -------------------------- | ------------------------------------------------------- | | Stripe | Default SaaS checkout | Subscriptions, one-time payments, credit packs, portal. | | Creem | Alternate card/local rails | Useful when Stripe is not ideal for your market. | | NOWPayments | Crypto one-time payments | No subscriptions; invoice flow only. | ## Verify it works 1. Set Stripe keys and price IDs in `.env.local`. 2. Run `pnpm dev`. 3. Open `/checkout?productId=pro`. 4. Complete a test payment. 5. Confirm a row appears in `payment` and the expected credits/license are granted. 6. Replay the webhook; the database should stay unchanged. # Stripe Source: https://docs.vibestrap.dev/payments/stripe Wire Stripe as the active payment provider — keys, products, webhook, local testing. Stripe is Vibestrap's default provider — best DX, deepest docs, lowest fees on the typical NA/EU stack. The trade-off: you're not the merchant of record, so you handle VAT and US sales tax yourself (Stripe Tax solves most of it for a 0.5% surcharge). ## Prerequisites * A Stripe account ([stripe.com](https://stripe.com)) — free, no business entity required for test mode. * The Stripe CLI for local webhook forwarding — [docs.stripe.com/stripe-cli](https://docs.stripe.com/stripe-cli). * A live Postgres database (`pnpm db:push` already run). ## 1. Set the active provider Open `src/config/site.ts`: ```ts theme={null} payment: { provider: 'stripe' as 'stripe' | 'creem' | 'nowpayments', currency: 'usd', }, ``` This is already the default. If you've changed it, set it back to `'stripe'`. ## 2. Create products + prices in Stripe Dashboard In the Stripe Dashboard go to **Products → Add product**. Create one product per scene you want to sell. For the Vibestrap scaffold itself you need two prices on the same product (or two separate products): | Scene | Description | | ------------------ | --------------------------------------- | | Vibestrap promo | Limited-time price (e.g. \$49 one-time) | | Vibestrap standard | Regular price (e.g. \$99 one-time) | For the demo SaaS the starter ships four tiers in `siteConfig.demoPlans` that illustrate the common patterns: `lifetime_promo` + `lifetime_standard` (one-time, reuse the Vibestrap price IDs) and `pro_monthly` + `pro_yearly` (recurring, need their own Stripe prices). Add more tiers by extending the array. Copy each `price_…` ID — you'll paste them into env vars next. ## 3. Set env vars In `.env.local`, fill in (names match `src/env.ts` exactly): ```bash theme={null} STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... # see step 5 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Vibestrap product (the scaffold itself) STRIPE_PRICE_VIBESTRAP_PROMO=price_... STRIPE_PRICE_VIBESTRAP_STANDARD=price_... # Demo subscription tiers (recurring; one-time tiers reuse the VIBESTRAP_* prices above) STRIPE_PRICE_PRO_MONTHLY=price_... STRIPE_PRICE_PRO_YEARLY=price_... ``` Only the prices you actually expose in the UI need to be set; the rest can stay empty. ## 4. Local webhook testing with Stripe CLI ```bash theme={null} stripe login stripe listen --forward-to localhost:3000/api/webhooks/stripe ``` The CLI prints a webhook signing secret like `whsec_…`. Paste it into `STRIPE_WEBHOOK_SECRET` in `.env.local` and restart `pnpm dev`. Now any test payment you make will deliver an event to your local route, signed with the correct secret. To trigger an event without paying through the UI: ```bash theme={null} stripe trigger checkout.session.completed ``` ## 5. Production webhook endpoint In the Stripe Dashboard: **Developers → Webhooks → Add endpoint**. * **URL**: `https://your-domain.com/api/webhooks/stripe` * **Events** (minimum): `checkout.session.completed`, `invoice.paid`, `customer.subscription.updated`, `customer.subscription.deleted`. * After creating, click into the endpoint and copy its **Signing secret** (`whsec_…`). Set it as `STRIPE_WEBHOOK_SECRET` in your production env. ## Verify it works 1. Start dev with `stripe listen` running. 2. Open `/pricing`, click "Get Vibestrap" — you should land on Stripe Checkout. 3. Pay with `4242 4242 4242 4242`, any future expiry, any CVC, any ZIP. 4. You should be redirected back to your `successUrl`. 5. Check the database: ```sql theme={null} select id, provider, scene, status, amount from payment order by created_at desc limit 1; ``` You should see `provider='stripe'`, `status='paid'`, your test amount. 6. Re-run `stripe trigger checkout.session.completed` — no duplicate row, thanks to the `payment.invoiceId` / `sessionId` idempotency check. ## Common pitfalls * **Webhook secret mismatch** — the secret printed by `stripe listen` is ephemeral (rotates each session). Don't paste it into production. Use the one from the Dashboard endpoint. * **Test vs live confusion** — test-mode `sk_test_*` and live-mode `sk_live_*` prices are not interchangeable. Each price ID exists in only one mode. * **Tax not configured** — if you sell to the EU and don't enable Stripe Tax, you'll owe VAT out of pocket. Flip Stripe Tax on, or route those buyers through Creem instead. * **Missing `successUrl` / `cancelUrl`** — `createCheckout` throws if either is empty. They must be absolute URLs (`https://…`). * **Subscriptions without `subscription_data.metadata`** — recurring renewals emit `invoice.paid` events whose `metadata` is empty. The Stripe provider copies checkout `metadata` into `subscription_data.metadata` so renewals carry your `userId` / `scene` forward — don't strip that path. ## Official docs * Stripe Docs: [stripe.com/docs](https://stripe.com/docs) * Stripe CLI: [docs.stripe.com/cli](https://docs.stripe.com/cli) * Webhooks reference: [docs.stripe.com/webhooks](https://docs.stripe.com/webhooks) * Test cards: [docs.stripe.com/testing](https://docs.stripe.com/testing) * Stripe Tax: [stripe.com/tax](https://stripe.com/tax) # Quickstart Source: https://docs.vibestrap.dev/quickstart From a fresh clone to your first paid checkout in under an hour. This walks you from `git clone` to "a real human paid me \$X" in about an hour, assuming you have Stripe and Resend accounts ready. ## Prerequisites You've already done [Installation](/installation) — `pnpm dev` runs and you can sign up at `/register`. ## 1. Set your brand Open `src/config/site.ts`. Change: ```ts theme={null} export const siteConfig = { name: 'YourProduct', description: '...', shortDescription: '...', url: 'https://your-domain.com', links: { twitter: 'https://x.com/yourhandle', github: 'https://github.com/yourorg/yourrepo', }, // ... }; ``` This file is the buyer's "what to change". Keep it under \~150 lines — provider-specific configuration belongs in its own module (see [Configuration](/configuration)). ## 2. Wire Stripe ```bash theme={null} # .env.local STRIPE_SECRET_KEY=sk_test_… STRIPE_WEBHOOK_SECRET=whsec_… STRIPE_PRICE_VIBESTRAP_PROMO=price_… ``` Then in `src/config/site.ts` set `payment.provider = 'stripe'` (it already is). The full 4-provider walkthrough is at [Payments overview](/payments/overview). For local webhook testing, the Stripe CLI: ```bash theme={null} stripe listen --forward-to localhost:3000/api/webhooks/stripe ``` Copy the webhook signing secret it prints into `STRIPE_WEBHOOK_SECRET`. ## 3. Wire Resend ```bash theme={null} # .env.local RESEND_API_KEY=re_… RESEND_FROM_EMAIL=onboarding@your-domain.com ``` The verify / forgot / welcome templates ship in `src/mail/templates/`. They're React Email components — preview them with `pnpm dlx react-email dev` if you want to tweak the design visually. ## 4. Try the flow 1. Sign up at `/register`. The verification email lands in Resend's logs. 2. Click the verify link. 3. Visit `/pricing`, hit "Get Vibestrap for \$49", complete the Stripe Checkout. 4. Webhook fires → row inserted in `payment` → credits granted in `credit_transaction`. 5. Visit `/settings/purchases`, enter your GitHub username, click **Send invite** — you'll receive a read-only collaborator invitation to the source repo. If any of those steps fail, check the dev server log — every module prefixes its errors with `[stripe]`, `[mail]`, `[github-invite]`, etc. ## 5. Customize the product * **Hero copy** → `messages/en.json` and `messages/zh.json` (`Home.hero.*`) * **Pricing card** → `siteConfig.product.*` * **Feature list** → `messages/{en,zh}.json` (`Features.items.*`) + `src/components/features/items.ts` * **Theme colors** → `src/app/globals.css` (Tailwind v4 `@theme` directive) See [Customization](/customization) for the full surface map. ## 6. Deploy When you're ready to ship: ```bash theme={null} git push # to your GitHub repo # Then in Vercel: Import → set env vars → deploy. ``` Full walkthrough: [Deploying to Vercel](/deployment/vercel). ## 7. Add the AI feature If your product uses AI: 1. Pick a provider — set `AI_PROVIDER=openrouter` (or `openai` / `anthropic`) and the matching API key. 2. Use `useGeneration()` in any client component for streaming chat, or call `chat()` from `@/ai/manager` in a server action for non-streaming. 3. The cost ledger and observability happen automatically — see [AI overview](/ai/providers).