The API server (apps/api/) runs as a Cloudflare Worker and handles all backend logic: authentication, data access, and billing webhooks. It combines two frameworks:
Hono handles the HTTP surface. tRPC handles the typed contract between frontend and backend. They share the same Worker and middleware stack.
The API has two entrypoints – one for production (Cloudflare Workers) and one for local development (Bun):
| File | Runtime | Description |
|---|---|---|
worker.ts |
Cloudflare Workers | Production entrypoint |
dev.ts |
Bun | Local dev server via wrangler platform proxy |
Both follow the same structure:
worker.ts / dev.ts
├── errorHandler, notFoundHandler
├── secureHeaders()
├── requestId()
├── logger()
├── context init (db, dbDirect, auth)
└── mount app.ts
├── GET /api → API info (JSON)
├── GET /health → health check
├── * /api/auth/* → Better Auth handler
└── * /api/trpc/* → tRPC fetch adapter
The top-level worker (worker.ts) sets up global middleware and initializes shared resources, then mounts the core Hono app (lib/app.ts) which defines the actual routes.
// apps/api/worker.ts (simplified)
const worker = new Hono();
worker.onError(errorHandler);
worker.notFound(notFoundHandler);
worker.use(secureHeaders());
worker.use(requestId({ generator: requestIdGenerator }));
worker.use(logger());
// Initialize shared context
worker.use(async (c, next) => {
const db = createDb(c.env.HYPERDRIVE_CACHED);
const dbDirect = createDb(c.env.HYPERDRIVE_DIRECT);
c.set("db", db);
c.set("dbDirect", dbDirect);
c.set("auth", createAuth(db, c.env));
await next();
});
// Mount the core app
worker.route("/", app);
| Path | Method | Handler | Description |
|---|---|---|---|
/ |
GET | Hono | Redirects to /api |
/api |
GET | Hono | API metadata (name, version, endpoints) |
/health |
GET | Hono | Health check – returns { status, timestamp } |
/api/auth/* |
GET, POST | Better Auth | Authentication routes (docs) |
/api/trpc/* |
* | tRPC | Type-safe RPC – all queries and mutations |
The root router merges domain-specific sub-routers:
// apps/api/lib/app.ts
const appRouter = router({
billing: billingRouter,
user: userRouter,
organization: organizationRouter,
});
Each sub-router lives in routers/ and exports a single router instance. See Procedures for details on adding your own.
apps/api/
├── worker.ts # Cloudflare Workers entrypoint
├── dev.ts # Local dev server (Bun)
├── index.ts # Public package exports
├── lib/
│ ├── ai.ts # OpenAI provider factory
│ ├── app.ts # Hono app + tRPC router composition
│ ├── auth.ts # Better Auth configuration
│ ├── context.ts # TRPCContext and AppContext types
│ ├── db.ts # Drizzle ORM database factory
│ ├── email.ts # Resend email utilities
│ ├── env.ts # Environment variable schema (Zod)
│ ├── loaders.ts # DataLoader instances for N+1 prevention
│ ├── middleware.ts # Error handler, 404 handler, request ID
│ ├── plans.ts # Subscription plan limits
│ ├── stripe.ts # Stripe client factory
│ └── trpc.ts # tRPC init, procedures, error formatter
├── routers/
│ ├── billing.ts # Subscription queries
│ ├── billing.test.ts # Billing router tests
│ ├── organization.ts # Organization CRUD
│ └── user.ts # User profile queries
└── wrangler.jsonc # Cloudflare Workers config
The frontend app (apps/app/) uses @trpc/client with TanStack Query integration. The tRPC client is configured in apps/app/lib/trpc.ts:
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
export const api = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});
Use api in components to call procedures with full type safety:
import { useSuspenseQuery } from "@tanstack/react-query";
import { api } from "~/lib/trpc";
function Profile() {
const { data } = useSuspenseQuery(api.user.me.queryOptions());
return <p>{data.name}</p>;
}
See the tRPC + TanStack Query docs for the full client API.