Building with Vercia Platform Core
This guide walks you through building a complete SaaS product on top of the framework — from first install to a running multi-tenant application.
The mental model
Vercia gives you three layers:
``
Your product
↓
Recipe (modules + config + plans + navigation + theme)
↓
Platform kernel (tenancy, RBAC, outbox, audit, crypto, …)
`
- Kernel — never touch this unless you are extending the platform itself
- Modules — install, enable, configure; add your own for domain logic
- Recipe — the glue: which modules, which plans, which nav routes, which theme
Step 1 — fork and rename
`bash
git clone <repo> my-product
cd my-product
rename the platform brand in .env
cp .env.example .env
set: PLATFORM_NAME=Acme, PLATFORM_DOMAIN=acme.com
`
Step 2 — configure your environment
Minimum required variables in .env:
`bash
PLATFORM_MASTER_KEY=<base64-32-bytes> # openssl rand -base64 32
SESSION_SECRET=<random-string>
DATABASE_URL=postgres://...
`
For development, docker compose up -d starts Postgres, Redis, MinIO, and Mailpit. Everything else defaults to localhost.
Step 3 — choose a recipe
The built-in recipe is recipes/saas — a B2B SaaS shape with 9 modules, 3 plans, and full navigation. To use it as-is:
`typescript
// apps/api/src/platform.module.ts already imports saasRecipe
import { saasRecipe } from "@vercia/recipe-saas";
`
To fork it, copy recipes/saas/ to recipes/my-product/ and edit src/index.ts:
`typescript
export const myRecipe = {
name: "My Product",
theme: "foundation",
modules: [
// pick the modules you need
{ id: "identity", manifest: identityManifest, migrations: identityMigrations, required: true },
{ id: "billing", manifest: billingManifest, migrations: billingMigrations, required: true },
// ...
],
roles: [
{ key: "tenant.owner", name: "Owner", scope: "tenant", permissions: ["*"], builtIn: true },
{ key: "tenant.member", name: "Member", scope: "tenant", permissions: ["content.page.read"], builtIn: true },
],
plans: [
{
key: "starter", name: "Starter", price: 0, currency: "USD",
entitlements: { "media.storage_gb": 1, "identity.seat": 5 },
},
{
key: "pro", name: "Pro", price: 4900, currency: "USD",
entitlements: { "media.storage_gb": 100, "identity.seat": 50 },
},
],
// navigation, pages, emailTemplates …
};
`
Step 4 — the API boots with your recipe
apps/api/src/main.ts provisions the entire recipe schema at startup (all module migrations, permissions, plans). Run:
`bash
pnpm --filter @vercia/app-api dev
`
The API is at http://localhost:3001. Check GET /health and GET /v1/platform/modules.
Step 5 — identity flows (register → org → dashboard)
The identity journey works out of the box:
| Flow | Route | Notes |
|------|-------|-------|
| Register | POST /v1/auth/register | emits identity.user.created → welcome email |
| Login | POST /v1/auth/login | sets vcs_session cookie |
| Create org | POST /v1/identity/tenants | auto-bootstraps owner role |
| Invite member | POST /v1/identity/members/invite | emits event → invitation email with accept link |
| Accept invite | POST /v1/identity/invitations/accept | joins org, sets tenant cookie |
The Next.js web app (apps/web/) wires all of these via server actions. Run pnpm --filter @vercia/app-web dev to start the UI.
Step 6 — add your domain logic
For a thin domain (a few CRUD resources that sit inside an existing module), add a controller to apps/api/src/ and a service class to the relevant module.
For a self-contained domain (its own schema, events, permissions), create a module — see docs/guides/custom-module.md.
Step 7 — billing and plans
The billing module uses a state machine (free → trialing → active → past_due → canceled). In dev, BILLING_PROVIDER=fake accepts any checkout. To wire Stripe:
`bash
.env
BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
`
The BillingService.checkout() call redirects to Stripe Checkout. Webhooks arrive at POST /v1/billing/webhook. Entitlements are evaluated locally (no Stripe API on every request).
Step 8 — file uploads
Media upload flow:
1. POST /v1/media/upload-intent → returns a signed S3 URL
2. Client PUTs the file directly to MinIO/S3
3. POST /v1/media/process → quarantine pipeline (magic bytes, zip bomb check, virus scan hook)
4. GET /v1/media/:id/download-url → signed download URL (short-lived)
In dev, MinIO runs at http://localhost:9000 (console :9001). The vercia bucket is auto-created on first boot.
Step 9 — email
The worker drains outbox events and sends emails via SMTP. In dev all mail is captured by Mailpit at http://localhost:8025.
Built-in triggers:
- identity.user.created
→ welcome email
- identity.invitation.created
→ invitation email with accept link
Add your own in apps/worker/src/main.ts:
`typescript
handlers.set("billing.subscription.activated", async (event) => {
const { tenantId, payload } = event;
// send "your trial started" email
});
`
Step 10 — custom theme
Edit themes/foundation/ or create a new theme:
`typescript
// themes/my-brand/tokens.ts
export const tokens = {
color: { brand: { 500: "#6d28d9" } },
font: { family: { sans: "'Inter', sans-serif" } },
};
`
Run pnpm --filter @vercia/theme-foundation build to compile CSS variables and React Native style objects.
Running in production
The platform is designed to run as:
- A single api
process (NestJS + Fastify) — horizontal scale behind a load balancer
- A single worker
process — scale to 1 replica per environment (outbox relay is idempotent)
- Postgres 16 — connection pooler (PgBouncer) recommended
- Redis — for rate limiting, cache, and future BullMQ job queues
- S3-compatible storage — AWS S3 or managed MinIO
- SMTP — AWS SES, Postmark, Resend, or Mailgun
Set OTEL_ENABLED=true and point OTEL_EXPORTER_OTLP_ENDPOINT at your collector for distributed traces, metrics, and structured logs.
See ADR 0010 (docs/architecture/decisions/0010-deployment.md) for the deployment model.
API overview
All API routes are prefixed /v1/.
| Area | Routes |
|------|--------|
| Auth | POST /auth/register POST /auth/login POST /auth/logout GET /auth/me |
| Identity | /identity/tenants /identity/members /identity/invitations /identity/roles /identity/api-keys |
| Content | /content/pages /content/navigation /content/redirects |
| Media | /media /media/upload-intent /media/process /media/:id/download-url |
| Billing | /billing/subscription /billing/checkout /billing/webhook /billing/credits |
| Forms | /forms /forms/:id/submit /forms/:id/submissions |
| Features | /features /features/:key/evaluate |
| Communications | /communications/messages /communications/suppression |
| Messaging | /messaging/conversations /messaging/conversations/:id/messages |
| Platform | GET /platform GET /platform/modules |
| Health | GET /health GET /health/readiness |
Every route (except /auth/* and /health) requires a valid session cookie (vcs_session) and a tenant header (x-tenant-id`).