This document contains all design decisions and instructions for AI agents working on this project. Follow these guidelines to ensure consistency.
A full-stack web application template for Cloudflare Workers with type-safe RPC, authentication, and modern tooling.
| Layer | Technology | Notes |
|---|---|---|
| Frontend Runtime | Bun | Tooling, dev server, builds |
| Frontend Framework | TanStack Router + React | File-based routing |
| Styling | Tailwind CSS | |
| State Management | TanStack Query | |
| Backend | Hono on Cloudflare Workers | |
| RPC | hono/client | Type-safe API calls |
| Database | Cloudflare D1 OR PlanetScale | User choice at project creation |
| ORM | Drizzle + Drizzle Kit | |
| Validation | Zod | |
| Auth | Better Auth | Google OAuth |
| Session Storage | Cookies | |
| Logging | Cloudflare Workers Logs | |
| AI (optional) | Vercel AI SDK | |
| File Storage | Cloudflare R2 | Signed URLs for private access |
At the start of a new project, always ask the user which database they want to use:
- Cloudflare D1 - SQLite-based, serverless, integrated with Cloudflare
- PlanetScale - MySQL-compatible, uses Hyperdrive for connection pooling
Run bun run setup to configure the project based on this choice.
Always run bun run lint and bun run typecheck after making code changes. Oxlint checks code quality. The type check ensures type safety across the RPC boundary. The hono/client provides end-to-end type safety, but only if TypeScript passes.
Rule: When adding new functionality, check if it exists in another page file. If so, extract to backend/functions/ and update both pages to use it.
Never duplicate logic across page files.
Each frontend page has a corresponding file in backend/pages/ containing all routes used by that page.
Do not use useEffect in frontend code. Read docs/you-might-not-need-an-effect.md and use a direct React or TanStack Query pattern.
If an Effect is required to synchronize with an external system, add a reason and a narrow oxlint-disable-next-line eslint/no-restricted-imports comment to the import. Do not disable the rule for a complete file.
Every change to backend/db/schema.ts must include a generated SQL migration
under backend/drizzle/migrations/.
After each schema change:
- Run
bun run db:generate. - Review and commit the generated SQL and Drizzle metadata.
- Run
bun run db:migrate:localagainst a fresh local D1 database. - Run the affected API or application flow against that migrated database.
Do not use db:push, request-time CREATE TABLE statements, or runtime schema
initialization as a substitute for a committed migration. The canonical
migration directory is backend/drizzle/migrations; do not create
backend/migrations.
/
├── frontend/
│ ├── src/
│ │ ├── routes/ # TanStack Router file-based routes
│ │ │ ├── __root.tsx # Root layout
│ │ │ ├── index.tsx # Landing page (public)
│ │ │ ├── login.tsx # Login page
│ │ │ ├── logout.tsx # Logout page
│ │ │ ├── auth/
│ │ │ │ └── callback.tsx # OAuth callback
│ │ │ └── app/
│ │ │ └── index.tsx # Default signed-in page
│ │ ├── components/
│ │ │ ├── PageLoader.tsx # Full page loading state
│ │ │ └── ErrorBoundary.tsx # Global error boundary
│ │ ├── lib/
│ │ │ ├── api.ts # Hono RPC client
│ │ │ └── query.ts # TanStack Query client
│ │ ├── main.tsx
│ │ └── routeTree.gen.ts # Auto-generated by TanStack Router
│ ├── index.html
│ ├── package.json
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── vite.config.ts
│
├── backend/
│ ├── pages/ # Route handlers organized by frontend page
│ │ ├── index.ts # Landing page routes
│ │ ├── login.ts # Login routes
│ │ ├── logout.ts # Logout routes
│ │ └── app/
│ │ └── index.ts # Signed-in page routes
│ ├── functions/ # Shared business logic
│ │ ├── github-issues.ts # Deduplicated server-error issues
│ │ └── user.ts # Example: user-related functions
│ ├── db/
│ │ ├── schema.ts # Drizzle schema definitions
│ │ └── index.ts # Database client export
│ ├── lib/
│ │ ├── auth.ts # Better Auth configuration
│ │ ├── errors.ts # Error response helpers
│ │ └── r2.ts # R2 signed URL helpers
│ ├── middleware/
│ │ ├── auth.ts # Auth middleware
│ │ ├── csp.ts # CSP headers
│ │ └── logging.ts # Structured Workers Logs middleware
│ ├── drizzle/
│ │ └── migrations/ # Drizzle Kit migrations
│ ├── index.ts # Hono app entry point
│ ├── package.json
│ ├── tsconfig.json
│ └── wrangler.toml
│
├── scripts/
│ └── setup.ts # Initial project setup script
├── package.json # Root workspace scripts
├── env.example # Example environment variables
├── .dev.vars # Local environment variables (gitignored)
├── .gitignore
├── AGENTS.md # This file
└── README.md
Each frontend page has a corresponding file in backend/pages/ containing all routes used by that page.
Naming Convention:
GET /api/pages/{page}/data- Load data for a page (onLoad)POST /api/pages/{page}/{action}- Handle form submissions (onSubmit)
Note: Actions use flat paths (e.g., /update-profile) not nested paths (e.g., /actions/update-profile) for better Hono RPC type inference.
Example: backend/pages/app/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { apiSuccess } from '../lib/errors'
import type { Env, Variables } from '../types'
const app = new Hono<{ Bindings: Env; Variables: Variables }>()
// Load page data (onLoad)
.get('/data', async (c) => {
const user = c.get('user')
return c.json(apiSuccess({ user, items: [] }))
})
// Handle form submission (onSubmit)
.post('/update-profile',
zValidator('json', z.object({
name: z.string().min(1).max(100),
})),
async (c) => {
const body = c.req.valid('json')
return c.json(apiSuccess({ updated: true, name: body.name }))
}
)
export default appShared logic between multiple pages goes in backend/functions/.
Example: backend/functions/user.ts
import { db } from '../db'
import { users } from '../db/schema'
import { eq } from 'drizzle-orm'
export async function getUserById(id: string) {
return db.query.users.findFirst({
where: eq(users.id, id)
})
}
export async function updateUserProfile(id: string, data: { name: string }) {
return db.update(users)
.set(data)
.where(eq(users.id, id))
.returning()
}All API responses must follow this standardized format:
// Success response
type ApiSuccess<T> = {
success: true
data: T
}
// Error response
type ApiError = {
success: false
error: {
code: string // e.g., "VALIDATION_ERROR", "NOT_FOUND", "UNAUTHORIZED"
message: string // Human-readable message
fieldErrors?: Record<string, string> // Per-field validation errors
formErrors?: string[] // Form-level errors
}
}Helper functions in backend/lib/errors.ts:
export function apiSuccess<T>(data: T): ApiSuccess<T> {
return { success: true, data }
}
export function apiError(
code: string,
message: string,
fieldErrors?: Record<string, string>,
formErrors?: string[]
): ApiError {
return {
success: false,
error: { code, message, fieldErrors, formErrors }
}
}Error Codes:
VALIDATION_ERROR- Input validation failedNOT_FOUND- Resource not foundUNAUTHORIZED- Authentication requiredFORBIDDEN- Insufficient permissionsINTERNAL_ERROR- Server error
- Uses Google OAuth for authentication
- Sessions stored in cookies
- Cookie caching enabled (5 minutes)
Use requireAuth middleware for protected routes:
import { requireAuth } from '../middleware/auth'
const app = new Hono()
.use('*', requireAuth)
.get('/data', async (c) => {
const user = c.get('user')
// user is guaranteed to exist
})Applied via middleware:
Content-Security-Policy: Restrictive default policyX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin
- All input validated with Zod at API boundary via
@hono/zod-validator - Drizzle provides SQL injection protection via parameterized queries
Handled automatically by Better Auth using the double-submit cookie pattern.
Use signed URLs for all file operations. The bucket should be private by default.
import { getDownloadUrl, getUploadUrl } from '../lib/r2'
// Get a signed URL for downloading
const downloadUrl = await getDownloadUrl(env, 'files/document.pdf')
// Get a signed URL for uploading
const uploadUrl = await getUploadUrl(env, 'files/new-document.pdf')// backend/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().defaultNow(),
})// backend/db/schema.ts
import { mysqlTable, varchar, int, timestamp } from 'drizzle-orm/mysql-core'
export const users = mysqlTable('users', {
id: varchar('id', { length: 255 }).primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
})# Generate migration from schema changes
bun run db:generate
# Push to database (development)
bun run db:push
# Apply committed migrations to local D1
bun run db:migrate:local
# Apply migrations (production)
bun run db:migrateAll requests emit structured events to Cloudflare Workers Logs. Use console.log,
console.warn, or console.error with structured objects for additional events.
When all three GITHUB_ISSUES_* environment variables are configured, server
errors create GitHub issues and mention the configured user. The reporter uses
a stable fingerprint and does not create a duplicate while a matching issue is
open.
| Command | Description |
|---|---|
bun run dev |
Start both frontend and backend in development |
bun run dev:frontend |
Start only frontend dev server |
bun run dev:backend |
Start only backend (wrangler dev) |
bun run build |
Build both frontend and backend |
bun run lint |
Run Oxlint |
bun run typecheck |
Run TypeScript type checking |
bun run setup |
Run initial project setup |
bun run db:generate |
Generate Drizzle migrations |
bun run db:push |
Push schema to database |
bun run db:migrate:local |
Apply committed migrations to local D1 |
bun run db:migrate |
Run migrations |
| Page | Route | Description |
|---|---|---|
| Landing | / |
Public marketing/landing page |
| Login | /login |
Google OAuth login button |
| Logout | /logout |
Logout confirmation and action |
| Auth Callback | /auth/callback |
OAuth callback handler |
| App Dashboard | /app |
Default signed-in page (protected) |
Copy env.example to .dev.vars for local development. For production, use Cloudflare secrets:
wrangler secret put DATABASE_URL
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put BETTER_AUTH_URL # Your production URL, e.g., https://your-app.workers.dev
wrangler secret put GITHUB_ISSUES_TOKEN
wrangler secret put GITHUB_ISSUES_REPOSITORY
wrangler secret put GITHUB_ISSUES_NOTIFY_USER
wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEYWhen working on this project:
- Confirm database choice (D1 or PlanetScale) if not already configured
- Follow the pages pattern for backend routes
- Use the standardized API response format
- Run
bun run lintandbun run typecheckafter code changes - Extract shared logic to
backend/functions/ - Use Zod for all input validation
- Use signed URLs for R2 file access
- Use
requireAuthmiddleware for protected routes - Do not use frontend
useEffectwithout a documented lint exception