← All posts
·12 min read

Claude Code with Cloudflare Workers: Edge Development

Claude CodeCloudflare WorkersWorkflowServerless
Claude Code with Cloudflare Workers: Edge Development

Why Cloudflare Workers needs different Claude Code configuration

Cloudflare Workers runs JavaScript on V8 isolates at the edge, not Node.js. This distinction matters more than it sounds. There is no process, no fs, no Buffer, no require, no __dirname, and no access to the Node.js standard library. Workers has its own runtime APIs: the Web Crypto API instead of crypto, fetch instead of axios, the Cloudflare-specific binding system for storage, and strict CPU time limits instead of long-running async operations.

Claude Code knows JavaScript and TypeScript thoroughly. It knows the Workers platform well enough to generate handler boilerplate, KV operations, and basic routing. What it does not know without configuration is your project: which bindings you have declared in wrangler.toml, which storage layers you are using (KV, R2, D1, Durable Objects), how your routing is structured (raw fetch handler, itty-router, Hono), and which Workers-specific APIs replace the Node.js equivalents you might expect.

The result without CLAUDE.md configuration is Workers code that looks plausible but includes Node.js APIs, missing bindings, incorrect wrangler.toml syntax, and async patterns that exceed the CPU time budget. One well-structured CLAUDE.md eliminates every category of these errors.

For Workers built with a framework like Next.js deployed to Cloudflare Pages, the Claude Code Next.js guide covers the framework layer. This guide focuses on the Workers runtime itself.

The Cloudflare Workers CLAUDE.md

The root CLAUDE.md for a Workers project needs to declare: the runtime environment, which bindings are configured, the routing approach, the TypeScript setup with Workers types, the Wrangler commands, and the hard rules about Node.js APIs.

# Cloudflare Workers project rules

## Runtime
- Cloudflare Workers (V8 isolate, NOT Node.js)
- Wrangler CLI: 3.x
- TypeScript: strict mode
- Workers types: @cloudflare/workers-types (in tsconfig compilerOptions.types)
- Language target: ES2022

## Project structure
src/
  index.ts          # Worker entry point, exports default handler
  handlers/         # Route handlers (one file per route group)
  lib/              # Shared utilities, API clients, validation
  types/            # TypeScript interfaces, Env type
wrangler.toml       # Wrangler config, bindings declared here
.dev.vars           # Local secrets (not committed, gitignored)

## Bindings (declared in wrangler.toml, typed in types/env.d.ts)
- CACHE: KV namespace (session caching, short-lived data)
- CONTENT: KV namespace (static content, config)
- STORAGE: R2 bucket (user uploads, generated assets)
- DB: D1 database (relational data)
- RATE_LIMITER: Rate limiting (Cloudflare Rate Limiting binding)

## Env type (types/env.d.ts)
export interface Env {
  CACHE: KVNamespace;
  CONTENT: KVNamespace;
  STORAGE: R2Bucket;
  DB: D1Database;
  RATE_LIMITER: RateLimiter;
  // Secrets (from .dev.vars locally, Worker secrets in production)
  API_KEY: string;
  JWT_SECRET: string;
}

## Routing
- Framework: Hono v4 (lightweight, Workers-native, typed routing)
- Pattern: single Hono app instance exported as the Worker default
- Middleware: Hono built-ins (cors, bearerAuth, validator)
- No Express, no Fastify, no Koa

## Running locally
- Dev server: `wrangler dev`
- Dev server with local D1: `wrangler dev --local`
- Tail logs: `wrangler tail`
- Never use `node src/index.ts`, Workers code does not run in Node

## Tests
- Framework: Vitest with @cloudflare/vitest-pool-workers
- Run: `npx vitest`
- Workers-specific test pool runs code in a real V8 isolate (not jsdom or Node)
- Miniflare v3 powers local testing

## Hard rules (V8 isolate constraints)
- NEVER import from 'node:*' or 'fs', 'path', 'crypto', 'buffer', 'stream'
- Use Web Crypto API for all cryptography: crypto.subtle, crypto.randomUUID()
- Use fetch() for all HTTP. Never axios, got, or node-fetch.
- CPU time limit is 10ms (free) or 30ms (Paid) per request. Avoid heavy loops.
- No filesystem access. All storage goes through bindings (KV, R2, D1).
- No top-level await in the module body, use the handler function.
- Secrets come from env bindings, never process.env.

Three rules do the most work here.

The Node.js hard rules block the most common category of wrong output. Claude will default to familiar Node.js imports when generating utility functions. crypto.randomBytes(), path.join(), Buffer.from() and require() calls all appear in Claude outputs without explicit prohibition. The hard rules mean these never appear.

The Env type declaration is the second critical section. Every binding your Worker uses must be declared in wrangler.toml and typed in the Env interface. When Claude sees the complete Env type in CLAUDE.md, it generates handler functions that correctly destructure bindings from the env parameter rather than trying to import them.

The Hono framework declaration prevents Express-style routing from appearing. Claude knows Express deeply and will generate Express patterns by default. Declaring Hono explicitly means route handlers use Hono's c.req, c.env, c.json(), and c.text() APIs throughout.

The wrangler.toml configuration

Claude Code will read and modify your wrangler.toml when asked to add a new binding or update configuration. Teach it the format by including the key sections in CLAUDE.md:

## wrangler.toml structure

name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[vars]
ENVIRONMENT = "production"

[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"

[[kv_namespaces]]
binding = "CONTENT"
id = "your-content-kv-id"
preview_id = "your-preview-content-kv-id"

[[r2_buckets]]
binding = "STORAGE"
bucket_name = "my-storage-bucket"
preview_bucket_name = "my-storage-bucket-preview"

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-d1-database-id"

[dev]
port = 8787
local_protocol = "http"

### Rules for wrangler.toml edits
- NEVER remove existing bindings when adding new ones
- NEVER change compatibility_date without explicit instruction
- Always add both `id` and `preview_id` for KV namespaces
- Always add both `bucket_name` and `preview_bucket_name` for R2
- Secrets go in .dev.vars locally and `wrangler secret put` in production

The "never remove existing bindings" rule prevents a specific failure: when Claude adds a new binding to wrangler.toml, it sometimes regenerates the full file and omits bindings it was not explicitly asked to touch. The rule keeps it to surgical additions.

KV, R2, and D1 patterns

The three main storage layers in Workers each have distinct patterns. Claude generates correct usage when these are explicit in CLAUDE.md.

## Storage patterns

### KV (KVNamespace, eventually consistent, fast reads)
// Read with fallback:
const value = await env.CACHE.get('key');
const withType = await env.CACHE.get('key', { type: 'json' }) as MyType | null;

// Write with TTL:
await env.CACHE.put('key', JSON.stringify(data), { expirationTtl: 3600 });

// List with prefix:
const list = await env.CACHE.list({ prefix: 'user:', limit: 100 });

// Delete:
await env.CACHE.delete('key');

### R2 (R2Bucket, S3-compatible object storage, no egress fees)
// Upload:
await env.STORAGE.put('path/to/file.pdf', file, {
  httpMetadata: { contentType: 'application/pdf' },
  customMetadata: { userId: '123', uploadedAt: new Date().toISOString() },
});

// Download with null check:
const object = await env.STORAGE.get('path/to/file.pdf');
if (!object) return c.notFound();
return new Response(object.body, {
  headers: { 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream' },
});

// List:
const listed = await env.STORAGE.list({ prefix: 'uploads/', limit: 50 });

// Delete:
await env.STORAGE.delete('path/to/file.pdf');

### D1 (D1Database, SQLite at the edge, consistent reads)
// Query with prepare + bind:
const result = await env.DB.prepare(
  'SELECT * FROM users WHERE id = ?'
).bind(userId).first<User>();

// Insert:
const stmt = env.DB.prepare(
  'INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)'
);
await stmt.bind(id, email, new Date().toISOString()).run();

// Batch:
const batch = await env.DB.batch([
  env.DB.prepare('UPDATE users SET last_seen = ? WHERE id = ?').bind(now, userId),
  env.DB.prepare('INSERT INTO events (user_id, type) VALUES (?, ?)').bind(userId, 'login'),
]);

// D1 uses SQLite syntax. No RETURNING clause on older compat dates.

The .bind() pattern for D1 is important to make explicit. Claude Code knows the D1 API but will sometimes generate direct string interpolation in SQL queries rather than parameterized statements. The explicit .prepare().bind().run() pattern in CLAUDE.md means parameterized queries are generated consistently.

The null check pattern for R2 (if (!object) return c.notFound()) prevents runtime errors. R2 .get() returns null when the object does not exist, and attempting to access .body on null throws. Claude includes this check when the pattern is established.

Hono routing patterns

Hono is the standard routing layer for Workers in 2026. Its API is purpose-built for edge environments: it runs in V8 isolates, has zero dependencies, and is typed for the Workers Env interface.

## Hono routing patterns (src/index.ts)

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { bearerAuth } from 'hono/bearer-auth';

const app = new Hono<{ Bindings: Env }>();

// CORS middleware:
app.use('*', cors({ origin: ['https://yourdomain.com'] }));

// Bearer auth on protected routes:
app.use('/api/*', bearerAuth({ token: (c) => c.env.API_KEY }));

// Route with typed response:
app.get('/api/users/:id', async (c) => {
  const id = c.req.param('id');
  const user = await c.env.DB.prepare('SELECT * FROM users WHERE id = ?')
    .bind(id)
    .first<User>();
  if (!user) return c.notFound();
  return c.json(user);
});

// File upload to R2:
app.post('/api/upload', async (c) => {
  const formData = await c.req.formData();
  const file = formData.get('file') as File;
  const key = `uploads/${crypto.randomUUID()}-${file.name}`;
  await c.env.STORAGE.put(key, file.stream(), {
    httpMetadata: { contentType: file.type },
  });
  return c.json({ key });
});

// Export the app as the Worker default handler:
export default app;

### Route file pattern (handlers/users.ts)
import { Hono } from 'hono';
const users = new Hono<{ Bindings: Env }>();
// ... routes
export default users;

// Register in index.ts:
app.route('/api/users', users);

The <{ Bindings: Env }> generic is what makes the c.env object typed. Without it declared in CLAUDE.md, Claude generates app instances without the generic and you lose type inference on bindings. One pattern in CLAUDE.md makes every generated route correctly typed.

Durable Objects for stateful coordination

Durable Objects are the Workers answer to coordination problems: rate limiting, WebSocket sessions, distributed locks, and real-time state. They require different Claude Code guidance than stateless Workers because they have their own class-based API and storage layer.

## Durable Objects (when stateful coordination is needed)

### Class structure:
import { DurableObject } from 'cloudflare:workers';

export class RateLimiter extends DurableObject {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const key = url.searchParams.get('key') ?? 'default';

    const stored = await this.ctx.storage.get<number>(key) ?? 0;
    const now = Date.now();

    if (stored && now - stored < 1000) {
      return Response.json({ allowed: false }, { status: 429 });
    }

    await this.ctx.storage.put(key, now);
    return Response.json({ allowed: true });
  }
}

### wrangler.toml declaration for Durable Objects:
[[durable_objects.bindings]]
name = "RATE_LIMITER"
class_name = "RateLimiter"

[[migrations]]
tag = "v1"
new_classes = ["RateLimiter"]

### Calling a Durable Object from a Worker handler:
const id = env.RATE_LIMITER.idFromName(`user:${userId}`);
const stub = env.RATE_LIMITER.get(id);
const response = await stub.fetch(new Request('http://internal/check?key=' + userId));

The Durable Object class declaration pattern is specific to Workers. The DurableObject base class import from cloudflare:workers, the this.ctx.storage API, and the idFromName stub pattern are all Workers-specific. Including an example in CLAUDE.md means Claude generates syntactically correct Durable Object code rather than guessing at the API.

Claude Code permission hooks for Workers

Wrangler commands range from safe (local dev, log tailing) to significant (deploy, secret management, database migration). Gate the significant ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(wrangler dev*)",
      "Bash(wrangler tail*)",
      "Bash(wrangler types*)",
      "Bash(wrangler d1 execute --local*)",
      "Bash(wrangler kv key list*)",
      "Bash(wrangler r2 object list*)",
      "Bash(npx vitest*)"
    ],
    "deny": [
      "Bash(wrangler deploy*)",
      "Bash(wrangler publish*)",
      "Bash(wrangler secret put*)",
      "Bash(wrangler d1 execute*)",
      "Bash(wrangler kv key put*)",
      "Bash(wrangler kv key delete*)",
      "Bash(wrangler r2 object put*)",
      "Bash(wrangler r2 object delete*)"
    ]
  }
}

The distinction here is between read operations and state-changing operations. Claude can list KV keys, tail logs, run the dev server, generate types, and run tests. It cannot deploy, write or delete storage data in production, or manage secrets. The wrangler d1 execute --local allowance lets Claude run migrations against the local D1 database while blocking the same command against production.

The Claude Code deployment guide covers the broader deployment permission strategy that this Workers setup follows.

Workers-specific TypeScript configuration

The Workers TypeScript setup is different from Node.js or browser TypeScript in one key way: the @cloudflare/workers-types package provides ambient types for all Workers APIs. Without it in tsconfig.json, TypeScript does not know about KVNamespace, R2Bucket, DurableObject, or the fetch event types.

Add to CLAUDE.md:

## TypeScript setup

### tsconfig.json (key sections)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["@cloudflare/workers-types"],
    "lib": ["ES2022"]
  }
}

### What @cloudflare/workers-types provides (Claude should use, not re-declare):
- KVNamespace, KVNamespaceListResult
- R2Bucket, R2Object, R2ObjectBody
- D1Database, D1PreparedStatement, D1Result
- DurableObjectNamespace, DurableObjectId, DurableObjectStub
- ExecutionContext (ctx.waitUntil, ctx.passThroughOnException)
- Fetcher (for service bindings)

### Do NOT import these from any package, they are globally available via workers-types
### Do NOT add "DOM" to lib, it conflicts with Workers globals

The "do not add DOM to lib" rule prevents a specific conflict. When Claude generates a Workers project and sees TypeScript configuration, it will sometimes add "DOM" to the lib array because most TypeScript projects need browser types. In Workers, DOM types conflict with Workers-specific globals. The explicit instruction prevents this.

Workers AI binding

Workers AI is the Workers-native inference layer. Claude Code can generate Workers AI requests when the binding pattern is in CLAUDE.md:

## Workers AI (if AI binding is declared in wrangler.toml)

### wrangler.toml:
[ai]
binding = "AI"

### Env type addition:
AI: Ai;

### Inference call pattern:
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: userMessage },
  ],
  stream: false,
});

// For streaming:
const stream = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
  messages: [...],
  stream: true,
});
return new Response(stream, {
  headers: { 'Content-Type': 'text/event-stream' },
});

### Available models (declare any you use):
- @cf/meta/llama-3.1-8b-instruct (text generation)
- @cf/baai/bge-base-en-v1.5 (embeddings)
- @cf/openai/whisper (audio transcription)
- @cf/stabilityai/stable-diffusion-xl-base-1.0 (image generation)

With the Workers AI binding pattern in CLAUDE.md, Claude generates inference calls that use the env.AI.run() API instead of calling an external AI API over fetch. The streaming pattern is particularly important to document, as the text/event-stream response format is specific to Workers AI.

What Workers developers get wrong with Claude Code

Three mistakes come up consistently when developers start using Claude Code for Workers projects.

Not declaring the compatibility_flags setting. The nodejs_compat flag enables a subset of Node.js APIs in Workers, including process.env, Buffer, and some stream APIs. Whether you are using this flag or not changes what Claude should generate. If nodejs_compat is enabled, some Node.js imports are valid. If it is not, none are. Declaring this in CLAUDE.md prevents the wrong assumption.

Letting Claude use async/await on cold path operations without CPU time awareness. Workers on the free plan have a 10ms CPU time limit. Claude will generate correct async/await patterns that work but may involve expensive operations (complex regex, large JSON parsing, heavy cryptography) that burn through the CPU budget. The CLAUDE.md rule "CPU time limit is 10ms free / 30ms Paid" prompts Claude to keep handler logic minimal and push heavy work to a queue or Durable Object.

Missing the waitUntil pattern for background work. ctx.waitUntil() extends the Worker's lifetime to complete background work after the response is returned. Claude knows this API but will sometimes write fire-and-forget code that does not use it, which means background operations get cut off when the response is sent. A note in CLAUDE.md ("background work after response must use ctx.waitUntil(promise)") ensures the pattern appears.

Getting more from your Workers workflow

The configuration in this guide gives Claude Code what it needs to generate Workers code that respects the V8 isolate constraints, uses the binding system correctly, follows Hono routing conventions, and applies the right storage layer for each use case.

The Claude Code environment variables guide covers how environment variables and secrets work in deployed applications, which extends directly to the Workers .dev.vars and wrangler secret put pattern. For TypeScript configuration that applies across all Workers projects, the Claude Code TypeScript guide covers the strict mode and module resolution settings that underpin the Workers tsconfig setup.

The fundamental difference between Workers and other serverless platforms is the V8 isolate model. Sub-millisecond cold starts, 330 edge locations, and no egress fees make Workers compelling for the right use cases. Getting Claude Code configured for the Workers runtime means that advantage is accessible without the friction of Claude generating Node.js code that does not run at the edge.

Start with the Env type and V8 hard rules in CLAUDE.md, since those affect every file Claude touches. Add the storage patterns and Hono routing once the baseline is working. If you want Claude Code handling your full Workers development cycle, Cloudflare has a Workers-specific CLAUDE.md template available as part of the Claude Code workflow kit, covering Hono, KV/R2/D1 patterns, Durable Objects, and Workers AI.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir