Define your features.
Get your secure backend.
One TypeScript file per table, written by you or your agent, becomes a production Hono backend: typed API, auth, migrations, admin UI, MCP server. Security is compiled in, so it can't be forgotten.
Free while in beta. Standard Hono + Drizzle + Better Auth on your own Cloudflare account.Read the docs
Get started
Three ways in. Same backend out.
Describe the app. The Start agent defines the features, runs the build, walks you through what it generated, and hands you a project you can deploy from the browser or download. No install, no signup to try.
Try it — describe an app, get a real backend
A real build validates every definition. No signup to try it.
Scaffold a project, define tables in quickback/features/, build, run, deploy. Node 20+ and a free account when you build.
npx @quickback-dev/cli startPick a template: blank, todos, blog, or saas.
quickback buildValidates every definition and generates src/. Re-run after any change to a feature file.
npm run devYour API, admin UI and OpenAPI docs on localhost.
quickback deployCreates the D1, KV and R2 resources in your Cloudflare account, applies migrations, ships the Worker.
Already building with Claude Code or Cursor? Give it Quickback context and let it write the feature files. The build validates everything it writes, so it learns from errors instead of shipping them.
Claude Code skill
npx @quickback-dev/cli claude install --globalDeep knowledge of the security layers plus a specialist agent that drafts complete feature definitions.
Cursor rules
npx @quickback-dev/cli cursor installDrops quickback.mdc into .cursor/rules/. Commit it and the whole project gets the rules.
MCP server, any tool
{
"mcpServers": {
"quickback": { "command": "npx", "args": ["@quickback-dev/cli", "mcp"] }
}
}Docs, your config and your feature definitions as tools for Claude Desktop, VS Code, Windsurf, or anything MCP.
Or just paste this into your agent
It scaffolds the project, installs the skill for itself, and asks what you want to build.
How it works
Prompt. Define. Generate.
Describe a feature. Your agent turns it into one TypeScript file, or you write it yourself. Build, and every layer you declared lands in the routes, the migration, the types, the docs, the MCP tools and the admin UI.
Build a hiring pipeline for my side project.Each company is its own organization; nobody ever sees another company's jobs.A job has a title, department, status, salary range and internal notes.Anyone on the team can read jobs, but only hiring managers and admins can create or edit them, and only admins can delete.Salary is set when the job is created, not edited later.Hide internal notes from everyone except admins.Add a “close job” step that moves an open job to closed and records who closed it.
// quickback/features/jobs/jobs.tsimport { feature, q } from "@quickback/compiler"; export default feature("jobs", { columns: { id: q.id(), title: q.text().required().searchable(), department: q.text().required().filterable("eq"), status: q.enum(["draft", "open", "closed"]).default("draft").required(), salaryMin: q.int().optional(), salaryMax: q.int().optional(), internalNotes: q.text().optional(), closedAt: q.timestamp().optional(), closedById: q.text().optional(), organizationId: q.scope("organization"), ...q.audit(), ...q.softDelete(), }, // firewall derived from q.scope(): // WHERE organization_id = ctx.activeOrgId AND deleted_at IS NULL guards: { createable: ["title", "department", "salaryMin", "salaryMax", "internalNotes"], updatable: ["title", "department", "internalNotes"], protected: { status: ["close"], closedAt: ["close"], closedById: ["close"] }, }, masking: { internalNotes: { type: "redact", show: { roles: ["admin+"] } }, }, read: { access: { roles: ["member+"] }, pageSize: 25 }, create: { access: { roles: ["hiring-manager", "admin+"] } }, update: { access: { roles: ["hiring-manager", "admin+"] } }, delete: { access: { roles: ["admin+"] }, mode: "soft" }, transitions: { close: { field: "status", from: "open", to: "closed", access: { roles: ["hiring-manager", "admin+"] }, stamp: { at: "closedAt", by: "closedById" }, }, },});
- $ quickback build
- ✓ parsed 1 feature · 1 table · 1 transition
- ✓ firewall: organization scope + soft delete on jobs
- ✓ access: 5 operations gated, deny by default
- ✓ guards: 5 createable · 3 updatable · 3 protected
- ✓ masking: internalNotes redacted below admin
- ✓ emitted Hono routes, Drizzle migration, types, OpenAPI, MCP tools, admin registry
- done in 2.4s
// src/features/jobs/jobs.routes.ts — GENERATED, rebuilt from jobs.tsimport { Hono } from "hono";import { and, eq, isNull } from "drizzle-orm";import { jobs } from "./schema";import { evaluateAccess, AccessErrors, AuthErrors } from "../../lib/access";import { masks } from "../../lib/masks"; const app = new Hono<AppEnv>(); // Firewall — derived from q.scope("organization") + soft deletefunction firewall(ctx: AppContext) { return and(eq(jobs.organizationId, ctx.activeOrgId!), isNull(jobs.deletedAt));} // Guards — anything else in the body is rejectedconst CREATEABLE = new Set(["title", "department", "salaryMin", "salaryMax", "internalNotes"]);const UPDATABLE = new Set(["title", "department", "internalNotes"]);const PROTECTED = new Set(["status", "closedAt", "closedById"]); // written only by close() // Masking — internalNotes redacted below adminfunction mask(row: Job, ctx: AppContext): Job { if (ctx.roles.some((r) => ["admin", "owner"].includes(r))) return row; return { ...row, internalNotes: masks.redact(row.internalNotes) };} // GET /api/v1/jobs — read.access: member+app.get("/", async (c) => { const ctx = c.get("ctx"); if (!ctx.authenticated) return c.json(AuthErrors.required(), 401); if (!(await evaluateAccess({ roles: ["member", "admin", "owner"] }, ctx))) { return c.json(AccessErrors.roleRequired(["member+"], ctx.roles), 403); } const page = parsePagination(c, { pageSize: 25 }); const where = and(firewall(ctx), ...filters(c, { department: "eq" }), ...search(c, ["title"])); const rows = await db.select().from(jobs).where(where).limit(page.limit).offset(page.offset); return c.json({ data: rows.map((r) => mask(r, ctx)), pagination: page.meta(rows) });}); // PATCH /api/v1/jobs/:id — update.access: hiring-manager, admin+app.patch("/:id", async (c) => { const ctx = c.get("ctx"); if (!(await evaluateAccess({ roles: ["hiring-manager", "admin", "owner"] }, ctx))) { return c.json(AccessErrors.roleRequired(["hiring-manager", "admin+"], ctx.roles), 403); } const body = pickAllowed(await c.req.json(), UPDATABLE); // silently drops status, organizationId… const [row] = await db.update(jobs).set(body) .where(and(firewall(ctx), eq(jobs.id, c.req.param("id")))).returning(); if (!row) return c.json(NotFoundError(), 404); return c.json(mask(row, ctx));}); // POST /api/v1/jobs/:id/close — transitions.closeapp.post("/:id/close", async (c) => { const ctx = c.get("ctx"); if (!(await evaluateAccess({ roles: ["hiring-manager", "admin", "owner"] }, ctx))) { return c.json(AccessErrors.roleRequired(["hiring-manager", "admin+"], ctx.roles), 403); } const [row] = await db.update(jobs) .set({ status: "closed", closedAt: now(), closedById: ctx.userId }) .where(and(firewall(ctx), eq(jobs.id, c.req.param("id")), eq(jobs.status, "open"))) .returning(); if (!row) return c.json(TransitionError("open → closed"), 409); return c.json(mask(row, ctx));}); export default app;
-- quickback/drizzle/features/0000_initial.sql — GENERATED by drizzle-kitCREATE TABLE `jobs` ( `id` TEXT PRIMARY KEY NOT NULL, `title` TEXT NOT NULL, `department` TEXT NOT NULL, `status` TEXT DEFAULT 'draft' NOT NULL CHECK (`status` IN ('draft', 'open', 'closed')), `salary_min` INTEGER, `salary_max` INTEGER, `internal_notes` TEXT, `closed_at` TEXT, `closed_by_id` TEXT, `organization_id` TEXT NOT NULL REFERENCES `organization`(`id`), `created_at` TEXT NOT NULL, `created_by` TEXT, `modified_at` TEXT NOT NULL, `modified_by` TEXT, `deleted_at` TEXT, `deleted_by` TEXT); -- every firewalled query filters on this pairCREATE INDEX `jobs_organization_id_deleted_at_idx` ON `jobs` (`organization_id`, `deleted_at`);-- department is filterable("eq")CREATE INDEX `jobs_department_idx` ON `jobs` (`department`);
// quickback.gen.ts — GENERATED type tree. Zero runtime.// import type { Api, Qb } from "./quickback.gen";export namespace Api { export namespace jobs { export type Id = Qb.Brand<string, "jobs.jobs">; // branded — a candidate id will not type-check here // Masked columns depend on who is reading: Row<"admin"> reveals, Row (never) may be masked export interface Row<Role extends string = never> { id: Id; title: string; department: string; status: "draft" | "open" | "closed"; salaryMin: number | null; salaryMax: number | null; internalNotes: Qb.Reveal<Role, "admin", string | null>; // Qb.Masked<string> unless Role is admin closedAt: string | null; closedById: Qb.UserId | null; organizationId: Qb.OrganizationId; // auth principals are branded too createdAt: string; createdBy: Qb.UserId | null; modifiedAt: string; modifiedBy: Qb.UserId | null; } // Insert / Update mirror guards.createable / guards.updatable — writes take real values export type Insert = Pick<Row<"admin">, "title" | "department"> & Partial<Pick<Row<"admin">, "salaryMin" | "salaryMax" | "internalNotes">>; export type Update = Partial<Pick<Row<"admin">, "title" | "department" | "internalNotes">>; export type ListResponse<Role extends string = never> = Qb.Page<Row<Role>>; export namespace columns { export type status = Row["status"]; /* … */ } export namespace actions { export namespace close { export type Input = Record<string, never>; export type Output = Row; // enforced: the route parses execute() through the declared schema } } }}
// openapi.json — GENERATED, served at GET /openapi.json (excerpt){ "openapi": "3.1.0", "paths": { "/api/v1/jobs": { "get": { "operationId": "listJobs", "parameters": [ { "name": "department", "in": "query", "schema": { "type": "string" } }, { "name": "search", "in": "query", "description": "Matches title" }, { "name": "page", "in": "query" }, { "name": "sort", "in": "query" } ], "responses": { "200": { "$ref": "#/components/responses/JobsPage" }, "401": { "$ref": "#/components/responses/AuthRequired" }, "403": { "description": "Requires role member+" } } }, "post": { "operationId": "createJobs", "requestBody": { "content": { "application/json": { "schema": { "required": ["title", "department"], "properties": { "title": { "type": "string" }, "department": { "type": "string" }, "salaryMin": { "type": "integer" }, "salaryMax": { "type": "integer" }, "internalNotes": { "type": "string" } }, "additionalProperties": false } } } }, "responses": { "201": { "$ref": "#/components/schemas/Job" }, "403": { "description": "Requires role hiring-manager or admin+" } } } }, "/api/v1/jobs/{id}/close": { "post": { "operationId": "jobsClose", "description": "Transition status: open → closed. Stamps closedAt / closedById.", "responses": { "200": { "$ref": "#/components/schemas/Job" }, "403": { "description": "Requires role hiring-manager or admin+" }, "409": { "description": "Job is not open" } } } } }, "components": { "schemas": { "Job": { "properties": { "internalNotes": { "type": ["string", "null"], "x-masked": "redact", "x-masked-show": ["admin+"] } } } } }}
# MCP server — the same routes, exposed as tools to Claude Code, Cursor, or any MCP client# Every tool carries the same firewall, access, guards and masking as the HTTP route. jobs_list GET /api/v1/jobs roles member+ params department (eq) · search (title) · sort · page returns Page<Job> — internalNotes redacted below admin jobs_create POST /api/v1/jobs roles hiring-manager, admin+ input title* · department* · salaryMin · salaryMax · internalNotes rejects status, organizationId, createdBy — system-managed jobs_update PATCH /api/v1/jobs/{id} roles hiring-manager, admin+ input title · department · internalNotes jobs_delete DELETE /api/v1/jobs/{id} (soft) roles admin+ jobs_close POST /api/v1/jobs/{id}/close roles hiring-manager, admin+ transition open → closed · stamps closedAt, closedById annotations idempotentHint: false · destructiveHint: false # Cross-tenant reads are impossible from here: organization_id comes from the session, never the tool input.
| Title | Department | Status | Salary | Internal notes | |
|---|---|---|---|---|---|
| Senior Backend Engineer | Engineering | open | 140–180k | 🔒 •••••••• | Close job |
| Product Designer | Design | open | 110–135k | 🔒 •••••••• | Close job |
| Support Lead | Customer | closed | — | 🔒 •••••••• | Close job |
Rendered from schema-registry.json. Zero UI code per table — forms follow the guards, columns follow the masking, buttons follow access.
Real shapes from the complete example in the docs, trimmed to fit. Nothing here runs on our servers: the build emits code, you deploy it.
Faster
The boring 80% is already done.
Everything a backend needs before your first real feature is generated from the same definitions. You spend your time, and your tokens, on what makes your app yours.
All the basics you'd expect
Typed end to end
Every build emits quickback.gen.ts: Row, Insert, Update, view items and action input/output for every table, with branded IDs so a candidate ID can't be passed where a job ID goes. Type-only, no runtime package.
Your agent already knows it
The CLI installs a Claude Code skill, Cursor rules and an MCP server that serves the docs, your config and your definitions to any AI tool. And because the build validates every definition, your agent is told exactly what's wrong instead of guessing at runtime.
An admin UI, for free
Set cms: true and a schema-driven CMS ships inside your Worker. Tables, inline editing, action dialogs, role switching, masked columns. Zero UI code per table. Add a table, rebuild, it's there.
Save your tokens
Your agent writes forty lines of definitions, not two thousand lines of routes, policies, migrations and types. Less to generate, less to review, and nothing security-critical left for a model to improvise. The build catches mistakes before a single request does.
More secure
Secure, because you can't forget.
Security isn't middleware you remember to add. It's declared next to the table and compiled into every route, every MCP tool and every admin button. The build refuses to emit an endpoint without it.
Firewall
Every table is scoped to a tenant. The WHERE clause is compiled into every query, so a cross-org read cannot be written.
Access
Roles gate every operation. Deny by default: no write route exists until you declare one.
Guards
Only the fields you list are writable from the client. Everything else is dropped before it reaches the database.
Masking
PII and notes are redacted below the role you name, in the API, the MCP tools, and the admin UI alike.
Rate limits
Per-operation request caps on every endpoint, backed by Cloudflare. Nothing to install.
Also in the box: end-to-end encryption for chosen columns, workflows with guarded transitions, and a per-view column allowlist so different roles see different fields.How the layers stack
When it doesn't fit
You're never stuck.
Generated code covers the common case. For everything else there are two doors, and both are wide open.
Door one
Drop into code
An action is a typed handler with the firewalled database in hand. Write any TypeScript. Need raw SQL? Opt in on that one action and the build lets it through, on the record.
// quickback/features/jobs/actions/salary-bands.ts
import { z } from "zod";
import { sql } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
path: "/salary-bands", // standalone, not per-record
access: { roles: ["admin+"] },
allowRawSql: true, // explicit, per action
output: z.array(z.object({ department: z.string(), median: z.number() })),
async execute({ db, ctx }) {
return db.execute(sql`
SELECT department, AVG(salary_min) AS median
FROM jobs
WHERE organization_id = ${ctx.activeOrgId} -- raw SQL: you scope it
GROUP BY department
`);
},
});Every automatic rule has an explicit exception, too: firewall exceptions, access overrides, guard opt-outs, one resource at a time.Actions
Door two
Or just fork the code
The output is ordinary TypeScript: Hono routes, Drizzle schema and migrations, Better Auth. No SDK, no runtime, no proprietary anything. Stop running the build tomorrow and you still have a working codebase on libraries you already know.
- Nothing runs on Quickback's servers. We never see your database or your data.
- Your action handlers live in your project and are never overwritten.
- Rebuilds overwrite
src/only. Patch it after a build, or branch off for good.
Who it's for
Built for one person shipping real things.
The weekend side project
Real auth, real permissions, a real admin panel, before Sunday night. Not a demo that needs rewriting the moment someone else signs up.
Indie developers →The commercial product
Multi-tenant from the first table. Orgs, roles, masking, audit columns and rate limits are the default, so the security review is already written.
Ship a SaaS →The agent-first builder
You direct, the agent writes. Give it a target where the dangerous parts are compiled, not prompted, and review forty lines instead of four thousand.
First-time builders →Define. Build. Ship.
Free while in beta. Your code, your Cloudflare account, our build step.
Questions about rebuilds, lock-in, databases or custom logic? Read the FAQ
