Skip to content

Repository files navigation

Quire

A small headless CMS built with Next.js and TypeScript, running on Cloudflare Workers. It ships a JSON API, an authenticated admin panel, and a public blog that reads through the same published content layer the API exposes.

Live demo: https://quire.filipespan.workers.dev Admin: https://quire.filipespan.workers.dev/login Demo login: demo@quire.dev / demo1234 (read only)

The demo account can open every screen but cannot write. The database reseeds itself on a schedule, so the sample content stays clean no matter who pokes at it.

What it is

Quire is a generic take on a custom CMS: posts, pages, media, categories, and users, with role based access. It is deliberately small. There is no plugin system, no visual page builder, and no multi tenancy. The goal is a codebase where every decision is easy to explain, not a product that does everything.

Stack

  • Next.js 16 (App Router) and React 19, TypeScript in strict mode.
  • Cloudflare Workers through OpenNext (@opennextjs/cloudflare).
  • Cloudflare D1 (SQLite at the edge) with Drizzle ORM and versioned SQL migrations.
  • Cloudflare R2 for media blobs.
  • Vitest with @cloudflare/vitest-pool-workers, running tests against a real D1 and R2 in the Workers runtime.

Features

  • Cookie based authentication with server side, revocable sessions.
  • Password hashing with PBKDF2 through the Web Crypto API, no native modules.
  • Full CRUD for posts, pages, categories, media, and users.
  • Roles: admin (everything), editor (content, not users), demo (read only). Authorization is enforced in the API, not just hidden in the UI.
  • A JSON API, including a public read API that only ever returns published content.
  • Media uploads streamed to R2 and served back through the app.
  • Markdown post bodies rendered to HTML on the server.
  • A public blog: home, post pages, category pages, and static pages.
  • An accessible admin: labelled inputs, visible focus, keyboard operable controls, and a clear read only banner in demo mode.

Architecture

Browser -> Cloudflare Worker (Next.js via OpenNext)
              |- /                public blog (server components)
              |- /admin/*         auth-gated admin panel (React)
              |- /api/*           REST API (route handlers)
              |     |- auth, posts, pages, categories, media, users
              |     |- public/*   published content, no auth
              |
              |- D1 (SQLite)      content, users, sessions   [Drizzle]
              |- R2               media blobs

quire-cron (separate Worker) -> [service binding] -> POST /api/admin/reset

Requests flow through route handlers into a thin service layer that owns all database access. The service layer takes a Drizzle instance as an argument, which is what makes it straightforward to test.

API

Authenticated endpoints require the session cookie set by POST /api/auth/login.

Method Path Notes
POST /api/auth/login Sets the session cookie
POST /api/auth/logout Clears and revokes the session
GET /api/auth/me Current user
GET, POST /api/posts Editors and admins can write
GET, PATCH, DELETE /api/posts/[id]
GET, POST /api/pages
GET, PATCH, DELETE /api/pages/[id]
GET, POST /api/categories
GET, PATCH, DELETE /api/categories/[id]
GET, POST /api/media POST is multipart/form-data
GET, PATCH, DELETE /api/media/[id]
GET, POST /api/users Admin only
GET, PATCH, DELETE /api/users/[id] Admin only

Public endpoints need no auth and only return published content:

curl https://quire.filipespan.workers.dev/api/public/posts
curl https://quire.filipespan.workers.dev/api/public/posts/shipping-on-the-edge-with-workers
curl https://quire.filipespan.workers.dev/api/public/pages/about
curl https://quire.filipespan.workers.dev/api/public/categories

Local development

Prerequisites: Node 22 and npm.

npm install

# Apply migrations to the local D1
npm run db:migrate:local

# Copy secrets for local dev, then edit the values
cp .dev.vars.example .dev.vars

# Run the Next.js dev server
npm run dev

To seed local data, run the app (npm run preview) and call the reset endpoint with the secret from your .dev.vars:

curl -X POST http://localhost:8787/api/admin/reset -H "x-reset-secret: <secret>"

Useful scripts:

npm run typecheck   # tsc, no emit
npm run lint        # eslint
npm test            # vitest against real D1 and R2
npm run db:generate # generate a migration from the Drizzle schema

Deployment

The app deploys as two Workers on Cloudflare: quire (the Next.js app) and quire-cron (the scheduled reset trigger).

# Provision resources once
npx wrangler d1 create quire-db
npx wrangler r2 bucket create quire-media

# Secrets (never committed)
npx wrangler secret put RESET_SECRET
npx wrangler secret put SEED_ADMIN_PASSWORD
npx wrangler secret put SEED_EDITOR_PASSWORD

# Migrate and deploy
npm run db:migrate:remote
npm run deploy

# Seed the deployed database once
curl -X POST https://<your-worker>/api/admin/reset -H "x-reset-secret: <secret>"

# Deploy the cron worker
cd workers/cron && npx wrangler deploy

Technical decisions

Each choice here is something I can defend in an interview.

Next.js on Cloudflare Workers via OpenNext. The brief was Next.js with real data on Cloudflare. OpenNext is the supported path to run an App Router app on Workers, so the whole thing (UI, API, database, media) lives on one platform and one deploy.

D1 with Drizzle ORM. D1 is SQLite at the edge, colocated with the worker, so a query is a binding call rather than a trip to another region. Drizzle gives typed queries and generates plain SQL migrations, so the schema is the single source of truth and there is no loose any in the data layer.

PBKDF2 for password hashing. bcrypt and scrypt native modules do not run on the Workers runtime, and a pure JavaScript bcrypt is slow. PBKDF2 is available through the platform's Web Crypto (crypto.subtle), so hashing is fast, has no dependencies, and uses a vetted primitive with a per user salt and a high iteration count.

Server side sessions, not JWTs. On login the server stores a random opaque token in a sessions table and sets it in an httpOnly, Secure, SameSite=Lax cookie. Server side sessions are revocable, which the demo reset relies on: wiping the table logs everyone out. The cookie carries only the token, never user data.

A service layer that takes the database as an argument. Route handlers stay thin: authenticate, validate with Zod, call a service, map errors to status codes. The services own all database access and receive a Drizzle instance, so tests call them directly against a real D1 with no HTTP and no mocks.

The public site reads the published layer directly. The public REST API is the contract for external consumers, and there are curl examples above to prove it. The site itself lives in the same worker, so instead of making the worker send an HTTP request to itself (extra latency and a failure point), its server components call the same published content functions the API is built on. The line between draft and published is one function, not a self subrequest.

Read only demo plus a scheduled reset. The public demo account has the demo role, which the API blocks from every write, so a stranger can explore the admin without breaking anything. A second, tiny Worker (quire-cron) fires every three hours and calls the reset endpoint to reseed the database. It reaches the app through a service binding rather than the public internet, which is faster and sidesteps worker to worker loop protection. The reset is one idempotent function with two entry points (the cron worker and a secret guarded route), so the schedule works and the reset stays testable.

Tests in the real runtime. @cloudflare/vitest-pool-workers runs the suite inside workerd with a real D1 and R2, so the tests exercise the same SQLite behavior, foreign keys, and R2 API the deployed app uses, not a mock that can drift from reality.

Testing

npm test

The suite covers password hashing and sessions, CRUD for every resource, authorization and the demo read only block, the public API exposing only published content, and the reset restoring a known seed.

About

Built by Filipe Spanghero as a public, production shaped portfolio project. The admin panel uses hand written accessible components; a natural next step would be to adopt cable-ui, my React component library, as its UI kit.

License

MIT. See LICENSE.

About

A small headless CMS on Cloudflare Workers: Next.js, TypeScript, D1, R2. JSON API, auth, admin panel, public blog, live demo.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages