Skip to content
jccbbb
Go back

How I Structure Fullstack Projects on Cloudflare

A repo can be honest about its constraints, or it can fight them. Most fullstack repos on Cloudflare end up fighting — folder structures inherited from the AWS era, configs that pretend the runtime is Node, deploy pipelines that try to be CI/CD platforms.

This post is the shape I’ve landed on after building a half-dozen production systems on Cloudflare. Not the only shape — the one I keep returning to.

Table of contents

Open Table of contents

The repo

my-app/
├── apps/
│   ├── web/               # Astro site (storefront, marketing, blog)
│   └── admin/             # React or Astro dashboard (auth-gated)
├── workers/
│   ├── api/               # Main Hono API on Workers
│   ├── jobs/              # Cron workers (scheduled tasks)
│   └── webhooks/          # External webhook receivers
├── packages/
│   ├── db/                # Drizzle schema + migrations + queries
│   ├── auth/              # Session, JWT, Turnstile helpers
│   └── shared/            # Zod schemas, types, constants
├── infra/
│   ├── wrangler.toml      # Per-worker config
│   └── migrations/        # D1 migration runner scripts
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.base.json

A few opinions in that layout:

The deploy story

One wrangler.toml per worker. Pages config lives in a wrangler.toml too — Pages Functions get the same treatment.

# workers/api/wrangler.toml
name = "my-app-api"
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-app"
database_id = "..."

[[kv_namespaces]]
binding = "SESSIONS"
id = "..."

[[r2_buckets]]
binding = "UPLOADS"
bucket_name = "my-app-uploads"

[[durable_objects.bindings]]
name = "WORKSPACE_HUB"
class_name = "WorkspaceHub"

Deploy in CI:

# .github/workflows/deploy.yml
- run: pnpm install --frozen-lockfile
- run: pnpm -F api deploy
- run: pnpm -F jobs deploy
- run: pnpm -F web build
- run: npx wrangler pages deploy apps/web/dist --project-name=my-app

Three deploys, no orchestration tool. If one fails, the others can still go. If they all succeed, the system is live. Cloudflare doesn’t gate the deploys on each other — and that’s a feature: you can roll out a Pages change without bouncing the API.

Where features live

This is the part that always trips people. New feature comes in — “let users export their data as JSON” — and someone has to decide which worker owns it. My defaults:

FeatureWhere it lives
Page load, server-rendered HTMLapps/web (Astro + adapter) or apps/admin
Authenticated API call (CRUD)workers/api
Webhook from a third partyworkers/webhooks
Nightly job, cleanup, billingworkers/jobs (cron triggers)
Long-running async (>30s)A Workflow or a Queue consumer in workers/jobs
Per-tenant realtime stateA Durable Object class in workers/api
Static asset, image, PDFR2 + a thin workers/api proxy if private
AI inferenceworkers/api (Workers AI binding)

That table is the rule. The exceptions are usually performance-driven — pulling a hot endpoint into its own worker because it has a different memory profile.

The local dev loop

The local-dev story is the part Cloudflare did most quietly but did really well. wrangler dev runs the actual Workers runtime locally — same V8 isolate, same compatibility flags, same bindings (mocked or proxied to remote).

# Run the API + a local D1 + a local KV
pnpm -F api dev

# Run Astro against the local API
VITE_API_URL=http://localhost:8787 pnpm -F web dev

When a Pages app needs a Worker, you can run both in a single wrangler pages dev — Pages binds the Worker as a service. Local dev is one of the few places where running on Cloudflare is genuinely easier than running on AWS-equivalents.

Three things I always do

  1. Drizzle, not raw SQL. D1 has a great REST API, but query construction in TypeScript is faster to write and faster to refactor than string-building. Migrations stay clean. Type inference lights up in the editor.
  2. Hono, not handler-per-file. Workers can scale to thousands of routes, but they’re nicer to read when grouped. Hono gives me middleware, validation, and a routing tree without bloat.
  3. Zod everywhere. Every API input, every webhook payload, every form submission. Validation at the boundary, types inside.

Three things I never do

  1. Don’t reach for a third-party DB. D1 covers more cases than people give it credit for, and the cross-region latency penalty for hitting Postgres-as-a-service is real.
  2. Don’t sprinkle business logic into Pages Functions. Functions are for routing, redirects, headers. Real work belongs in a Worker.
  3. Don’t deploy from a laptop. Always through CI, even for personal projects. wrangler deploy from your shell is fine for hotfixes, but the long-term tax is real.

The full edge-first argument is in Why I’m Building Cloudflare-Native Systems. The stack choice for the frontend layer of this picture is covered in Astro + Cloudflare.


Share this post:

Previous Post
Astro + Cloudflare: My Preferred Stack for Modern Content Sites
Next Post
My AI-Assisted Development Workflow with Claude Code and ChatGPT