Skip to main content
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

LayerTechWhy
FrameworkNext.js 15 (App Router, RSC, Turbopack)Server-first rendering, mature ecosystem.
RuntimeReact 19Async server components, use() hook.
LanguageTypeScript 5 (strict)Catches the wrong shape before it ships.
StylingTailwind v4 + shadcn/uiOKLCH colors, CSS-first config in globals.css.
ORMDrizzleTyped schema, no codegen, raw-SQL escape hatch.
DatabasePostgres only (v0.1)One source of truth, transactions, JSONB.
AuthBetter Auth + Drizzle adapterServer-side sessions, OAuth, email verification.
i18nnext-intlBilingual EN/ZH with as-needed URL prefix.
DocsFumadocs MDXBilingual *.zh.mdx with locale-aware lookup.
PaymentsStripe / Paddle / Lemon Squeezy / CreemBehind a single facade.
AIMock / OpenRouter / OpenAI / Anthropic / Replicate / falPhase 2; same facade pattern.
MailResend + React EmailTemplated, 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.<module>.provider. Consumers see only the facade.
// src/payment/index.ts
function pickProvider(): PaymentProvider {
  switch (siteConfig.payment.provider) {
    case 'stripe': return stripeProvider;
    case 'paddle': return paddleProvider;
    case 'lemonsqueezy': return lemonSqueezyProvider;
    case 'creem': return creemProvider;
  }
}
export const paymentManager: PaymentProvider = pickProvider();
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, license.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/<provider>   [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