Overview
High-level system diagram and component responsibilities.
01 — System Architecture Overview
At a glance
Craftkit is a TypeScript monorepo of two deployable apps and several shared packages, coordinated by Turborepo. The apps deploy independently to suit their runtime profiles.
┌──────────────────────────────────────────────────────────────────┐
│ Marketing site │
│ (apps/web · /) │
└──────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────────────────────────┐
│ Dashboard (auth) │
│ (apps/web · /dashboard/...) │
│ · Sign-up / login (better-auth) │
│ · Projects, templates, renders, API keys, webhooks │
│ · Embed: keys, origins, catalogs, sessions, sandbox │
└──────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────────────────────────┐
│ Public REST API │
│ (apps/web · /v1/...) │
│ · POST /v1/templates/:slug/render (API-key auth) │
│ · GET /v1/renders/:id (poll) │
│ · POST /v1/hooks/:token (inbound, per-template) │
│ · POST /v1/embed/sessions (mint embed JWT) │
│ · POST /v1/embed/sessions/refresh (renew) │
└──────────────────────────────────────────────────────────────────┘
│
enqueue render jobs
▼
┌──────────────────────────────────────────────────────────────────┐
│ Render worker │
│ (apps/render-worker) │
│ · BullMQ consumer │
│ · Handlebars compile · Puppeteer print · S3 upload │
│ · Outgoing webhooks with HMAC │
└──────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────────────────────────────────────────┐
│ Embed iframe page │
│ (apps/web · /embed/builder) │
│ · No-chrome layout │
│ · JWT-validated │
│ · Catalog-aware variable picker │
│ · postMessage protocol with parent window │
└──────────────────────────────────────────────────────────────────┘Apps
apps/web
A Next.js 15 (App Router) application that owns:
- The marketing site (
/) - The authenticated dashboard (
/dashboard/...) - The embed iframe page (
/embed/builder) - The public REST API (
/v1/...) - The internal tRPC layer (used by dashboard server actions)
Deploys to Vercel or any Node runtime. Stateless except for the database.
apps/render-worker
A standalone Node process that consumes BullMQ jobs and renders PDFs via Puppeteer. Kept separate from the web app because:
- Puppeteer's memory profile (~300 MB resident per worker) doesn't fit a serverless function.
- Render concurrency tuning is independent from request concurrency.
- Worker can be scaled horizontally or replaced (e.g., with
@react-pdf/renderer) without touching the API.
Deploys to Railway, Fly.io, or any Docker-friendly host.
Packages
| Package | Purpose |
|---|---|
@craftkit/db |
Drizzle schema, migrations, typed client. Exports both node-postgres (worker) and postgres-js (web) flavors via the same schema. |
@craftkit/render-core |
Pure (framework-agnostic) renderer: Tiptap doc → variable manifest → Zod/JSON Schema → Handlebars-compiled HTML. Used by both web (server actions on save) and worker (render time). |
@craftkit/schema |
Shared Zod schemas: variable manifest, render request, webhook envelope. Single source of truth for API contracts. |
@craftkit/sdk |
Public TypeScript client (@craftkit/sdk) — what customers npm install. Zero deps. |
@craftkit/embed |
Public embed SDK — drop-in <script> partners use to mount the iframe. |
@craftkit/ui |
Shared shadcn primitives + brand tokens + cn() helper. |
@craftkit/tsconfig |
TS preset configs: next.json, node.json, react-library.json. |
External infrastructure
| Component | Dev | Production |
|---|---|---|
| Database | Postgres 16 (Docker) | Neon / Supabase / managed PG |
| Queue | Redis 7 (Docker) | Upstash Redis |
| Object storage | MinIO (Docker, S3-compat) | Cloudflare R2 |
| PDF engine | Puppeteer + bundled Chromium | Puppeteer + @sparticuz/chromium |
| Email (v0.2+) | Resend / Postmark via partner BYO keys | same |
| AI (v0.2+) | OpenAI / Anthropic / Together via provider abstraction | same |
Request flow — synchronous render API
1. Customer POSTs /v1/templates/:slug/render (Bearer ck_live_…)
2. Auth middleware resolves API key → project_id
3. Load template (slug + project) and its current_version
4. Validate request body against version's auto-generated Zod schema
5. Insert renders row (status=queued)
6. Enqueue BullMQ job with render_id
7. Respond 202 { id, status, poll_url }Request flow — async worker
1. Worker pulls job from queue
2. Load render → template_version → input data
3. Compile: render-core renders Handlebars(compiledHtml, data) → final HTML
4. Print: Puppeteer launches a page, sets HTML, prints PDF
5. Upload PDF to S3/R2, generate signed URL
6. Update renders row (status=succeeded, asset_url)
7. Enqueue webhook deliveries (separate queue)
8. Webhook deliverer POSTs to each registered webhook with HMAC signatureBoundaries
- No shared mutable state. Everything is in Postgres + Redis + S3. Workers can be killed and restarted at any time.
- No partner data crosses boundaries. Embed iframe has no read access to partner DB; partner backend never reads Craftkit's internal state except via documented API.
- No tenant data leakage between projects. Every query is scoped by
project_idat the data-access layer.
Configuration & secrets
All runtime config flows through environment variables documented in
engineering/02-environment-variables.md. Secrets are never stored in
code. JWT signing keys are stored in DB (rotated via admin tools), API
keys are hashed at rest with the prefix retained for identification.
Anti-residue policy
Craftkit was inspired by patterns from a separate codebase, but zero
identifiers, vocabulary, schemas, or values from that codebase appear here.
See architecture/07-anti-residue.md. The check is enforced by
scripts/anti-residue-check.sh in CI.
Last revised: 2026-05-02