Quickback Docs

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-site

This scaffolds a project with:

my-site/
├── quickback/
│   ├── quickback.config.ts       # Pinned-organization config
│   └── features/
│       └── posts/
│           ├── posts.ts          # Schema + security (defineTable)
│           └── actions/
│               ├── publish.ts    # Publish action
│               └── unpublish.ts  # Unpublish action
├── src/                          # Compiled output (generated)
├── package.json
├── tsconfig.json
├── wrangler.toml
└── drizzle.config.ts

Generated 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 { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";

export const posts = sqliteTable("posts", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content"),
  excerpt: text("excerpt"),
  status: text("status").notNull().default("draft"),
  publishedAt: text("published_at"),

  // Author tracking only — this is metadata, not access control.
  // Named "authorId" (not "userId") so the firewall validator does NOT
  // enter user-scoped RLS mode. Reads are gated by PUBLIC; writes by the
  // admin access check below.
  authorId: text("author_id").notNull(),
});

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", "status", "publishedAt"],
    updatable: ["title", "slug", "content", "excerpt", "status", "publishedAt"],
    immutable: ["id", "authorId"],
  },
  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" },
});
  • PUBLIC role on read.access means no authentication required for GET / and GET /:id
  • admin is the org-membership admin role — an authenticated user whose membership in the pinned organization has role: "admin" (or above). For platform-wide roles stored on user.role, use userRole: [...] instead — see Pinned Organization Mode
  • authorId is plain metadata recording who wrote the post — it is deliberately not a firewall scope, because an owner predicate would hide every post from anonymous readers
  • softDelete marks posts as deleted rather than removing them from the database

Post Actions (actions/publish.ts, actions/unpublish.ts)

The template scaffolds one file per action under features/posts/actions/. Each is a defineAction with a record-level access condition and an execute handler:

// quickback/features/posts/actions/publish.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { posts } from "../posts";

export default defineAction({
  description: "Publish a draft post",
  input: z.object({}),
  access: {
    roles: ["admin"],
    record: { status: { equals: "draft" } },
  },
  sideEffects: "sync",
  async execute({ db, record }) {
    const [updated] = await db
      .update(posts)
      .set({
        status: "published",
        publishedAt: new Date().toISOString(),
      })
      .where(eq(posts.id, record.id))
      .returning();
    return updated;
  },
});

unpublish.ts is the mirror image — it requires status: "published" and reverts the post to draft. These actions are admin-only and include record-level access conditions: you can only publish drafts and unpublish published posts.

Setup Steps

1. Install Dependencies

npm install

2. Log In to the Compiler

quickback login

3. Compile Your Definitions

quickback compile

4. 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-features

Copy 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:local

6. Start Development Server

npm run dev

Your 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-here

Admin 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(),
});

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 compile

Deploying to Production

# Apply remote migrations
npm run db:migrate:remote

# Deploy to Cloudflare
npm run deploy

Next Steps

On this page