---
title: "SaaS web app reference"
description: "Next.js on Vercel with Postgres, sign-in, organisations, Stripe checkout and email, on the SOC 2 baseline."
section: "guides"
status: "Planned for 0.7"
spec: "reference-architectures"
canonical: "https://mainsdk.dev/docs/guides/reference-saas-web"
updated: "2026-10-28"
---

# SaaS web app reference

> Next.js on Vercel with Postgres, sign-in, organisations, Stripe checkout and email, on the SOC 2 baseline.

Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/reference-saas-web · Updated: 2026-10-28

<!-- generated by tools/build_reference.py from packages/cli/templates/mainsdk/saas-web; edit the template, not this page -->

Start from it: `npx mainsdk init --template mainsdk/saas-web`

The reference architecture for a SaaS web app: sign-in, organisations with invites, hosted checkout and email, on MainSDK interfaces only. Pack: SOC 2 baseline.

| | Offline (`mainsdk dev app.ts`) | Production (`main.env.prod.yaml`) |
| --- | --- | --- |
| Database | local | Postgres on Neon |
| Sign-in | local dev sign-in (`Bearer dev_ada`) | passkeys and magic links, or Better Auth or Clerk as the driver |
| Email | local mailbox | Resend with an SES fallback |
| Payments | local test cards | Stripe hosted checkout |
| Share links and files | local | getqr.id |

Runs with no account and no provider key: `npm run dev`. `npm test` runs the tests and the locked flows (sign-in, organisation invite, checkout, email).

Every change and every MainSDK release runs `.github/workflows/reference.yml`: `mainsdk check`, `mainsdk drill restore --to scratch` and `mainsdk assure --standard soc2`, `npm run bench` and `npm run chaos` (a 60 second Resend outage must send all 20 emails through SES, none lost, none twice). `npm run verify-ci` fails when a job is removed.

Moving from Supabase, Firebase or Auth.js? `mainsdk migrate <from>` ends here.

## Performance

The benchmark harness fails a change when p95 goes over 25 ms on local drivers (`npm run bench`).

## main.yaml

```yaml
schema: 1
project: prj_template_saas_web
baseline: main27
auth:
  driver: local
  methods: [passkeys, link]
  admin: { require: phishing-resistant }
db:
  driver: local
files:
  driver: local
  max-size: 25MB
share:
  driver: local
  default-expiry: 7d
email:
  driver: local
  from: "Pinecart <hello@pinecart.dev>"
  templates:
    welcome:
      subject: "Welcome to {{org}}"
      text: "Your organisation {{org}} is ready."
    invite:
      subject: "Join {{org}}"
      text: "Join {{org}}: {{link}}"
pay:
  driver: local
logging:
  audit: true
  retention: 12m
network:
  allow: []
  default: deny
  mode: observe
# Evidence for `mainsdk assure --standard soc2`: change control is on (a pull request and a green gate before every merge),
# and the providers the app runs on are registered.
compliance:
  change-control: true
  providers: [local, ./backup.ts]
backup:
  rpo: 15m
  rto: 1h
  keep: 7d
  drill: { every: 30d, to: scratch }
  driver: ./backup.ts
```

## app.ts

```ts
// The SaaS web app reference architecture: sign-in, organisations, invites, hosted checkout and email. Every outside
// service is an interface (auth, share, email, pay), so the same code runs on local drivers offline and on Neon,
// Resend with an SES fallback, Stripe and getqr.id in production (main.env.prod.yaml).
import { randomBytes, randomUUID } from 'node:crypto'
import { auth, email, MainError, pay, share } from 'mainsdk'

type Role = 'owner' | 'admin' | 'member'
type Org = { id: string; name: string; members: Map<string, Role> }

/** Build the handler and its state; a test makes its own so nothing is shared. */
export function createSaasWeb(): (req: Request) => Promise<Response> {
  const orgs = new Map<string, Org>()
  const invites = new Map<string, { org: string; email: string; role: Role }>()
  const json = (status: number, body: unknown): Response => Response.json(body, { status })

  /** Answer a request; a refusal from an interface is its own problem answer, never a crash. */
  const answer = async (req: Request): Promise<Response> => {
    const url = new URL(req.url)
    const path = url.pathname
    if (req.method === 'GET' && path === '/health') return json(200, { ok: true })
    const session = await auth.require(req)
    const user = session.user
    if (req.method === 'GET' && path === '/me') return json(200, { user })

    if (req.method === 'POST' && path === '/orgs') {
      const body = (await req.json()) as { name?: string; email?: string }
      const org: Org = {
        id: randomUUID(),
        name: String(body.name ?? 'My organisation'),
        members: new Map([[user, 'owner']]),
      }
      orgs.set(org.id, org)
      if (body.email)
        await email.send({
          to: body.email,
          template: 'welcome',
          data: { org: org.name },
          idempotencyKey: `welcome:${org.id}`,
        })
      return json(201, { id: org.id, name: org.name })
    }

    const m = /^\/orgs\/([^/]+)\/(invites|checkout)$/.exec(path)
    if (m && req.method === 'POST') {
      const org = orgs.get(m[1] as string)
      const role = org?.members.get(user)
      if (!org || !role) return json(404, { error: 'not found' })
      if (m[2] === 'invites') {
        if (role === 'member') return json(403, { error: 'owners and admins invite' })
        const body = (await req.json()) as { email: string; role?: Role }
        const token = randomBytes(24).toString('base64url')
        invites.set(token, { org: org.id, email: body.email, role: body.role ?? 'member' })
        // Share links go through getqr.id (the share interface): private, expiring, read-only.
        const link = await share(
          { url: `${url.origin}/invites/${token}`, title: `Join ${org.name}` },
          { access: 'read' }, // expires after share.default-expiry (7d) in main.yaml
        )
        await email.send({
          to: body.email,
          template: 'invite',
          data: { org: org.name, link: link.url },
          idempotencyKey: `invite:${token}`,
        })
        return json(201, { invite: token, link: link.url, expiresAt: link.expiresAt })
      }
      const checkout = await pay.checkout({
        order: org.id,
        amount: '$29.00',
        currency: 'USD',
        successUrl: `${url.origin}/billing/done`,
        cancelUrl: `${url.origin}/billing`,
        idempotencyKey: `checkout:${org.id}`,
      })
      return json(200, checkout)
    }

    const accept = /^\/invites\/([^/]+)\/accept$/.exec(path)
    if (accept && req.method === 'POST') {
      const invite = invites.get(accept[1] as string)
      if (!invite) return json(404, { error: 'no such invite' })
      invites.delete(accept[1] as string)
      orgs.get(invite.org)?.members.set(user, invite.role)
      return json(200, { org: invite.org, role: invite.role })
    }
    return json(404, { error: 'not found' })
  }

  return async (req) => {
    try {
      return await answer(req)
    } catch (e) {
      if (e instanceof MainError)
        return json(e.status, { code: e.code, title: e.title, fix: e.fix })
      throw e
    }
  }
}

const handler = createSaasWeb()

/** The entry `mainsdk dev app.ts` serves. */
export default handler
```
