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`).