Blog Template
Step-by-step guide to creating a pinned-organization blog/CMS backend with Quickback on Cloudflare Workers.
The blog template creates a fixed-org backend for a blog or CMS, deployed to Cloudflare Workers with D1. Public visitors can read content without authentication, while admins manage posts through their membership in one pinned organization.
This template uses pinned organization mode. Organization support stays on, but the generated project hard-codes one organization id and hides org switching UI.
Create the Project
quickback create blog my-site
cd my-siteThis scaffolds a project with:
my-site/
├── quickback/
│ ├── quickback.config.ts # Pinned-organization config
│ └── features/
│ └── posts/
│ └── posts.ts # Schema, security, and publish/unpublish transitions
├── src/ # Compiled output (generated)
├── package.json
├── tsconfig.json
├── wrangler.toml
└── drizzle.config.tsGenerated Configuration
quickback.config.ts
export default {
name: "my-site",
template: "hono",
features: {
pinnedOrganizationId: "org_replace_me",
},
providers: {
runtime: { name: "cloudflare", config: {} },
database: {
name: "cloudflare-d1",
config: { binding: "DB" },
},
auth: { name: "better-auth", config: {} },
},
};Replace org_replace_me with a real Better Auth organization id before deploying. Unlike the removed single-tenant mode, this template keeps the Better Auth organization plugin and resolves roles from membership in that pinned org. See Pinned Organization Mode for details.
Posts Feature (posts.ts)
The template includes a posts feature with public read access and admin-only write access:
import { q, defineTable } from "@quickback/compiler";
export const posts = q.table("posts", {
id: q.id(),
title: q.text().required(),
slug: q.text().required().unique(),
content: q.text().optional(),
excerpt: q.text().optional(),
status: q.text().default("draft").required(),
publishedAt: q.timestamp().optional(),
// Author tracking only — this is metadata, not access control.
// Named "authorId" (not "userId") because a `userId` column on a feature
// table makes the compiler complain: it is not auto-detected as the owner
// column (that name is `ownerId`), and it errors when it is the only
// candidate column on the table. Reads here are gated by PUBLIC; writes by
// the admin access check below.
authorId: q.text().required(),
// Compiler-managed columns — declared, not magic. `...q.audit()` is the
// audit quartet on every table; `...q.softDelete()` adds the soft-delete
// pair because `delete.mode` is "soft".
...q.audit(),
...q.softDelete(),
});
export default defineTable(posts, {
// PUBLIC reads — no isolation predicate at the firewall layer.
// Anonymous viewers have no ctx.userId, so a userId/orgId WHERE clause
// would gate every row out. softDelete keeps deleted posts hidden.
firewall: {
softDelete: {},
},
guards: {
createable: ["title", "slug", "content", "excerpt"],
updatable: ["title", "slug", "content", "excerpt"],
immutable: ["id", "authorId"],
// `status` is workflow-owned: only the transitions below write it.
protected: { status: ["publish", "unpublish"] },
},
// Transitions generate POST /api/v1/posts/:id/publish and its inverse,
// stamping publishedAt on publish and clearing it on unpublish.
transitions: {
publish: {
field: "status", from: "draft", to: "published",
stamp: { at: "publishedAt" },
undo: "unpublish",
access: { roles: ["admin"] },
},
},
read: {
access: { roles: ["PUBLIC"] },
},
// "admin" = org-membership admin role, resolved from membership in
// the pinned organization (features.pinnedOrganizationId).
create: { access: { roles: ["admin"] } },
update: { access: { roles: ["admin"] } },
delete: { access: { roles: ["admin"] }, mode: "soft" },
});PUBLICrole onread.accessmeans no authentication required forGET /andGET /:idadminis the org-membership admin role — an authenticated user whose membership in the pinned organization hasrole: "admin"(or above). For platform-wide roles stored onuser.role, useuserRole: [...]instead — see Pinned Organization ModeauthorIdis plain metadata recording who wrote the post — it is deliberately not a firewall scope, because an owner predicate would hide every post from anonymous readerssoftDeletemarks posts as deleted rather than removing them from the database
Post Transitions (publish / unpublish)
The template declares the post lifecycle as transitions on the table instead of hand-written action files. The compiler generates both actions — POST /api/v1/posts/:id/publish and POST /api/v1/posts/:id/unpublish — with the from/to guard, idempotency, and CMS buttons built in:
publishrequiresstatus: "draft", setsstatus: "published", and stampspublishedAt.unpublishis the generatedundopair: it requiresstatus: "published", reverts todraft, and clearspublishedAt.- Both are admin-only via
access: { roles: ["admin"] };guards.protectedmeans nothing else can writestatus, not even a plainPATCH.
Need custom logic in the handler? Write actions/publish.ts yourself — an explicit action file always wins over the generated one — and keep the same transition: block so the guard stays compiler-owned. See Actions.
Setup Steps
1. Install Dependencies
npm install2. Log In to the Compiler
quickback login3. Compile Your Definitions
quickback build4. Create D1 Databases
Like every D1 project, the blog template uses dual database mode by default — separate databases for auth and application data:
npx wrangler d1 create my-site-auth
npx wrangler d1 create my-site-featuresCopy the database IDs from the output and update your wrangler.toml:
[[d1_databases]]
binding = "AUTH_DB"
database_name = "my-site-auth"
database_id = "paste-auth-id-here"
[[d1_databases]]
binding = "DB"
database_name = "my-site-features"
database_id = "paste-features-id-here"5. Run Migrations (Local)
npm run db:migrate:local6. Start Development Server
npm run devYour API is running at http://localhost:8787.
Using the API
Public Endpoints (No Auth Required)
# List all posts
curl http://localhost:8787/api/v1/posts
# Get a single post
curl http://localhost:8787/api/v1/posts/post-id-hereAdmin Endpoints (Auth Required)
# Create a post (admin only)
curl -X POST http://localhost:8787/api/v1/posts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Hello World", "slug": "hello-world", "content": "..."}'
# Publish a post (admin only) — record actions mount at /:id/{actionName}
curl -X POST http://localhost:8787/api/v1/posts/post-id/publish \
-H "Authorization: Bearer $TOKEN"Pairing with a Frontend
Since this is a JSON API, pair it with any frontend framework:
// Astro, Next.js, SvelteKit, etc.
const posts = await fetch('https://api.mysite.com/api/v1/posts').then(r => r.json());
// Admin operations require authentication
const res = await fetch('https://api.mysite.com/api/v1/posts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: 'New Post', slug: 'new-post', content: '...' }),
});Adding More Features
Add new content types by creating feature directories. For example, a pages feature for static pages:
// quickback/features/pages/pages.ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";
export const pages = sqliteTable("pages", {
id: text("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content"),
authorId: text("author_id").notNull(),
createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
createdBy: text("created_by"),
modifiedBy: text("modified_by"),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export default defineTable(pages, {
// PUBLIC reads need an open firewall — an owner predicate would hide
// every row from anonymous visitors. softDelete still hides deletions.
firewall: { softDelete: {} },
read: {
access: { roles: ["PUBLIC"] },
},
create: { access: { roles: ["admin"] } },
update: { access: { roles: ["admin"] } },
delete: { access: { roles: ["admin"] }, mode: "soft" },
});Then recompile:
quickback buildDeploying to Production
# Apply remote migrations
npm run db:migrate:remote
# Deploy to Cloudflare
npm run deployNext Steps
- Pinned Organization Mode — How fixed-org mode works
- Access Control — PUBLIC role and role-based permissions
- Custom Actions — Add business logic beyond CRUD
- Cloudflare Template — Multi-tenant alternative