Most "starter templates" give you a hello-world endpoint and wish you luck. When I started building my production app on Cloudflare Workers, I spent weeks figuring out how to structure the codebase so it wouldn't become a mess at 50+ endpoints.
I extracted all of that into an open-source template. No AI inference, no business logic — just the infrastructure patterns that took me months to get right.
GitHub: cloudflare-hono-starter
The Problem
Every Cloudflare Workers tutorial shows you this:
export default {
fetch(request: Request, env: Env) {
return new Response("Hello World!");
},
};
Then you're on your own for:
- Where do routes go? How do I organize 30 endpoints?
- How do I validate requests and generate API docs?
- Where does business logic live vs database queries?
- How do I add auth, rate limiting, cron jobs?
- How do I test any of this?
I needed answers to all of these. So I built them.
The Stack
| Layer | Technology |
|---|---|
| Runtime | Cloudflare Workers |
| Framework | Hono + Zod OpenAPI |
| Database | Cloudflare D1 (SQLite at the edge) |
| ORM | Drizzle ORM |
| Validation | Zod |
| Docs | Auto-generated Swagger UI |
Zero Node.js dependencies. Password hashing uses the Web Crypto API. JWT uses Hono's built-in utilities. Everything runs natively on the Workers runtime.
The Architecture: Vertical Slices
Instead of grouping by technical layer (all controllers together, all services together, all models together), each feature is a self-contained folder:
src/features/posts/
├── api/
│ ├── posts.contract.ts # Zod schemas + OpenAPI route definitions
│ └── posts.routes.ts # HTTP handlers
├── core/
│ └── posts.service.ts # Business logic
└── data/
└── posts.repository.ts # Database queries (Drizzle)
Data flows in one direction:
HTTP Request → Contract (validates) → Route → Service → Repository → D1
Why this matters: when you need to add a "comments" feature, you create src/features/comments/ and you're done. You don't touch auth, posts, or anything else. The Open/Closed Principle in action.
Show Me the Code
1. Contracts — Define Once, Validate Everywhere
Each route starts with a Zod schema that doubles as OpenAPI documentation:
// src/features/posts/api/posts.contract.ts
export const CreatePostSchema = z
.object({
title: z.string().min(1, "Title is required").max(200)
.openapi({ example: "Getting Started with Hono" }),
content: z.string().min(1, "Content is required")
.openapi({ example: "Hono is a fast web framework..." }),
published: z.boolean().default(false),
})
.openapi("CreatePost");
export const createPostRoute = createRoute({
method: "post",
path: "/",
tags: ["Posts"],
security: [{ BearerAuth: [] }],
request: {
body: { content: { "application/json": { schema: CreatePostSchema } } },
},
responses: {
201: {
description: "Post created",
content: { "application/json": { schema: PostSchema } },
},
},
});
This single definition gives you:
- Request validation — invalid payloads get a 400 with field-level errors automatically
-
TypeScript types —
c.req.valid("json")is fully typed -
Swagger UI — interactive docs at
/docswith zero extra work
2. Services — Business Logic, No HTTP
Services don't know about HTTP. They receive a repository through the constructor and focus purely on business rules:
// src/features/posts/core/posts.service.ts
export class PostsService {
constructor(private repo: PostsRepository) {}
async create(authorId: string, data: { title: string; content: string; published?: boolean }) {
const now = new Date().toISOString();
return this.repo.create({
id: crypto.randomUUID(),
title: data.title,
content: data.content,
published: data.published ?? false,
authorId,
createdAt: now,
updatedAt: now,
});
}
async getById(id: string, authorId: string) {
const post = await this.repo.findByIdAndAuthor(id, authorId);
if (!post) throw new PostNotFoundError();
return post;
}
}
Because the service depends on the repository interface (not a concrete DB implementation), testing is trivial:
// posts.service.test.ts
const repo = {
findByIdAndAuthor: vi.fn(),
create: vi.fn(),
// ...
};
const service = new PostsService(repo);
it("throws when post not found", async () => {
repo.findByIdAndAuthor.mockResolvedValue(undefined);
await expect(service.getById("missing", "user-1"))
.rejects.toThrow(PostNotFoundError);
});
3. Repositories — Type-Safe Queries with Drizzle
No raw SQL. Everything goes through Drizzle ORM:
// src/features/posts/data/posts.repository.ts
export class PostsRepository {
constructor(private db: Database) {}
async listByAuthor(authorId: string, limit: number, offset: number) {
const [data, countResult] = await Promise.all([
this.db.query.posts.findMany({
where: eq(posts.authorId, authorId),
orderBy: desc(posts.createdAt),
limit,
offset,
}),
this.db
.select({ count: sql<number>`count(*)` })
.from(posts)
.where(eq(posts.authorId, authorId)),
]);
return { data, total: countResult[0].count };
}
}
4. Dependency Injection — Per Request, Zero Cost
The ServiceContainer lazily creates services. Unused services never get instantiated:
// src/di.ts
export class ServiceContainer {
private _posts?: PostsService;
constructor(private env: Bindings) {
this.db = createDb(env.DB);
}
get posts(): PostsService {
if (!this._posts) {
this._posts = new PostsService(new PostsRepository(this.db));
}
return this._posts;
}
}
// In a route handler — one line
const services = new ServiceContainer(c.env);
const post = await services.posts.getById(id, userId);
No DI framework. No decorators. Just lazy getters. The container is instantiated per request, so there's no shared mutable state between requests.
What's Included Beyond CRUD
The template isn't just a to-do app. It includes patterns I needed in production:
Rate Limiting (IP-Based Sliding Window)
// Presets ready to use
app.use("/api/auth/*", authRateLimit); // 20 req/min
app.use("/api/sensitive/*", strictRateLimit); // 5 req/5min
Uses D1 for state — no Redis, no external services. The rate limiter gracefully degrades if the table doesn't exist yet.
Composable Auth Middleware
The auth middleware is a factory function, not a hardcoded singleton:
// Default — uses the main DB
postsApp.use("/*", protectAndLoadUser);
// Custom — point at a different D1 database
const customAuth = createAuthMiddleware((env) => env.OTHER_DB);
otherApp.use("/*", customAuth);
A/B Testing with Sticky Assignments
Full feature with weighted random variant selection:
// User always gets the same variant (sticky)
const assignment = await service.getOrAssignVariant(userId, "pricing-test");
// → { variantKey: "treatment", config: { price: 14.99 } }
Includes admin endpoints for managing experiments, updating weights, and viewing assignment stats.
Cron Jobs, Queues, and Durable Objects
// Type-safe cron registry
const CRON_JOBS: CronRegistry = {
"0 3 * * *": cleanupExpiredSubscriptions,
"0 4 * * *": cleanupRateLimits,
};
// Typed queue consumer with per-message ack/retry
for (const msg of batch.messages) {
try {
await processJob(msg.body); // fully typed
msg.ack();
} catch {
msg.retry(); // Cloudflare retries with backoff
}
}
Fire-and-Forget with waitUntil()
Session tracking, analytics, and cleanup never block the response:
c.executionCtx.waitUntil(
trackSession(db, userId) // runs after response is sent
);
Getting Started
It's a GitHub template — one click:
# Option 1: GitHub CLI
gh repo create my-api --template TyposBro/cloudflare-hono-starter --clone
cd my-api && npm install
# Option 2: GitHub UI
# Click "Use this template" on the repo page
Then:
npx wrangler d1 create my-app-db # create your database
cp .dev.vars.example .dev.vars # set JWT secrets
npm run db:migrate:local # run migrations
npm run dev # http://localhost:8787
Swagger UI is at http://localhost:8787/docs. Every endpoint is documented automatically.
Adding Your Own Feature
The whole point of the template is that adding features is mechanical:
mkdir -p src/features/comments/{api,core,data}- Add a table to
src/db/schema.ts - Run
npm run db:generateto create the migration - Build the slice: repository → service → contract → routes
- Add a getter to
src/di.ts - Mount with one line:
app.route("/api/comments", commentsApp)
No global files to hunt through. No "where does this go?" Just follow the pattern.
The Numbers
- 37 source files covering auth, CRUD, A/B testing, cron, queues, durable objects
- 8 middleware (auth, admin, rate limit, CORS, error handling, session tracking, subscription, version check)
- 20 unit tests with vitest
- CI pipeline with typecheck + tests on every push
- Zero Node.js dependencies — runs natively on Workers
If you've been looking for a serious starting point for Cloudflare Workers that goes beyond hello-world, give it a try. PRs welcome.
Top comments (0)