SaaS web app reference
Next.js on Vercel with Postgres, sign-in, organisations, Stripe checkout and email, on the SOC 2 baseline.
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.
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 |
| 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
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.tsapp.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 handlerWas this page helpful?