API Templates

API templates provide a Hono REST API server without a frontend. Perfect for microservices, backend services, or when you have a separate frontend application.

Available API Templates

API with Everything

Complete REST API with database and authentication.

npm create vinoflare@latest my-api --type=api-only

Features:

  • ✅ Hono REST API framework
  • ✅ Cloudflare D1 database with Drizzle ORM
  • ✅ Better Auth with Discord OAuth
  • ✅ OpenAPI documentation
  • ✅ Testing setup with Vitest

API without Authentication

Public API with database persistence.

npm create vinoflare@latest my-api --type=api-only --no-auth

Features:

  • ✅ Hono REST API framework
  • ✅ Cloudflare D1 database with Drizzle ORM
  • ❌ No authentication
  • ✅ Public endpoints
  • ✅ CORS configured

API without Database

Stateless API for webhooks, proxies, or external integrations.

npm create vinoflare@latest my-api --type=api-only --no-db

Features:

  • ✅ Hono REST API framework
  • ❌ No database
  • ❌ No authentication
  • ✅ Minimal footprint
  • ✅ Fast cold starts

Project Structure

Core Concepts

Module System

API templates use a modular architecture where each feature is a self-contained module:

// src/server/modules/todos/todos.routes.ts
import { createRouter } from "@/server/core/api-builder";
import * as handlers from "./todos.handlers";
 
export const todosRouter = createRouter()
  .get("/", handlers.getTodos)
  .get("/:id", handlers.getTodo)
  .post("/", handlers.createTodo)
  .put("/:id", handlers.updateTodo)
  .delete("/:id", handlers.deleteTodo);
// src/server/modules/todos/todos.handlers.ts
import { Context } from "hono";
import { db } from "@/server/db";
import { todos } from "@/server/db/schema";
 
export async function getTodos(c: Context) {
  const allTodos = await db.select().from(todos);
  return c.json(allTodos);
}
 
export async function createTodo(c: Context) {
  const body = await c.req.json();
  const newTodo = await db.insert(todos).values(body).returning();
  return c.json(newTodo[0], 201);
}

Middleware Stack

// src/server/core/app-factory.ts
export function createApp() {
  const app = new Hono<AppBindings>();
 
  // Global middleware
  app.use("*", jsonLogger());
  app.use("*", cors());
  
  // Conditional middleware
  if (hasDatabase) {
    app.use("*", database());
  }
  
  if (hasAuth) {
    app.use("/api/*", authGuard());
  }
  
  // Mount routes
  app.route("/api/todos", todosRouter);
  app.route("/api/users", usersRouter);
  
  // Error handling
  app.onError(errorHandler);
  
  return app;
}

Database Integration

Schema Definition

// src/server/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
 
export const todos = sqliteTable("todos", {
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
  title: text("title").notNull(),
  description: text("description"),
  completed: integer("completed", { mode: "boolean" }).default(false),
  userId: text("user_id").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .$defaultFn(() => new Date()),
  updatedAt: integer("updated_at", { mode: "timestamp" })
    .$defaultFn(() => new Date()),
});
 
export type Todo = typeof todos.$inferSelect;
export type NewTodo = typeof todos.$inferInsert;

Database Queries

// Complex queries with Drizzle
import { eq, and, like, desc } from "drizzle-orm";
 
// Filter by user
const userTodos = await db
  .select()
  .from(todos)
  .where(eq(todos.userId, userId));
 
// Search with pagination
const results = await db
  .select()
  .from(todos)
  .where(
    and(
      eq(todos.userId, userId),
      like(todos.title, `%${search}%`)
    )
  )
  .orderBy(desc(todos.createdAt))
  .limit(limit)
  .offset(offset);
 
// Count total
const [{ count }] = await db
  .select({ count: count() })
  .from(todos)
  .where(eq(todos.userId, userId));

Authentication

Setup Better Auth

// src/server/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { discord } from "better-auth/social-providers";
 
export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "sqlite",
  }),
  socialProviders: {
    discord: {
      clientId: c.env.DISCORD_CLIENT_ID,
      clientSecret: c.env.DISCORD_CLIENT_SECRET,
    },
  },
  trustedOrigins: ["http://localhost:5173"],
});

Protected Routes

// src/server/middleware/auth-guard.ts
export function authGuard() {
  return async (c: Context, next: Next) => {
    const session = await auth.api.getSession({ headers: c.req.raw.headers });
    
    if (!session) {
      return c.json({ error: "Unauthorized" }, 401);
    }
    
    c.set("user", session.user);
    c.set("session", session);
    
    await next();
  };
}
 
// Use in routes
const protectedRouter = createRouter()
  .use("*", authGuard())
  .get("/profile", async (c) => {
    const user = c.get("user");
    return c.json(user);
  });

Request Validation

Using Zod

import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
 
const createTodoSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string().optional(),
  dueDate: z.string().datetime().optional(),
});
 
export const todosRouter = createRouter()
  .post("/",
    zValidator("json", createTodoSchema),
    async (c) => {
      const validated = c.req.valid("json");
      const todo = await createTodo(validated);
      return c.json(todo, 201);
    }
  );

Custom Validators

// Validate query parameters
const paginationSchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(10),
  sort: z.enum(["asc", "desc"]).default("desc"),
});
 
.get("/",
  zValidator("query", paginationSchema),
  async (c) => {
    const { page, limit, sort } = c.req.valid("query");
    // Use validated params
  }
);

Error Handling

Global Error Handler

// src/server/core/error-handler.ts
export function errorHandler(error: Error, c: Context) {
  console.error("API Error:", error);
  
  if (error instanceof HTTPException) {
    return c.json(
      { error: error.message },
      error.status
    );
  }
  
  if (error instanceof z.ZodError) {
    return c.json(
      {
        error: "Validation failed",
        details: error.errors,
      },
      400
    );
  }
  
  // Generic error
  return c.json(
    { error: "Internal server error" },
    500
  );
}

Custom Error Classes

export class NotFoundError extends Error {
  constructor(resource: string) {
    super(`${resource} not found`);
    this.name = "NotFoundError";
  }
}
 
export class UnauthorizedError extends Error {
  constructor(message = "Unauthorized") {
    super(message);
    this.name = "UnauthorizedError";
  }
}
 
// Use in handlers
export async function getTodo(c: Context) {
  const id = c.req.param("id");
  const todo = await db.select().from(todos).where(eq(todos.id, id)).get();
  
  if (!todo) {
    throw new NotFoundError("Todo");
  }
  
  return c.json(todo);
}

Testing

Unit Tests

// src/server/modules/todos/todos.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { createApp } from "@/server/core/app-factory";
 
describe("Todos API", () => {
  let app: ReturnType<typeof createApp>;
  
  beforeEach(() => {
    app = createApp();
  });
  
  it("should create a todo", async () => {
    const res = await app.request("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        title: "Test Todo",
        description: "Test Description",
      }),
    });
    
    expect(res.status).toBe(201);
    const todo = await res.json();
    expect(todo.title).toBe("Test Todo");
  });
  
  it("should validate input", async () => {
    const res = await app.request("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    
    expect(res.status).toBe(400);
    const error = await res.json();
    expect(error.error).toBe("Validation failed");
  });
});

Integration Tests

import { testClient } from "@/server/tests/test-helpers";
 
describe("Todos Integration", () => {
  it("should handle full CRUD cycle", async () => {
    // Create
    const createRes = await testClient.post("/api/todos", {
      json: { title: "Integration Test" },
    });
    const todo = await createRes.json();
    
    // Read
    const getRes = await testClient.get(`/api/todos/${todo.id}`);
    expect(getRes.status).toBe(200);
    
    // Update
    const updateRes = await testClient.put(`/api/todos/${todo.id}`, {
      json: { completed: true },
    });
    expect(updateRes.status).toBe(200);
    
    // Delete
    const deleteRes = await testClient.delete(`/api/todos/${todo.id}`);
    expect(deleteRes.status).toBe(204);
  });
});

OpenAPI Documentation

Generate Documentation

npm run gen:openapi

This generates openapi.json with your API documentation.

Serve Documentation

// src/server/routes/docs.ts
import { swaggerUI } from "@hono/swagger-ui";
 
export const docsRouter = new Hono()
  .get("/", swaggerUI({ url: "/api/openapi.json" }))
  .get("/openapi.json", async (c) => {
    const spec = await import("@/generated/openapi.json");
    return c.json(spec);
  });

Access at: http://localhost:8787/docs

Deployment

Environment Variables

# Production secrets
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put DISCORD_CLIENT_ID
wrangler secret put DISCORD_CLIENT_SECRET

Database Migrations

# Generate migration
npm run db:generate
 
# Apply to production
npm run db:push:prod

Deploy to Cloudflare

npm run deploy

Common Patterns

Health Checks

app.get("/health", (c) => {
  return c.json({
    status: "healthy",
    timestamp: new Date().toISOString(),
    version: process.env.VERSION || "1.0.0",
  });
});

Rate Limiting

import { rateLimiter } from "hono-rate-limiter";
 
app.use("/api/*", rateLimiter({
  windowMs: 60 * 1000, // 1 minute
  limit: 100, // 100 requests per minute
  standardHeaders: "draft-6",
  keyGenerator: (c) => {
    return c.req.header("CF-Connecting-IP") || "anonymous";
  },
}));

CORS Configuration

import { cors } from "hono/cors";
 
app.use("*", cors({
  origin: (origin) => {
    const allowed = [
      "http://localhost:5173",
      "https://myapp.com",
    ];
    return allowed.includes(origin) ? origin : allowed[0];
  },
  credentials: true,
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowHeaders: ["Content-Type", "Authorization"],
}));

Next Steps