MainSDK
Search⌘K
How-to

SaaS web app reference

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

Planned for 0.7

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

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)
DatabaselocalPostgres on Neon
Sign-inlocal dev sign-in (Bearer dev_ada)passkeys and magic links, or Better Auth or Clerk as the driver
Emaillocal mailboxResend with an SES fallback
Paymentslocal test cardsStripe hosted checkout
Share links and fileslocalgetqr.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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet