← Back to Projects

Obelisk

#cloudflare-workers·#hono·#headless-cms·#drizzle·#turso·#edge-computing·#refine·#internal-tools

Shared backend that powers my personal sites. It's a custom headless CMS built on Hono and Cloudflare Workers, paired with a Refine admin frontend. It handles content management, authentication, and content delivery for multiple sites from a single edge deployment, with a schema-driven approach that makes extending it straightforward.

Overview #

Obelisk is a custom headless CMS backend built with Hono and deployed to Cloudflare Workers. It's paired with a Refine admin frontend for CRUD operations and serves multiple personal sites (my portfolio and blog) with shared infrastructure.

I previously used Payload CMS, which was genuinely good, but it was tightly coupled to the Vercel ecosystem. I had already been moving my frontends to Cloudflare, and Figma's acquisition of Payload was the final push to leave. I didn't want to start from scratch though. The goal was to keep the benefits I had (admin UI conventions, schema-driven content modeling) while shedding the lock-in.

The constraints were clear: cost-sensitive (free tiers matter), edge-native (my Astro frontends already live on Cloudflare, so why wouldn't the backend), portable (no vendor lock-in, swappable components), and fast to extend (adding a new content type shouldn't require a week of boilerplate).

How It Fits Together #

Both sides of the stack are headless. Refine expects standard REST conventions (list, getOne, create, update, delete). The backend exposes exactly that through a generic CRUD abstraction. Neither side is locked into the other. I get conventions where they help and customization where it matters.

The backend is built around a factory pattern called buildGenericEntityService. You define your Drizzle schema, and the factory gives you a complete CRUD service with type-safe filtering, sorting, and pagination. Beyond CRUD, it also handles authentication via Better Auth (session-based admin access + API keys with domain restrictions for frontend content fetching), webhooks for triggering external services, and content aggregation endpoints that return composed data.

Refine gives me a React-based admin UI that follows these same REST conventions out of the box. Integration is minimal. I get tables, forms, and CRUD operations without building them from scratch, but I can customize anything when needed. Writing admin UIs is tedious. Refine handles the tedium while staying out of my way.

For the database, I chose Turso (SQLite-compatible, serverless). I previously considered Neon (Postgres) but their free tier got worse over time, and D1 was still maturing. Turso felt like the pragmatic middle ground. The important part is that I'm using Drizzle ORM as the abstraction layer, so switching databases later is a config change and some migration work, not a rewrite.

Schema-Driven Development #

One pattern I'm particularly happy with is the schema-driven approach. The database schema is defined once in Drizzle. From there:

  1. Drizzle-Zod generates validation schemas automatically
  2. TypeScript types are inferred from those schemas
  3. The generic CRUD service uses those types for full type safety

There's one source of truth. The schema defines what exists, and everything else derives from it. No drift between database, validation, and types.

// Define the table
export const projectsTable = sqliteTable("projects", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  description: text("description"),
  type: text("type").notNull(),
  isPublished: integer("is_published", { mode: "boolean" }).default(false),
  // ...
});

// These are fields the API will allow sorting and filtering on
export const projectSortFilterFields = [
  "id",
  "name",
  "slug",
  // ...
];

export const ProjectListParamsSchema = buildListParamsSchema(
  projectSortFilterFields,
  ProjectSchema,
);

// Schemas and types are derived
export const ProjectSchema = createSelectSchema(projectsTable);
export type Project = z.infer<typeof ProjectSchema>;
export type ProjectListParams = z.infer<typeof ProjectListParamsSchema>;

// Generate type safe service with standard CRUD methods
export const projectService = buildGenericEntityService<
  Project,
  typeof projectsTable,
  ProjectListParams
>(projectsTable, ProjectSchema, projectSortFilterFields);

Multi-Tenancy #

The backend serves multiple sites from a single deployment. Some content is shared across sites (like projects), while other content is site-specific. Shared entities use flags and filters to control visibility per site. Site-specific content (configuration pages, posts) is scoped naturally through separate tables or namespaced routes.

Configuration pages use a singleton pattern with auto-initialization. If a site's home or about page doesn't exist yet, it gets created with sensible defaults on first access. Adding a new site doesn't require manual database seeding.

The multi-tenant design here isn't about supporting arbitrary external users. It's about running my own sites on shared infrastructure with minimal duplication. One backend, one database, one deployment. The sites just see different slices of it.

The Tradeoffs #

This approach isn't free. There are real costs compared to something like Payload or Strapi:

  1. More initial setup. No visual schema builder. Everything is code-first.
  2. Self-managed auth. Better Auth made this easy, but it's still something I own.
  3. No ecosystem plugins. Need image optimization? Build it or integrate it yourself.

For my use case, these tradeoffs are worth it. I get full control, edge deployment, and zero vendor lock-in. I understand what's running because I wrote it.

Tech Stack #

  • Cloudflare Workers: The runtime. Globally distributed, minimal cold starts, excellent pricing. It's become my default for backend services.
  • Hono: A lightweight web framework with first-class Workers support. It feels like the natural way to write Workers code. Routing, middleware, and type safety without the overhead.
  • Turso: SQLite-compatible serverless database with optional embedded replicas. Great free tier, HTTP-based access that works well with edge functions.
  • Drizzle ORM: Type-safe SQL query builder. Lightweight, excellent TypeScript integration, and not tied to any specific database.
  • Better Auth: Modern authentication library with built-in API key management. Handles sessions, accounts, and API keys without external dependencies.
  • Zod: TypeScript-first schema validation. Integrates with Drizzle via drizzle-zod for automatic schema generation.
  • Refine: Headless React framework for admin UIs. Provides CRUD conventions without locking you into a specific design.
  • Biome: Formatter and linter in one tool. I've been preferring it over ESLint + Prettier for the simplicity of a single dependency.

Future Considerations #

Open Source Starter The generic CRUD patterns and schema-driven approach could be useful to others. I've considered extracting them into a starter template for Hono + Drizzle + Refine projects.

See Also #