Read time: ~2 hours. This document explains every file, every decision, every tradeoff. Written for: junior developers, hackathon judges, interviewers, and anyone joining the project.
Imagine you need to collect survey responses, job applications, or user feedback. You could build a form from scratch — but that means writing HTML, validation, a database schema, an API, and an analytics dashboard. ScribbleForms solves all of this as a hosted service, just like Typeform or Google Forms, but built as a production-grade SaaS with a proper backend architecture.
What creators can do:
- Build forms with 13 different field types (text, email, rating, multi-select, file upload, etc.)
- Set fields as required or optional, with custom validation rules
- Apply themes (visual styles) to forms
- Publish forms publicly or as "unlisted" (only accessible via direct link)
- View responses in a dashboard
- See analytics (completion rates, drop-off, device breakdown)
- Set response limits, expiry dates, password protection
- Get webhook notifications when responses arrive
- Export responses as CSV
What respondents can do:
- Fill forms without creating an account
- Submit validated responses
- See custom success messages
Before touching any code, let's understand WHY each technology was chosen. This is the most important section for interviews.
Simple explanation: PostgreSQL is like a giant spreadsheet program that lives on a server and can hold millions of rows with relationships between them.
Why PostgreSQL specifically?
- It has JSONB columns (stores arbitrary JSON data efficiently — used for field config and form version snapshots)
- It has proper ACID transactions (all-or-nothing writes — critical for form submissions)
- It has
ON CONFLICT DO UPDATE(upsert) — used for analytics daily aggregates - It has array operators for JSONB (
@>) — used in webhook filtering - Free hosting available on Neon, Supabase, Railway
Why NOT MongoDB?
- Our data IS relational: forms have fields, responses have answers, users have forms
- MongoDB's flexibility is unnecessary here and loses us JOIN performance
- ACID transactions in MongoDB are complex; in PostgreSQL they're built-in
Interview answer: "PostgreSQL was chosen for its JSONB support (needed for dynamic field configs), ACID compliance (critical for atomic response submission), and its mature ecosystem. The data model is fundamentally relational, so a document database would have been the wrong tool."
Simple explanation: Drizzle is a tool that lets you write database queries using TypeScript instead of raw SQL strings. But unlike most ORMs, Drizzle stays very close to SQL — you can see exactly what query it generates.
How it works internally:
// You write this TypeScript:
const users = await db.select().from(usersTable).where(eq(usersTable.email, "alice@example.com"));
// Drizzle generates this SQL:
// SELECT * FROM users WHERE email = 'alice@example.com'Drizzle doesn't hide the SQL from you. Every method (.select(), .from(), .where()) directly maps to a SQL clause. This means no surprise "N+1 queries" — a common problem with ORMs like Prisma that do lazy loading behind your back.
Why NOT Prisma?
- Prisma has its own schema language (
.prismafile) — a DSL you have to learn - Prisma's migration system is opaque (it generates migrations for you, which can be wrong)
- Prisma's client is a singleton — hard to use cleanly in monorepos with multiple packages
- Prisma does "magic" lazy loading that causes N+1 queries without you realizing
- Drizzle schemas are pure TypeScript — importable, exportable, tree-shakeable
Why NOT raw SQL strings?
"SELECT * FROM users WHERE email = '" + email + "'"is a SQL injection vulnerability- TypeScript can't check column names in raw strings — typos become runtime errors
- Drizzle's
eq(usersTable.email, email)— ifemailcolumn doesn't exist, TypeScript error at compile time
The Drizzle schema defines the TypeScript types AND the database structure:
export const usersTable = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(), // → uuid column in DB, string in TypeScript
email: varchar("email", { length: 255 }).notNull().unique(), // → NOT NULL UNIQUE constraint
plan: planEnum("plan").default("free").notNull(), // → enum column
});
// Drizzle infers: typeof usersTable.$inferSelect = { id: string, email: string, plan: "free"|"creator"|"studio" }Interview answer: "Drizzle was chosen over Prisma because it's schema-as-TypeScript (not a DSL), produces predictable SQL, integrates cleanly in monorepos, and avoids hidden N+1 queries. It gives us the type safety of an ORM with the transparency of raw SQL."
Simple explanation: Normally, the frontend and backend are separate programs that communicate by sending JSON strings over HTTP. tRPC is a library that makes it feel like you're just calling a function on the backend directly from the frontend — with full TypeScript type checking.
Without tRPC:
// Frontend has to guess what the API returns
const response = await fetch("/api/forms");
const data = await response.json(); // TypeScript type: any — no safety
const title = data.forms[0].titl; // typo! but no error until runtimeWith tRPC:
// Frontend gets the exact same types as the backend
const { data } = trpc.forms.list.useQuery({ limit: 20 });
const title = data.forms[0].titl; // TypeScript ERROR: property 'titl' doesn't exist
// TypeScript knows the shape of every response because it's inferred from the server codeHow it works internally (the magic):
- The server defines a router with typed procedures:
export const serverRouter = router({ forms: formsRouter, // contains list, create, getById, etc. }); export type ServerRouter = typeof serverRouter; // ← this type is the key
- The frontend imports ONLY the type (not the actual code):
import type { ServerRouter } from "@repo/trpc/server"; // type-only import const trpc = createTRPCReact<ServerRouter>();
- TypeScript uses
ServerRouterto infer what inputs each procedure accepts and what it returns. No code is shipped to the browser — just the type information.
Why not REST + OpenAPI?
- REST requires manually writing OpenAPI schemas AND TypeScript types — two sources of truth that can drift
- With tRPC, there's one source of truth: the server router
- We also expose a REST API via
trpc-to-openapi— so we get BOTH REST AND tRPC from one definition
Why not GraphQL?
- GraphQL requires learning its own schema language, resolvers, and client libraries
- tRPC has zero learning curve if you know TypeScript
- GraphQL's flexibility (clients can request any shape) is not needed here — the frontend always knows what it needs
Interview answer: "tRPC provides end-to-end type safety with zero code generation. The ServerRouter type is the single source of truth — frontend types are inferred from server code. We also expose REST via trpc-to-openapi, giving us two API protocols from one definition."
Simple explanation: Redis is like a super-fast dictionary (key → value store) that lives in RAM. Looking something up in Redis takes ~0.5ms; looking it up in PostgreSQL takes ~5ms. It's also used as a message queue — the API puts jobs in a queue, workers pick them up.
Why Redis specifically?
- Session caching: Every authenticated API call needs to validate the session token. Without Redis: every call hits PostgreSQL (~5ms each). With Redis: cache hit in ~0.5ms. At 100 requests/second, that's the difference between 500ms of DB load and 50ms.
- Form caching: A popular public form might get 1000 hits per minute. Without caching: 1000 PostgreSQL queries per minute. With 60-second Redis cache: 1 query per minute.
- BullMQ queues: BullMQ (the background job library) uses Redis as its persistence layer. Jobs are stored in Redis sorted sets.
Why not store sessions in PostgreSQL?
- Session validation happens on EVERY request. A 10ms DB query on every API call adds up fast.
- Redis handles 100,000+ operations per second vs PostgreSQL's ~10,000
- Sessions have natural TTLs (expire after 30 days) — Redis has built-in TTL (
SETEX key 900 value)
Why not use memory (in-process caching)?
- If you have 3 API server instances, in-process cache is not shared between them
- A server restart wipes the cache
- Redis works across multiple server instances and survives restarts
Cache invalidation strategy (the hardest problem in CS): We invalidate caches immediately when data changes:
// When a form is updated → delete its cache entry
await cacheDel(`sf:form:public:${form.slug}`);
// When a form is published/unpublished → delete the explore cache
await cacheDelPattern("sf:explore:*");Interview answer: "Redis serves two roles: caching (session tokens, form data, analytics) and queue persistence (BullMQ). The session cache eliminates DB round trips on every authenticated request. The queue enables async processing of emails, webhooks, and analytics without blocking HTTP responses."
Simple explanation: Instead of having separate GitHub repos for the frontend, backend, and shared code, everything lives in ONE repo. Turborepo is the tool that manages building and running multiple packages in the right order.
Why a monorepo? Imagine this scenario WITHOUT a monorepo:
- You change the
Usertype in the backend - You have to update the backend package, publish it to npm
- Then update the frontend package.json to use the new version
- Then update the frontend code
- Three separate commits, two npm publishes, possible version drift
WITH a monorepo and Turborepo:
- You change the
Usertype inpackages/trpc - The change is immediately visible in
apps/apiANDapps/web - TypeScript shows errors everywhere the change breaks something
- One commit fixes everything
How Turborepo's build cache works: Turborepo hashes your source files. If the files haven't changed since the last build, it uses the cached build output instead of rebuilding. On CI, this makes subsequent runs take seconds instead of minutes.
The dependency graph:
apps/api depends on packages/trpc depends on packages/database
apps/web depends on packages/trpc ↓
packages/redis
packages/queues
packages/constants
packages/validators
Turborepo builds in this order automatically. You never manually specify "build database before trpc before api."
Simple explanation: Zod is a library that checks if data matches a shape you define. You describe what data should look like, and Zod tells you if it does — at runtime, with proper error messages.
Why validation at all? When data comes from outside your system (HTTP requests, environment variables, form submissions), you can't trust its shape. Zod validates it and gives you a typed result.
How Zod works:
const schema = z.object({
email: z.string().email(), // must be a string AND a valid email format
password: z.string().min(8).regex(/[A-Z]/), // string, 8+ chars, contains uppercase
});
// Runtime validation:
const result = schema.safeParse({ email: "bad", password: "short" });
// result.success = false
// result.error.flatten() = { email: ["Invalid email"], password: ["String must contain at least 8 char(s)"] }
// If valid, TypeScript KNOWS the shape:
const result2 = schema.parse({ email: "alice@test.com", password: "Password1" });
// TypeScript type of result2: { email: string, password: string }Three places Zod is used in ScribbleForms:
- Env validation (
apps/api/src/env.ts): Fails at startup if required env vars are missing - API input/output (every tRPC procedure):
.input(schema)validates incoming data,.output(schema)strips sensitive fields from responses - Dynamic form validation (
packages/validators/src/submission-schema.ts): Builds a Zod schema at runtime based on form field configuration
The most powerful use — dynamic schema generation:
// A form has these fields from the database:
const fields = [
{ id: "f1", type: "email", required: true },
{ id: "f2", type: "rating", required: false, config: { max: 5 } },
];
// We generate a Zod schema dynamically:
const schema = buildFieldSchema(fields);
// Equivalent to: z.object({ f1: z.string().email(), f2: z.number().min(1).max(5).optional() })
// This exact schema validates form submissions on the backend
// The SAME schema can be used on the frontend for real-time validationInterview answer: "Zod provides runtime validation with compile-time type inference. The key insight is z.infer<typeof schema> — you define validation rules once and get TypeScript types for free. The shared @repo/validators package means frontend and backend validate with identical rules."
scribbleforms/
│
├── apps/ ← Runnable applications (deployable units)
│ ├── api/ ← The HTTP server (what runs on Render)
│ └── worker/ ← Background job processor (separate Render service)
│
├── packages/ ← Shared libraries (not deployable themselves)
│ ├── database/ ← Schema + migrations + seed + DB client
│ ├── trpc/ ← All API logic: routers, services, repositories
│ ├── validators/ ← Shared Zod schemas (frontend + backend)
│ ├── redis/ ← Redis client singleton + helpers
│ ├── queues/ ← BullMQ queue definitions
│ ├── constants/ ← Plan limits, field registry, rate limits
│ └── logger/ ← Structured logging
│
├── turbo.json ← Turborepo pipeline configuration
├── pnpm-workspace.yaml ← Tells pnpm about the monorepo structure
└── README.md ← Getting started guide
apps/ = things you deploy. Each app is a separate process with its own package.json and its own startup command. You deploy them independently.
packages/ = things you share. They're libraries — they don't run on their own. They're imported by apps.
Critical insight: The apps/worker is separate from apps/api even though they share code (both use @repo/queues, @repo/database). Why? Because:
- Workers hold long-lived Redis connections — the BullMQ "blocking pop" pattern. These block the event loop in ways that would hurt HTTP response times if mixed.
- A crashing worker (e.g., a malformed webhook payload causes an unhandled exception) should NOT crash the API server.
- You can scale them independently — add more worker instances when emails pile up without touching the API.
Purpose: Single source of truth for the database. Every other package imports from here.
What's inside:
models/— Drizzle table definitions (one file per entity)schema.ts— Barrel file that re-exports all models (so other packages doimport { usersTable } from "@repo/database/schema")index.ts— Creates and exports the Drizzle DB clientdrizzle.config.ts— Config for Drizzle Kit (the migration CLI tool)env.ts— ValidatesDATABASE_URLenv varseed/index.ts— Populates demo data
Why schema in a separate package?
Both packages/trpc and apps/worker need to query the database. If the schema lived inside apps/api, the worker couldn't import it without circular dependencies. A shared packages/database solves this cleanly.
What happens if you delete it? Everything breaks. Every package depends on it.
The database client:
// packages/database/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
export const db = drizzle(env.DATABASE_URL, { schema });
// The { schema } option enables Drizzle's relational query builder
// Without it, you can't use db.query.usersTable.findFirst()Purpose: Contains ALL business logic. This is the most important package.
What's inside:
packages/trpc/server/
├── trpc.ts ← Initializes the tRPC instance
├── context.ts ← Defines the TRPCContext type
├── index.ts ← Assembles all routers into one serverRouter
├── errors/ ← Custom error codes with domain-specific messages
├── middleware/
│ ├── auth.middleware.ts ← isAuthenticated middleware
│ └── plan.middleware.ts ← plan feature gating
├── utils/
│ └── audit.ts ← Fire-and-forget audit log writer
└── routes/
├── auth/ ← Authentication (login, signup, OAuth, etc.)
│ ├── schema.ts ← Zod input/output schemas
│ ├── repository.ts ← Database queries
│ ├── service.ts ← Business logic
│ ├── route.ts ← tRPC procedures
│ ├── utils.ts ← Cookie helpers
│ └── constants.ts ← AUTH_COOKIE_NAME
├── forms/ ← Form CRUD + publish/unpublish + public access
├── fields/ ← Field CRUD + reorder + duplicate
├── responses/ ← Response list + export
├── analytics/ ← Stats, dashboard, event tracking
├── themes/ ← Theme list + apply
├── webhooks/ ← Webhook CRUD + test
├── api-keys/ ← API key CRUD
└── health/ ← Health check
The four-layer pattern (schema → repository → service → route):
Every route follows the same four files:
schema.ts → What data looks like (Zod schemas)
repository.ts → How to talk to the database (raw Drizzle queries)
service.ts → Business rules (what you're allowed to do)
route.ts → HTTP endpoint (input → call service → output)
Why this separation?
repository.ts: "Give me the form with id X from the database"
service.ts: "Give me form X, but only if it's published and not expired"
route.ts: "The user requested form X. Validate their session, call service, return JSON"
Each layer has ONE job. This is the Single Responsibility Principle in practice:
- Want to change how pagination works? Edit
repository.ts - Want to change the business rule about expired forms? Edit
service.ts - Want to change the HTTP method from GET to POST? Edit
route.ts - Want to change the response shape? Edit
schema.ts
Testing benefit: Services are testable without an HTTP server. Repositories are mockable. Tests in server/__tests__/ mock the repository and test only the service logic.
Purpose: Zod schemas and validation utilities used by BOTH frontend and backend.
The key insight:
Before: buildFieldSchema() lived in apps/api/src/routes/submit.ts
After: buildFieldSchema() lives in packages/validators/src/submission-schema.ts
Why does this matter? The frontend can now import the same schema:
// On the frontend (form renderer):
import { buildFieldSchema, applyConditions } from "@repo/validators";
// Real-time validation with the EXACT same rules as the backend
// Zero possibility of frontend/backend validation driftWhat's inside:
submission-schema.ts—submissionEnvelopeSchema(envelope validation) +buildFieldSchema()(dynamic field validation)field-conditions.ts—applyConditions()(evaluates conditional logic — which fields are visible)
Interview talking point: "The shared validators package is the architectural signal that we take validation seriously. A field that's required on the backend is required on the frontend. They use the exact same code."
Purpose: One Redis client instance shared across the entire application.
Why a singleton? Each Redis connection has overhead. If every service created its own connection, you'd exhaust the connection pool quickly. A singleton means one connection per process.
// packages/redis/src/index.ts
let redisInstance: Redis | null = null;
export function getRedis(): Redis {
if (!redisInstance) {
redisInstance = new Redis(process.env.REDIS_URL!);
}
return redisInstance; // returns the SAME instance every time
}The cache helper functions:
// cacheGet: wraps redis.get() with JSON.parse + error handling
export async function cacheGet<T>(key: string): Promise<T | null> {
try {
const val = await redis.get(key);
return val ? JSON.parse(val) as T : null;
} catch {
return null; // Redis down → cache miss → fall through to DB (graceful degradation)
}
}Why try/catch in every cache operation?
A Redis outage should NOT crash the API. By catching errors and returning null, every cache miss simply falls through to the database. The service is slower but still functional. This is called graceful degradation.
Purpose: Defines BullMQ queues used by both the API (to enqueue jobs) and the worker (to process jobs).
Why shared between API and worker? Both need to reference the same queue name ("email", "webhook", etc.). If these were defined separately, a naming mismatch would silently break delivery.
// API enqueues:
safeEnqueue(getEmailQueue(), "welcome", { type: "welcome", userId: "..." });
// Worker dequeues from same queue:
const emailWorker = new Worker("email", async (job) => { /* process */ });The safeEnqueue wrapper:
export async function safeEnqueue(queue: Queue, jobName: string, data: unknown): Promise<void> {
try {
await queue.add(jobName, data);
} catch {
// Queue unavailable (Redis down) → log and continue
// The form submission still succeeds even if the email fails to queue
}
}This is the "fire and forget" pattern — the HTTP response is sent regardless of whether the side effect (email, webhook) succeeds.
Purpose: Constants shared across packages that can't change at runtime.
plan-limits.ts — The subscription tier definitions:
export const PLAN_LIMITS = {
free: { maxForms: 3, maxFieldsPerForm: 10, hasWebhooks: false, ... },
creator: { maxForms: 20, maxFieldsPerForm: 50, hasWebhooks: false, ... },
studio: { maxForms: -1, maxFieldsPerForm: -1, hasWebhooks: true, ... },
};
// -1 means unlimitedfield-registry.ts — How to serialize/deserialize each field type:
export const FIELD_REGISTRY = {
short_text: {
serializeAnswer: (value) => ({ valueText: String(value) }), // how to store in DB
deserializeAnswer: (row) => row.valueText, // how to read from DB
},
rating: {
serializeAnswer: (value) => ({ valueNumber: String(value) }), // number → DB
deserializeAnswer: (row) => row.valueNumber ? parseInt(row.valueNumber) : null,
},
multi_select: {
serializeAnswer: (value) => ({ valueArray: value as string[] }), // array → DB JSONB
deserializeAnswer: (row) => row.valueArray ?? [],
},
};Why a field registry?
Different field types store their values in different columns (value_text, value_number, value_array). The registry is a map from field type → serialization strategy. Adding a new field type means adding one entry to this registry — no code changes elsewhere.
Simple explanation: Express is the web framework. It receives HTTP requests, runs middleware, and sends responses. Think of middleware as a pipeline — every request passes through each piece of middleware in order.
Why Express if we're using tRPC? tRPC is not a standalone server — it's a library that handles RPC calls. It needs to be mounted ON an HTTP server. Express provides:
- Cookie parsing (
cookie-parsermiddleware) - CORS headers
- Security headers (
helmet) - Rate limiting
- Custom REST routes (OAuth, form submission, file upload)
The middleware pipeline:
HTTP Request
↓
helmet → Sets security headers (X-Frame-Options, Content-Security-Policy, etc.)
↓
cors → Allows frontend origin, enables credentials (cookies)
↓
json() → Parses request body as JSON
↓
cookieParser → Parses cookies into req.cookies object
↓
trustProxy → Trusts X-Forwarded-For header (needed behind Render/Railway load balancers)
↓
X-Request-Id → Adds unique trace ID to every response
↓
rateLimiters.global → Max 200 requests/minute per IP
↓
/auth/google/* → OAuth routes (Express REST, not tRPC — needs redirects)
↓
/upload/sign → Cloudinary pre-sign (Express REST)
↓
/f/:slug/submit → Form submission (Express REST, special rate limiter per slug)
↓
/api/* → REST adapter (trpc-to-openapi converts REST ↔ tRPC)
↓
/trpc/* → Native tRPC adapter (@trpc/server/adapters/express)
↓
404 handler → Returns { code: "NOT_FOUND" }
↓
Error handler → Returns { code: "INTERNAL_SERVER_ERROR" }
Why are submission, OAuth, and upload NOT tRPC routes?
- Form submission (
/f/:slug/submit): Needs per-slug rate limiting. The slug comes from the URL path, not the request body. tRPC routes receive the input as JSON body or query params — URL path parameters aren't natural for tRPC. - Google OAuth (
/auth/google/*): OAuth requires HTTP redirects (res.redirect(url)). tRPC procedures return data, not redirects. - Upload signing (
/upload/sign): Could be tRPC, but the response is raw JSON for Cloudinary — no benefit from tRPC type inference here.
Why validate env vars at startup?
Without validation:
Server starts ✓
User signs up ✓
User tries password reset ✗ → crash: "RESEND_API_KEY is not defined"
With validation (our approach):
Server starts → validates env → RESEND_API_KEY missing → process.exit(1) with clear error
// Deployment fails immediately, not 3 hours later when a user triggers the code path
How it works:
const envSchema = z.object({
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), // must exist and be non-empty
PORT: z.coerce.number().default(8000), // coerce means "convert string '8000' to number 8000"
REDIS_URL: z.string().default("redis://localhost:6379"), // optional with default
});
export const env = envSchema.parse(process.env); // throws on startup if invalidz.coerce.number() is needed because ALL environment variables are strings. process.env.PORT is "8000" (string), not 8000 (number). Zod's coerce automatically converts it.
Simple explanation: Context is a JavaScript object that tRPC creates for EVERY request and passes to every procedure handler. It's how procedures know who the user is, how to set cookies, and how to access shared services.
What's in the context:
interface TRPCContext {
createCookie: (name, value, options?) => void, // wraps res.cookie()
getCookie: (name) => string | undefined, // wraps req.cookies[name]
clearCookie: (name) => void, // wraps res.clearCookie()
user: SessionUser | null, // set by auth middleware
req: Request, // the raw Express request
}The cookie factory pattern — the most important architectural decision:
// ❌ BAD approach (what beginners do):
async function login(input, req, res) { // service receives HTTP objects
const user = await validateCredentials(input);
res.cookie("sf_session", token); // service has HTTP side effect
return user;
}
// Problem: service is coupled to Express. Can't test without spinning up HTTP.
// ✅ GOOD approach (what we do):
// 1. Context creation (runs once per request):
export async function createContext({ req, res }) {
return {
createCookie: createCookieFactory(res), // captures res in closure
getCookie: getCookieFactory(req),
// ... other stuff
};
}
// 2. Auth service (pure — no HTTP knowledge):
async function login(input, ipAddress, userAgent) {
const token = await createSession();
return { user, token }; // just returns the token — doesn't set it
}
// 3. Route handler (thin layer that connects HTTP to service):
login: publicProcedure.mutation(async ({ input, ctx }) => {
const { user, token } = await authService.login(input, ip, ua);
ctx.createCookie("sf_session", token, cookieOptions); // sets cookie
return user;
})Why this matters for testing:
// Testing the service (no HTTP needed):
const result = await authService.login({ email: "a@b.com", password: "Pass1" }, "", "");
expect(result.token).toBeTruthy(); // just checks the returned token
// The service doesn't know about cookies or Express — pure functionThree factory functions, each captures the req or res by closure:
export function createCookieFactory(res: Response) {
return function createCookie(name: string, value: string, options?: CookieOptions) {
res.cookie(name, value, options); // res is captured from outer scope
};
}What's a closure? When createCookieFactory(res) is called, it creates a new function that "remembers" the res from the outer scope. Every request gets its own res, so every request's createCookie writes to its own response object.
Cookie security flags:
const DEFAULT_OPTIONS = {
httpOnly: true, // JS can't read this cookie (prevents XSS theft)
secure: true, // only sent over HTTPS (prevents HTTP interception)
sameSite: "lax", // sent on same-site requests + top-level navigation (CSRF protection)
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
};httpOnly: true— If there's an XSS vulnerability, the attacker's JavaScript cannot read the session cookie. This is the single most important security flag.secure: true— The cookie is only sent over HTTPS. In production, this prevents session theft over HTTP connections.sameSite: "lax"— The cookie is sent when navigating TO the site, but not on cross-site POST requests. This prevents CSRF attacks where a malicious site tricks your browser into submitting forms.
POST /api/auth/login
Body: { email: "alice@example.com", password: "Password1" }
- Helmet sets security headers
- CORS validates origin
express.json()parses bodycookieParser()parses existing cookies- Rate limiter checks: has this IP made 10+ failed auth attempts in 15 minutes?
The createOpenApiExpressMiddleware adapter receives the request and calls createContext({ req, res }):
// apps/api/src/context.ts
export async function createContext({ req, res }) {
return {
createCookie: createCookieFactory(res), // bound to this specific res object
getCookie: getCookieFactory(req),
clearCookie: clearCookieFactory(res),
user: null, // no user yet — set by auth middleware for protected routes
req,
};
}The procedure has no middleware (it's a publicProcedure) so it runs directly.
Input validation runs first:
.input(loginInputSchema) // z.object({ email: z.string().email(), password: z.string().min(1) })
// If email is "notanemail", tRPC throws a 400 before our code runs.mutation(async ({ input, ctx }) => {
const ip = ctx.req.ip ?? "";
const ua = ctx.req.headers["user-agent"] ?? "";
const { user, token } = await authService.login(input, ip, ua);
setAuthCookie(ctx, token); // calls ctx.createCookie(...)
return user;
})async login(payload, ipAddress, userAgent) {
// 1. Find user by email (case-insensitive)
const user = await this.repository.findUserByEmail(payload.email);
// 2. SAME error for wrong email AND wrong password (prevents user enumeration)
if (!user || !user.salt || !user.password) {
throw domainError("INVALID_CREDENTIALS", "Wrong email or password", "UNAUTHORIZED");
}
// 3. Hash the submitted password with the stored salt and compare
const hash = this.hashPassword(payload.password, user.salt);
if (hash !== user.password) {
throw domainError("INVALID_CREDENTIALS", "Wrong email or password", "UNAUTHORIZED"); // same message!
}
// 4. Create a new session
const token = randomBytes(64).toString("hex"); // 128-char hex string
await this.repository.createSession({ userId: user.id, token, ipAddress, userAgent });
return { user, token };
}Why the same error message for wrong email and wrong password? If you return different errors, an attacker can probe accounts:
- "Wrong email" → this email is NOT registered → try the next email
- "Wrong password" → this email IS registered → now try passwords
Same message → attacker learns nothing.
hashPassword(password: string, salt: string): string {
return createHmac("sha256", salt).update(password).digest("hex");
}What is HMAC-SHA256?
SHA-256 is a one-way hash function. HMAC(salt, password) means "hash the password with the salt as a secret key." The result is always the same for the same salt+password combination, but different salts produce completely different hashes for the same password.
Why a unique salt per user?
Without salt: If two users have password "Password1", their hashes are identical. An attacker who cracks one cracks both.
With salt: HMAC(salt1, "Password1") ≠ HMAC(salt2, "Password1") — even for the same password.
const token = randomBytes(64).toString("hex");
// → "a3b5f8..." (128 hex characters = 512 bits of entropy)
// Probability of guessing: 1 in 2^512 ≈ mathematically impossibleThe token is stored in the sessions table with an expiry timestamp.
setAuthCookie(ctx, token);
// → calls ctx.createCookie("sf_session", token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: ... })The browser receives a Set-Cookie: sf_session=a3b5f8...; HttpOnly; Secure; SameSite=Lax header.
HTTP/1.1 200 OK
Set-Cookie: sf_session=a3b5f8...; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000
Content-Type: application/json
{ "id": "uuid...", "email": "alice@example.com", "plan": "free" }
Note: the password and salt are NOT in the response because the output schema only includes { id, email, fullName, plan, avatarUrl }.
Every subsequent request from the logged-in user includes the cookie automatically (the browser sends all matching cookies with every request). Here's how protectedProcedure validates it:
const isAuthenticated = middleware(async ({ ctx, next }) => {
// 1. Read the cookie
const token = ctx.getCookie("sf_session");
if (!token) throw new TRPCError({ code: "UNAUTHORIZED" });
// 2. Try Redis cache first (fast path: ~0.5ms)
const cacheKey = `sf:session:${token}`;
const cached = await cacheGet<SessionUser>(cacheKey);
if (cached) {
return next({ ctx: { ...ctx, user: cached } }); // set user on context, continue
}
// 3. Cache miss → query PostgreSQL (slower path: ~5ms)
const session = await db.select({ user: { id, email, plan... } })
.from(sessionsTable)
.innerJoin(usersTable, eq(usersTable.id, sessionsTable.userId))
.where(and(eq(sessionsTable.token, token), gt(sessionsTable.expiresAt, new Date())))
.limit(1);
if (!session) throw new TRPCError({ code: "UNAUTHORIZED", message: "Session expired" });
// 4. Cache the user for 15 minutes (next requests use cache)
await cacheSet(cacheKey, session.user, 900);
// 5. Inject user into context and continue
return next({ ctx: { ...ctx, user: session.user } });
});
export const protectedProcedure = publicProcedure.use(isAuthenticated);The next() function is how tRPC middleware chains work. Calling next({ ctx: newCtx }) passes the modified context to the next middleware or the procedure handler. NOT calling next() means the request stops here (returns an error).
OAuth lets users log in with their Google/GitHub account instead of creating a password. The idea is: "I trust Google to verify this person's identity. Google says this is Alice. I'll create/find Alice's account."
Browser Our API Google
| | |
| 1. Click "Login with | |
| Google" | |
| | |
| 2. GET /auth/google/redirect |
|──────────────────────────>| |
| | 3. Generate random |
| | "state" string |
| | Set state cookie|
| | |
| 4. 302 Redirect to Google OAuth URL |
|<─────────────────────────| |
| | |
| 5. Follow redirect to Google |
|───────────────────────────────────────────────>
| | |
| 6. User approves "Sign in with Google" |
| | |
| 7. 302 Redirect back to /auth/google/callback?code=xyz&state=abc
|<───────────────────────────────────────────────
| | |
| 8. GET /auth/google/callback?code=xyz&state=abc
|──────────────────────────>| |
| | 9. Verify state |
| | (prevents CSRF) |
| | |
| | 10. Exchange code |
| |─────────────────────>
| | |
| | 11. Receive access |
| | + id_token |
| |<─────────────────────
| | |
| | 12. Verify id_token|
| | Extract email |
| | |
| | 13. Upsert user in |
| | PostgreSQL |
| | |
| | 14. Create session |
| | Set sf_session |
| | cookie |
| | |
| 15. 302 Redirect to frontend /dashboard |
|<─────────────────────────| |
The state parameter (step 3 and 9) prevents CSRF: If there's no state check, an attacker could trick your browser into completing an OAuth flow with the attacker's Google account — effectively logging you into their account.
The state is a random string stored in a cookie. When Google redirects back with ?state=abc, we check that it matches the cookie. If it doesn't match (the user didn't initiate this flow), we abort.
The code exchange (step 10):
Google redirects with a code parameter. This code is short-lived (60 seconds) and single-use. We exchange it for actual tokens server-side. The browser never sees the access token — only the code, which is useless without our client secret.
oauthRouter.get("/auth/google/callback", async (req, res) => {
const code = req.query["code"] as string | undefined;
const state = req.query["state"] as string | undefined;
const cookieState = req.cookies?.["oauth_state"]; // the state we set in step 3
// CSRF check: state must match
if (!state || state !== cookieState) {
return res.redirect(`${env.WEB_URL}/login?error=oauth_state_mismatch`);
}
// Exchange code for tokens (this is a server-to-server call, not browser-to-Google)
const tokenRes = await client.getToken(code!);
const tokens = tokenRes.tokens;
// Verify the id_token (JWT signed by Google)
const ticket = await client.verifyIdToken({ idToken: tokens.id_token!, audience: env.GOOGLE_CLIENT_ID! });
const payload = ticket.getPayload()!;
// payload contains: { email, name, picture, sub (Google user ID) }
// Upsert the user (create if new, find if existing)
const user = await authRepo.upsertOAuthUser({
email: payload.email!,
fullName: payload.name ?? null,
authProvider: "google",
providerUserId: payload.sub, // Google's unique user ID
});
// Create a session and set the cookie
const token = randomBytes(64).toString("hex");
await authRepo.createSession({ userId: user.id, token, ipAddress: req.ip ?? "", userAgent: req.headers["user-agent"] ?? "" });
res.cookie("sf_session", token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 30 * 24 * 60 * 60 * 1000 });
// Redirect to the frontend dashboard
res.redirect(`${env.WEB_URL}/dashboard`);
});Why is OAuth an Express route, not tRPC?
tRPC procedures return data. OAuth requires res.redirect(url) — sending a 302 HTTP response that tells the browser to navigate to a new URL. That's not a data response, it's a navigation command. Express handles this naturally; tRPC doesn't.
users (1) ────────── (many) sessions
│ [session tokens, expiry, IP]
│
├───── (many) forms ────── (many) form_versions
│ │ [field snapshot on publish]
│ │
│ ├───── (many) fields
│ │ [type, label, required, config JSONB, conditions JSONB]
│ │
│ ├───── (many) responses ──── (many) response_answers
│ │ [ip_hash, time] [valueText/Number/Array per field]
│ │
│ ├───── (many) analytics_events
│ │ [form_view, form_start, field_skip, form_submit]
│ │
│ └───── (many) analytics_daily
│ [pre-aggregated: views/starts/completions per day]
│
├───── (many) webhooks ──── (many) webhook_deliveries
│
├───── (many) api_keys
│
└───── (many) audit_logs
themes (standalone — seeded system themes + user custom themes)
export_jobs (tracks async CSV export progress)
Stores creator accounts. Supports two auth methods:
- Email/password:
password(HMAC hash) +salt(16-byte hex) columns - OAuth:
auth_provider("google"/"github") +provider_user_id(Google'ssubfield) planenum: "free" | "creator" | "studio" — controls feature accessdeleted_atsoft delete: users are never hard-deleted (preserves their forms/responses)
Stores login sessions. Each login creates one row.
token: 128-char hex (64 random bytes) — this is the cookie valueexpires_at: 30 days from creationip_address: stored for security audit (was this login from an unusual location?)- Why not JWT? JWTs are stateless — you can't revoke them. Session tokens can be deleted to force logout.
The core entity. Key design decisions:
slug: auto-generated unique URL slug (e.g., "my-form-a3b5")custom_slug: user-defined slug (e.g., "job-application") — creator/studio planstatusenum: draft | published | archived | pausedvisibilityenum: public | unlistedcurrent_version_id: FK toform_versions— which snapshot is livepassword_hash: bcrypt hash — null if no password requiredresponse_limit: max responses allowed — null if unlimitedexpires_at: after this date, submissions rejected — null if nevertotal_responses/total_views: denormalized counters (fast reads, updated atomically via SQL)
Why denormalized counters?
SELECT COUNT(*) FROM responses WHERE form_id = X on a table with millions of rows is slow. Keeping a counter on the form row means reading it is O(1). We update it atomically with SET total_responses = total_responses + 1 in the same transaction as the insert.
Snapshots of field configuration at publish time. fields_json stores the entire fields array as JSONB.
Why store the whole array as JSON instead of joining to fields?
If you relied on joining fields at submission time, updating a field after publishing would change what old responses were validated against. The snapshot is immutable — it captures exactly what the form looked like when published.
Individual questions in a form.
typeenum: 13 types (short_text, email, number, rating, single_select, multi_select, checkbox, date, phone, long_text, file_upload, divider, section_title)configJSONB: type-specific config:- For
single_select:{ "options": ["Yes", "No", "Maybe"] } - For
rating:{ "max": 5 } - For
short_text:{ "minLength": 10, "maxLength": 200 }
- For
conditionsJSONB: conditional logic rules- Example:
{ "show": true, "logic": "and", "rules": [{ "fieldId": "uuid", "operator": "equals", "value": "Yes" }] } - If this field's conditions are met, show it; otherwise hide it
- Example:
Why JSONB for config and conditions? Different field types have completely different configurations. A relational approach would need separate tables for each config type (rating_config, select_config, text_config...) — very complex to query. JSONB stores arbitrary structure and PostgreSQL can index into it.
Two-table design:
responses: one row per form submission (metadata: IP hash, time, completion time)response_answers: many rows per response (one row per field answer)
Why separate tables?
A form might have 20 fields. Storing all 20 answers in one responses row would require 20 dynamic columns (impossible to know in advance). The normalized design allows any number of answers per response.
Why value_text, value_number, value_array instead of just one JSON column?
Enables efficient SQL queries:
SELECT AVG(value_number) FROM response_answers WHERE field_id = X— average of a rating fieldSELECT * FROM response_answers WHERE value_text ILIKE '%@gmail.com'— find Gmail users- Can add B-tree indexes on
value_textfor fast text search
Two-level analytics storage:
analytics_events (raw): Every trackable event is stored here.
form_view → someone visited the form page
form_start → someone started filling the form
field_focus → someone clicked on a field
field_blur → someone left a field
field_skip → someone moved past a field without filling it
form_submit → form was successfully submitted
form_abandon → someone closed the page without submitting
analytics_daily (aggregated): Pre-computed daily rollup.
form_id | date | views | starts | completions | abandons | avg_time_ms
f1-uuid | 2025-01-15 | 423 | 287 | 198 | 89 | 75000
Why two tables?
The dashboard shows charts with 30 days of data. Running SELECT COUNT(*) GROUP BY date over millions of raw events for every dashboard load is O(N) over millions of rows. The daily rollup is O(30 rows) — thousands of times faster.
The analytics worker processes raw events and updates the daily rollup:
await db.insert(analyticsDailyTable).values({ date, views: 1 })
.onConflictDoUpdate({ set: { views: sql`analytics_daily.views + 1` } });
// If today's row exists, increment; if not, create itWithout tRPC, you write code like this:
Backend (/api/forms):
app.get("/api/forms", async (req, res) => {
const forms = await db.select().from(formsTable);
res.json(forms); // TypeScript type: any — the frontend has no idea what this returns
});Frontend:
const response = await fetch("/api/forms");
const data = await response.json(); // TypeScript type: any — no safety
// data.forms[0].titl ← typo, no error until users hit itWith tRPC:
Backend (packages/trpc/server/routes/forms/route.ts):
list: protectedProcedure
.input(formListInputSchema) // input shape defined with Zod
.output(formListOutputSchema) // output shape defined with Zod
.query(async ({ input, ctx }) => {
return formService.listForUser(ctx.user!.id, input);
// TypeScript KNOWS this returns { forms: FormOutput[], nextCursor: string | null, total: number }
}),Frontend (zero extra configuration needed):
const { data } = trpc.forms.list.useQuery({ limit: 20 });
// data is typed as { forms: FormOutput[], nextCursor: string | null, total: number }
// data.forms[0].titl ← TypeScript ERROR immediatelyimport { initTRPC } from "@trpc/server";
import { OpenApiMeta } from "trpc-to-openapi";
import type { TRPCContext } from "./context";
const t = initTRPC
.meta<OpenApiMeta>() // adds .meta({ openapi: {} }) capability
.context<TRPCContext>() // all procedures get access to ctx: TRPCContext
.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
// Never expose stack traces in production — security risk
},
};
},
});
export const router = t.router; // used to create sub-routers
export const publicProcedure = t.procedure; // base procedure (no auth)
export const middleware = t.middleware; // used to create auth/plan middlewareWhat initTRPC does: Creates a tRPC instance configured for your specific context type and metadata shape. The generic parameters <OpenApiMeta> and <TRPCContext> are TypeScript type parameters that flow down to every procedure defined with this instance.
// packages/trpc/server/index.ts
export const serverRouter = router({
health: healthRouter, // GET /api/health
auth: authRouter, // POST /api/auth/login, etc.
forms: formsRouter, // GET /api/forms, POST /api/forms, etc.
fields: fieldsRouter, // POST /api/forms/{formId}/fields, etc.
responses: responsesRouter, // ...
// ...
});
export type ServerRouter = typeof serverRouter;
// ServerRouter is the TYPE of the entire router — exported for frontend useThe router is just a nested object. router.forms.list maps to formsRouter.list. The path in the URL /api/forms and the tRPC path trpc.forms.list are both derived from this nesting.
// auth.middleware.ts:
const isAuthenticated = middleware(async ({ ctx, next }) => {
const token = ctx.getCookie("sf_session");
// ... validate token ...
return next({ ctx: { ...ctx, user: validatedUser } }); // inject user
});
// This creates a new procedure type with user guaranteed non-null:
export const protectedProcedure = publicProcedure.use(isAuthenticated);
// Usage in route:
list: protectedProcedure.query(async ({ input, ctx }) => {
// TypeScript KNOWS ctx.user is not null here because isAuthenticated
// calls next() only after setting ctx.user
const forms = await formService.listForUser(ctx.user!.id, input);
// ^^^
// TypeScript: ctx.user is SessionUser, not null
});The next() pattern: tRPC middleware is similar to Express middleware. Calling next({ ctx }) means "proceed to the next middleware or the handler, with this updated context." NOT calling next means "stop here and throw an error."
Standard tRPC: All calls go to POST /trpc/forms.list with JSON body { "0": { "input": {...} } }
— weird for REST clients.
With trpc-to-openapi: We annotate each procedure:
list: protectedProcedure
.meta({ openapi: { method: "GET", path: "/forms", tags: ["Forms"] } })
// ^^^^^^^^^^^^^^^ This annotationThe library reads these annotations and:
- Generates a REST endpoint
GET /api/formsthat maps toforms.list - Converts query params to tRPC input format
- Generates an OpenAPI JSON document
The Scalar docs page at /docs renders this OpenAPI document as an interactive UI.
Browser
POST /f/my-form-slug/submit
Body: {
formVersionId: "a0eebc99-...",
answers: {
"field-uuid-1": "Alice Johnson",
"field-uuid-2": "alice@example.com"
},
metadata: { timeToCompleteMs: 45000 }
}
const envelopeResult = submissionEnvelopeSchema.safeParse(req.body);
// Checks: is formVersionId a valid UUID? is answers an object?
// If not: 422 VALIDATION_FAILEDif (envelopeResult.data.__hp !== undefined && envelopeResult.data.__hp !== "") {
// Bot filled the hidden field — silently discard
return res.status(200).json({ success: true, responseId: crypto.randomUUID() });
}The bot thinks it succeeded. A real user never sees or fills __hp (it's hidden with CSS).
// Try Redis first (60s TTL)
let form = await cacheGet(`sf:form:public:${slug}`);
if (!form) {
// Cache miss: query PostgreSQL
form = await db.select().from(formsTable).where(/* slug match */).limit(1);
}
// If not found: 404 FORM_NOT_FOUNDif (form.status !== "published") → 403 FORM_NOT_PUBLISHED
if (form.expiresAt < now) → 410 FORM_EXPIRED
if (form.totalResponses >= limit) → 410 (checked again inside transaction)
if (form.currentVersionId !== submittedVersionId) → 409 FORM_VERSION_OUTDATEDThe version check (409) is critical. If a creator published a new version while someone was filling the form, we tell them to reload rather than validating against the wrong schema.
const version = await db.select().from(formVersionsTable)
.where(eq(formVersionsTable.id, formVersionId)).limit(1);
const allFields = version.fieldsJson; // the exact fields as they were at publish timeconst activeFields = applyConditions(allFields, answers);
// "Show Field B only if Field A = 'Yes'"
// If Field A = "No", Field B is removed from activeFields
// The submission is only validated against fields that were actually shownWhy this matters: If Field B is a required email field but it's hidden because Field A = "No", we shouldn't require Field B to be filled. applyConditions removes it from the schema.
const schema = buildFieldSchema(activeFields);
// Generates something equivalent to:
// z.object({
// "field-uuid-1": z.string().min(1), // required short_text
// "field-uuid-2": z.string().email().optional(), // optional email
// })
const validation = schema.safeParse(answers);
// If invalid: 422 VALIDATION_FAILED with per-field errorsconst responseId = await db.transaction(async (tx) => {
// Re-check response limit inside transaction (prevents race condition)
if (form.responseLimit) {
const current = await tx.select({ count: formsTable.totalResponses })...;
if (current >= form.responseLimit) { limitExceeded = true; return null; }
}
// 1. Insert response row
const response = await tx.insert(responsesTable).values({...}).returning();
// 2. Insert all answer rows
await tx.insert(responseAnswersTable).values(serializedAnswers);
// 3. Increment counter atomically
await tx.update(formsTable).set({ totalResponses: sql`total_responses + 1` });
return response.id;
}); // If anything fails, ALL three operations are rolled backWhy a transaction? If the server crashes after inserting the response but before incrementing the counter, the counter would be wrong. A transaction ensures all three operations succeed together or none at all. This is ACID compliance.
setImmediate(() => {
safeEnqueue(getEmailQueue(), "new_response", { formId, responseId });
safeEnqueue(getAnalyticsQueue(), "track", { eventType: "form_submit" });
safeEnqueue(getWebhookQueue(), "dispatch", { event: "form.response.created" });
});
// Response is already sent — user gets 200 immediately
// Side effects happen asynchronously in the backgroundsetImmediate pushes these enqueues to AFTER the current event loop tick — meaning the HTTP response is sent first, then these fire.
Handles 4 email types: welcome, password_reset, new_response notification, export_ready.
Why a worker for emails? SMTP calls take 200-500ms. If you send email synchronously during signup, the user waits 500ms longer than necessary. With a queue, signup returns in ~5ms and the email sends in the background.
Retry strategy: BullMQ retries failed jobs with exponential backoff:
- Attempt 1: immediate
- Attempt 2: 2 seconds later
- Attempt 3: 4 seconds later This handles transient SMTP failures without losing emails.
Fires HTTP POST requests to user-configured URLs when events occur.
HMAC signature verification:
const body = JSON.stringify(payload);
const sig = createHmac("sha256", webhook.secret).update(body).digest("hex");
// Header: X-ScribbleForms-Signature: sha256=<hex>Recipients verify by recomputing the HMAC with their stored secret. If the header doesn't match, the request was either tampered with or not from ScribbleForms.
SQL filtering (fixed from O(N) scan):
// Instead of loading ALL webhooks and filtering in JS:
.where(and(
eq(webhooksTable.isActive, true),
or(isNull(webhooksTable.formId), eq(webhooksTable.formId, formId)),
sql`${webhooksTable.events} @> ${JSON.stringify([event])}::jsonb` // JSONB array contains
))The @> operator checks if the JSONB events array contains the event string. This runs in the database with an index, not by loading all rows into JavaScript memory.
Processes raw analytics events and updates the daily aggregate table.
// Upsert pattern: INSERT new row, or UPDATE existing row's counter
await db.insert(analyticsDailyTable)
.values({ formId, date, views: 1 })
.onConflictDoUpdate({
target: [analyticsDailyTable.formId, analyticsDailyTable.date],
set: { views: sql`analytics_daily.views + 1` }
});Generates CSV files asynchronously and uploads to Cloudinary.
Why async export? A form with 10,000 responses might take 30 seconds to export. You can't make a user wait 30 seconds for an HTTP response (browser timeout is usually 30s). The pattern:
- API: create export job record (status: "pending"), enqueue job, return
{ exportJobId } - Worker: generate CSV, upload to Cloudinary, update job record (status: "done", fileUrl: "...")
- Frontend: poll
GET /api/export/{jobId}/statusevery 2 seconds until status is "done"
async getFormStats({ formId, startDate, endDate }) {
// 1. Query pre-aggregated daily data (fast — small table)
const daily = await db.select().from(analyticsDailyTable)
.where(and(eq(formId), betweenDates(startDate, endDate)));
// 2. Simple arithmetic from aggregates
const totalViews = sum(daily.map(d => d.views));
const totalStarts = sum(daily.map(d => d.starts));
const totalCompletions = sum(daily.map(d => d.completions));
const completionRate = (totalCompletions / totalStarts) * 100;
const dropOffRate = ((totalStarts - totalCompletions) / totalStarts) * 100;
// 3. Average completion time from responses table (needs raw data)
const avgTime = await db.select({ avg: avg(responses.timeToCompleteMs) })
.from(responsesTable).where(and(formId, betweenDates));
// 4. Device breakdown from user agents (UA string parsing)
const devices = { mobile: 0, desktop: 0, tablet: 0, other: 0 };
for (const r of allResponses) {
if (/Mobi|Android|iPhone/.test(r.userAgent)) devices.mobile++;
else if (/iPad|Tablet/.test(r.userAgent)) devices.tablet++;
else devices.desktop++;
}
// 5. Field drop-off from skip events
const skipEvents = await db.select({ fieldId, skipCount: count() })
.from(analyticsEventsTable)
.where(and(formId, eventType = "field_skip", betweenDates))
.groupBy(fieldId);
const fieldDropOff = fields.map(f => ({
fieldId: f.id,
fieldLabel: f.label,
dropOffRate: (skipMap.get(f.id) ?? 0) / totalStarts * 100,
}));
// 6. Previous period comparison (same duration, immediately before)
const prevData = /* same queries for prev period */;
return { totalResponses, completionRate, dropOffRate, avgTimeToCompleteMs,
deviceBreakdown, fieldDropOff, topSources, responsesOverTime, previousPeriod };
}Phase 1 (Real-time, synchronous):
Form submit → insert response → increment total_responses counter
Analytics track event → enqueue to BullMQ
Phase 2 (Async, via worker):
BullMQ job → insert raw analytics_event → upsert analytics_daily aggregate
"views + 1" or "starts + 1" etc.
Phase 3 (On demand, cached):
Dashboard load → query analytics_daily (30 rows) → compute stats → cache 5 min
Next load within 5 min → Redis cache hit → instant response
Plans are stored on the users table as an enum: "free" | "creator" | "studio".
Checking plan limits at the route level:
// In forms/route.ts — creating a new form:
create: protectedProcedure.mutation(async ({ input, ctx }) => {
const count = await formService.countForUser(ctx.user!.id);
const limits = PLAN_LIMITS[ctx.user!.plan]; // { maxForms: 3, ... }
if (limits.maxForms !== -1 && count >= limits.maxForms) {
throw domainError("PLAN_LIMIT_FORMS",
`Your plan allows up to ${limits.maxForms} forms. Upgrade to create more.`,
"FORBIDDEN"); // 403
}
return formService.create(ctx.user!.id, input);
})Feature gating with planMiddleware:
// For procedures that entire plan tiers can't access at all:
list: protectedProcedure
.use(planMiddleware("hasWebhooks")) // free/creator → throws 403 before handler runs
.query(async ({ ctx }) => {
// Only studio users reach here
})// packages/trpc/server/middleware/plan.middleware.ts
export function planMiddleware(feature: keyof PlanLimits) {
return middleware(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowed = PLAN_LIMITS[ctx.user.plan][feature]; // e.g., PLAN_LIMITS.free.hasWebhooks = false
if (!allowed) {
throw new TRPCError({ code: "FORBIDDEN", message: "This feature requires a higher plan." });
}
return next({ ctx });
});
}1. Creator fills form builder UI
→ clicks "Add field"
→ trpc.fields.addField.mutate({ formId, field: { type: "email", label: "Email", required: true } })
2. API receives POST /api/forms/{formId}/fields
→ isAuthenticated middleware validates cookie
→ assertOwnership: this form belongs to this user?
→ planMiddleware check: free plan allows ≤10 fields
→ fieldRepo.addField() → INSERT INTO fields VALUES (...)
→ cacheDel(`sf:form:public:${form.slug}`) — invalidate cache if published
3. Creator clicks "Publish"
→ trpc.forms.publish.mutate({ id: formId })
4. API receives POST /api/forms/{id}/publish
→ assertOwnership
→ formService.publish():
a. load all fields for this form
b. INSERT INTO form_versions VALUES ({ fieldsJson: allFields })
c. UPDATE forms SET status='published', current_version_id=newVersionId
5. API returns { versionId, version: 1 }
6. Background tasks (non-blocking):
→ webhookQueue.add("dispatch", { event: "form.published" })
→ cacheDelPattern("sf:explore:*") ← form is now public, refresh explore cache
1. Browser: GET /api/forms/explore?limit=12
2. tRPC router: formsRouter.explore
→ No auth required (publicProcedure)
→ Check Redis: cacheGet("sf:explore:{'limit':12}")
→ Cache HIT → return cached JSON instantly (TTL: 2 minutes)
→ Cache MISS:
a. db.select().from(formsTable)
.where(and(status='published', visibility='public', deleted_at IS NULL))
.orderBy(totalResponses DESC).limit(13)
b. cacheSet("sf:explore:{'limit':12}", result, 120)
→ Return { forms: [...], nextCursor: "uuid..." }
1. Form submission succeeds (response saved to DB)
2. setImmediate → safeEnqueue(webhookQueue, "dispatch", {
formId: "f1-uuid",
event: "form.response.created",
payload: { formId, responseId }
})
3. Redis stores the job in a BullMQ sorted set
4. Webhook worker polls Redis (blocking BRPOP):
→ job dequeued
→ SQL query: webhooks WHERE is_active=true AND (form_id=f1-uuid OR form_id IS NULL)
AND events @> '["form.response.created"]'::jsonb
→ Found 2 webhooks
5. For each webhook:
→ body = JSON.stringify({ event, payload, timestamp })
→ sig = HMAC-SHA256(webhook.secret, body)
→ fetch(webhook.url, { method: "POST", headers: { X-ScribbleForms-Signature: "sha256=..." }, body })
6. If HTTP response is not 2xx → BullMQ retries (up to 5 times with exponential backoff)
7. Record delivery: INSERT INTO webhook_deliveries VALUES ({ webhookId, statusCode, status: "success" })
Without rate limiting:
- A bot can try 1000 passwords on an account in 1 second (brute force)
- A bot can submit 10,000 fake form responses per hour (spam)
- A DDoS attack can send millions of requests, crashing the server
// apps/api/src/middleware/rate-limit.ts
import rateLimit from "express-rate-limit";
export const rateLimiters = {
// Global: 200 requests per minute per IP — protects against DDoS
global: rateLimit({ windowMs: 60_000, max: 200 }),
// Auth: 10 failed attempts per 15 minutes — protects against brute force
// skipSuccessfulRequests: true — successful logins don't count toward limit
auth: rateLimit({ windowMs: 15 * 60_000, max: 10, skipSuccessfulRequests: true }),
// Form submission: 5 submissions per minute per IP per form slug
// keyGenerator makes the limit per (IP, slug) combination
formSubmit: rateLimit({
windowMs: 60_000, max: 5,
keyGenerator: (req) => `${req.ip}:${req.params["slug"] ?? "unknown"}`,
}),
// Password reset: 3 requests per hour — prevents email flooding
passwordReset: rateLimit({ windowMs: 60 * 60_000, max: 3 }),
};How express-rate-limit works internally:
It maintains a counter in memory (or Redis) per key (IP address). Each request increments the counter. When the counter exceeds max within windowMs milliseconds, it returns 429 Too Many Requests. After the window passes, the counter resets.
Why the formSubmit rate limiter uses slug as part of the key:
Without slug: if a bot submits form A and form B, both count toward the same counter. A single malicious user could submit form A 4 times and form B once (5 total), staying under the limit for both forms.
With slug: 5 submissions to form A, 5 submissions to form B — separate limits.
A webhook is a "callback" over HTTP. Instead of your system polling "did anything happen?", the other system calls YOUR URL when something happens.
Traditional API polling: Webhooks:
Frontend → "Any new responses?" → API API → "New response!" → Your URL
Frontend → "Any new responses?" → API
Frontend → "Any new responses?" → API
... (100 requests per minute, mostly empty)
The problem: if someone discovers your webhook URL, they can send fake events. The solution: every request is signed with a secret only you and ScribbleForms know.
// ScribbleForms signs the payload:
const body = JSON.stringify({ event: "form.response.created", payload: {...}, timestamp: "..." });
const signature = createHmac("sha256", webhookSecret).update(body).digest("hex");
// Sends: X-ScribbleForms-Signature: sha256=a3b5f8...
// Your server verifies:
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!).update(rawBody).digest("hex");
if (`sha256=${expected}` !== req.headers["x-scribbleforms-signature"]) {
return res.status(401).send("Invalid signature");
}Why HMAC instead of a simple token? A simple token in the header can be replayed (attacker captures a legitimate request and replays it later). HMAC signs the entire body — if the payload changes, the signature doesn't match. Combined with a timestamp in the payload, you can reject requests older than 5 minutes.
Answer: "tRPC provides end-to-end type safety with zero code generation. The frontend gets TypeScript types inferred directly from the server router — if I rename a field on the server, TypeScript immediately shows errors on the frontend. We also expose a REST API via trpc-to-openapi so third-party integrations and our Scalar docs still work. One codebase, two protocols."
Answer: "On login, we generate a 128-char hex token (64 random bytes) using crypto.randomBytes. We store it in the sessions table and set it as an HttpOnly; Secure; SameSite=Lax cookie. On every protected request, the middleware reads the cookie, checks Redis first (15-min TTL), then falls back to PostgreSQL if there's a cache miss. When the session expires or the user logs out, we delete it from both Redis and PostgreSQL."
Answer: "The response limit check happens twice. First, a pre-check reads the cached totalResponses from Redis — this filters out most over-limit submissions cheaply. For the ones that pass, we re-check INSIDE the database transaction. The transaction reads the current counter with a SELECT before INSERT, ensuring atomicity. Two concurrent submissions can't both pass the limit check inside a transaction."
Answer: "When a form is published, all field configurations are snapped into a form_versions row as a JSON array. At submission time, we load that snapshot, run applyConditions() to determine which fields are actually visible based on the answers, then call buildFieldSchema() from @repo/validators to generate a Zod schema dynamically. This schema validates required fields, type constraints, and options. The same buildFieldSchema() is available to the frontend for real-time validation."
Answer: "Frontend and backend validation parity is critical. If the frontend validates an email field but the backend doesn't, or vice versa, users get confusing errors. By putting buildFieldSchema() and applyConditions() in a shared package, both frontend and backend use identical validation rules. This is the architectural signal that 'we take this seriously.'"
Answer: "We have six cache keys with different TTLs based on data freshness requirements. Session tokens cache for 15 minutes (sliding window) — every auth'd request hits Redis not PostgreSQL. Public form data caches for 60 seconds — a popular form with 1000 hits/minute becomes 1 DB query/minute. Analytics stats cache for 5 minutes — expensive GROUP BY queries don't run on every dashboard load. The pattern is always: try cache → cache miss → DB query → populate cache. Cache invalidation happens immediately on data changes (update form → delete form cache, unpublish → delete explore cache)."
Answer: "Several changes:
- Move to a connection pooler like PgBouncer to handle more simultaneous DB connections
- Add read replicas — analytics queries hit replicas, not the primary
- Move the Redis cache to a Redis Cluster for horizontal scaling
- Add more worker instances — BullMQ supports multiple consumers on the same queue
- Move the analytics pipeline to a time-series database (TimescaleDB or ClickHouse) for better aggregate query performance
- Add a CDN layer in front of public form data (Cloudflare Workers could serve cached form JSON at the edge)
- Consider event sourcing for the audit log — instead of direct writes, all changes emit events that write the audit log asynchronously"
1. JWT instead of sessions for stateless auth Currently sessions require a Redis lookup. JWTs encode the user data in the token itself — no DB or Redis lookup needed. Tradeoff: JWTs can't be revoked (use short expiry + refresh token rotation).
2. WebSocket support for live collaboration Real-time form editing (multiple creators editing the same form) requires WebSockets. Socket.io with Redis pub/sub adapter would work.
3. Idempotency keys on form submission If a user double-clicks submit, they might submit twice. An idempotency key (hashed from IP + form + time window) prevents duplicate submissions.
4. Full-text search on responses
Add a PostgreSQL GIN index on response_answers.value_text to support searching responses by content.
5. Event-driven architecture
Instead of calling safeEnqueue directly in route handlers, emit domain events:
eventBus.emit("form.submitted", { formId, responseId });
// Separate listeners handle: email, webhook, analytics, audit log
// Route handler knows nothing about side effects6. CQRS pattern for analytics Separate read model (analytics queries) from write model (response inserts). The write side publishes events; the read side maintains denormalized views optimized for queries.
7. Row-level security in PostgreSQL
Instead of WHERE user_id = X in every query, use PostgreSQL RLS policies. More secure (the DB enforces access, not application code) but more complex to set up with Drizzle.
8. Microservice extraction The analytics service could become its own microservice that subscribes to events from the form service. Benefits: independent scaling, independent deployment, different tech stack (e.g., Go for analytics processing).
9. Global deployment with edge caching Deploy form data globally using Cloudflare Workers and Durable Objects. Respondents get form data from the nearest edge node (<10ms) instead of the origin server (~100ms).
10. GraphQL layer for dashboard The dashboard has complex data requirements (forms + recent responses + analytics all in one request). GraphQL's query composition would reduce round trips from 3 to 1.
route.ts= The Waiter: Takes your order (HTTP request), brings it to the kitchen, delivers the food (HTTP response). Doesn't cook anything.service.ts= The Chef: Knows the recipe (business rules). Checks if you're allowed to order this dish (plan limits). Doesn't know about tables, customers, or bills.repository.ts= The Pantry: Knows where everything is stored (database). Fetches ingredients. Doesn't know why you need them.
- Your library card = the
sf_sessioncookie - The card number = the session token (128-char hex string)
- The library database = the
sessionstable in PostgreSQL - The front desk checking your card = the
isAuthenticatedmiddleware - The card having an expiry date =
sessions.expires_at - The fast lookup system = Redis cache
Without monorepo: each package (frontend, backend, shared types) lives in its own warehouse. Moving goods between them requires customs, paperwork, and delays (publishing to npm, bumping versions).
With monorepo: everything is in one massive warehouse. Moving goods between shelves is instant. Turborepo is the forklift operator that knows the optimal order to move things.
-
Cookie factory pattern — Services are pure functions. No HTTP primitives leak into business logic. Testable without HTTP.
-
Form version snapshots — Most candidates miss this. Immutable snapshots at publish time solve validation drift, mid-fill conflicts, and response attribution simultaneously.
-
Two-phase analytics — Raw events for debugging, daily aggregates for performance. The dashboard never touches raw event data.
-
Shared
@repo/validators— Frontend and backend validation use identical code. No drift possible. -
Explicit error codes — Every domain error has a machine-readable
domainCodeAND a human-readable message. The frontend can show localized messages based on the code; logging systems can alert on specific codes. -
safeEnqueuepattern — Side effects (email, webhook) never block HTTP responses. Failures are swallowed gracefully (Redis down → email not queued → still responds 200). -
Atomic response limit check — Race condition prevented by re-reading the counter inside the transaction.
-
Graceful cache degradation — Every cache operation is wrapped in try/catch. Redis outage = slower API, not a 500.
- End-to-end type safety (tRPC + Zod + Drizzle together)
- Form version snapshots enabling safe schema evolution
- Two-level analytics (raw + pre-aggregated)
- Clean separation of concerns in the four-file pattern
- Production-quality security (HMAC, bcrypt, cookie flags, rate limiting, honeypot)
- 83 tests covering every critical path
- No JWT/refresh token pattern (sessions can't be used in mobile apps without cookies)
- No real-time collaboration (WebSocket support missing)
- Analytics device breakdown uses basic UA parsing (should use a proper UA parser library)
- No retry logic on analytics event tracking (if worker fails, event is lost)
- Export generates CSV in memory (will OOM on very large exports — needs streaming)
This architecture sits between "senior engineer at a Series A startup" and "staff engineer at a Series B startup." The patterns used (cookie factories, version snapshots, shared validators, two-phase analytics, async side effects) are all production-grade and would be familiar to engineers at companies like Linear, Vercel, PlanetScale, or Stripe.
This document was generated to explain the ScribbleForms codebase at interview depth. Every section answers "what, why, how, and what are the alternatives" — the four questions a senior engineer should be able to answer about every architectural decision.