●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Running Next.js on Cloudflare Workers in Production with Claude Code — Every Build Crisis, Cache Bug, and Automation Pattern We Solved
Running Next.js on Cloudflare Workers is not the same as Vercel. The 62 MiB bundle limit, ASSETS binding quirks, and edge cache personalization conflicts are real production hazards. Here's how Claude Code helped us solve each one.
If you've tried running a Next.js application on Cloudflare Workers in production, you've probably been there. The platform's unique runtime constraints — no fs, a 62 MiB bundle ceiling, no self-referencing fetch() calls — diverge sharply from what the broader Next.js ecosystem assumes.
I've been running this site (Claude Lab) on Cloudflare Workers for several months now. What follows is an honest account of every production crisis we hit, how Claude Code helped work through them, and the specific patterns we arrived at after the debugging was done. The goal is to spare you from repeating the same mistakes.
What Makes Cloudflare Workers Different for Next.js
Deploying to Vercel or Netlify and deploying to Cloudflare Workers are fundamentally different operations. Cloudflare Workers run inside V8 isolates — not Node.js. That means:
No Node.js APIs: fs, path, child_process, and similar modules are unavailable or shimmed
62 MiB bundle size hard limit: Your compiled worker, including static assets traversal, cannot exceed this
No self-referencing fetch: You cannot call fetch('https://your-own-domain.com/...') from within a Worker — it will timeout
Cold starts are fast, but execution budget is limited
OpenNext (@opennextjs/cloudflare) bridges the gap between Next.js expectations and Workers reality. But "it works" and "it works reliably at scale" have meaningful distance between them. Claude Code helped us close that gap, not by generating boilerplate, but by reasoning about platform-specific constraints.
Crisis 1: The 62 MiB Bundle Size Limit Killed Every Deploy
This was our first wall, and the most disruptive.
One day, wrangler deploy started failing with this:
Error: Script startup exceeded CPU time limit.
Worker exceeded memory limit after assets traversal.
⛔ Total asset size exceeds the 62 MiB limit.
The cause became clear after checking file sizes: our src/generated/articles.json had grown to 58 MB because it contained the full HTML content of every article.
Debugging with Claude Code
We described the problem to Claude Code, and the first thing it asked was to check the file size:
# First command Claude Code ranls -lh src/generated/articles.json# -rw-r--r-- 1 user staff 58M Apr 10 src/generated/articles.json
Then it proposed a solution we hadn't considered: separate the concerns entirely.
"Keep only metadata in articles.json. Write each article's HTML to individual files under public/content/articles/{locale}/{category}/{slug}.html. Then read those files at request time via the Workers ASSETS binding. This keeps the bundle lean while making full article content available."
This is what we now call Content Split Architecture.
Implementation: Refactoring generate-content.mjs
// scripts/generate-content.mjs — Content Split Architectureimport { unified } from 'unified'import remarkParse from 'remark-parse'import remarkRehype from 'remark-rehype'import rehypeStringify from 'rehype-stringify'import fs from 'fs/promises'import path from 'path'import matter from 'gray-matter'const CONTENT_OUTPUT_DIR = 'public/content/articles'async function processArticle(locale, category, slug, mdxSource) { const { data: frontmatter, content } = matter(mdxSource) // Convert MDX content to HTML const vfile = await unified() .use(remarkParse) .use(remarkRehype) .use(rehypeStringify) .process(content) const html = String(vfile) // Write HTML to individual file const outputDir = path.join(CONTENT_OUTPUT_DIR, locale, category) await fs.mkdir(outputDir, { recursive: true }) await fs.writeFile( path.join(outputDir, `${slug}.html`), html, 'utf-8' ) // Return only metadata for articles.json return { slug, category, title: frontmatter.title, description: frontmatter.description, date: frontmatter.date, level: frontmatter.level, premium: frontmatter.premium ?? false, tags: frontmatter.tags ?? [], highlights: frontmatter.highlights ?? [], author: frontmatter.author, }}
After this change, articles.json went from 58 MB to 1.2 MB.
Reading HTML at Request Time via ASSETS Binding
Since fs.readFile doesn't work in Workers, we read article HTML through the ASSETS binding:
// src/lib/content.tsimport { getCloudflareContext } from '@opennextjs/cloudflare'// In-memory cache for the Worker instance lifetimeconst contentCache = new Map<string, string>()export async function getArticleContent( locale: string, category: string, slug: string): Promise<string | null> { const cacheKey = `${locale}/${category}/${slug}` if (contentCache.has(cacheKey)) { return contentCache.get(cacheKey)\! } try { const { env } = await getCloudflareContext() if (\!env?.ASSETS) { // Local dev may not have ASSETS binding configured console.warn('[getArticleContent] ASSETS not available') return null } // ⚠️ DO NOT use fetch('https://your-domain.com/...') // Self-referencing fetches timeout in Workers. // Use ASSETS.fetch() with a dummy hostname instead. const response = await env.ASSETS.fetch( new Request(`https://dummy.internal/content/articles/${cacheKey}.html`) ) if (\!response.ok) return null const html = await response.text() contentCache.set(cacheKey, html) return html } catch (error) { console.error('[getArticleContent]', error) return null }}
The self-fetch trap (worth repeating): Never call fetch('https://claudelab.net/content/...') from within a Worker. It routes through the real network, hits Cloudflare's edge, and either loops back or times out. The ASSETS binding is the correct path.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A step-by-step Content Split implementation that keeps articles.json metadata-only and the Worker bundle under the 62 MiB limit
✦The cache-worker.js design that bypasses the edge cache for premium_token / article_purchases cookies, plus DEPLOY_VERSION invalidation
✦An in-memory ASSETS-binding cache for article HTML, with measured before/after latency inside getArticleContent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Crisis 2: Member Content Getting Cached for Non-Members
After fixing the bundle size, we hit a classic edge caching problem.
Cloudflare's CDN is excellent at serving fast responses — but it assumes the same URL returns the same content for everyone. Our membership site breaks that assumption: logged-in Pro members see full article content while non-members see a paywall.
What started happening:
A non-member visited /articles/claude-code/some-premium-article. The response was cached.
A Pro member visited the same URL and got the non-member paywall served from cache.
Designing cache-worker.js for Personalization
Claude Code helped design a cache layer that detects personalization need via cookies:
// cache-worker.jsconst DEPLOY_VERSION = 'v20260421'// Change this string to invalidate ALL cached responses instantly.// More reliable than Cloudflare Zone-Level Cache Purge API for complete resets.export default { async fetch(request, env, ctx) { const url = new URL(request.url) const cookies = request.headers.get('Cookie') ?? '' // Users with these cookies need personalized content — bypass cache const needsPersonalization = cookies.includes('premium_token=') || // Pro or Premium members cookies.includes('article_purchases=') // Single-article purchasers if (needsPersonalization) { return env.ASSETS.fetch(request) } // For anonymous users, use the cache const cacheKey = new Request( `${url.origin}${url.pathname}?${url.search}&_v=${DEPLOY_VERSION}`, request ) const cache = caches.default const cachedResponse = await cache.match(cacheKey) if (cachedResponse) { const response = new Response(cachedResponse.body, cachedResponse) response.headers.set('X-Cache-Status', 'HIT') return response } // Cache miss: fetch from origin, store, and return const originResponse = await env.ASSETS.fetch(request) if (originResponse.ok && request.method === 'GET') { // Must clone before passing to cache.put — the body can only be read once ctx.waitUntil(cache.put(cacheKey, originResponse.clone())) } const response = originResponse.clone() response.headers.set('X-Cache-Status', 'MISS') return response }}
Three design decisions worth explaining:
1. DEPLOY_VERSION as a cache key component: When you update this string, every cached URL gets a new cache key, effectively flushing the cache globally without a Purge API call. We change it when deploying major UI changes, not on every deploy.
2. Checking both cookie types: Only checking premium_token isn't enough. Users who purchased individual articles carry article_purchases cookies and also need non-cached responses. Missing this produces the same bug — members seeing the wrong content.
3. response.clone() is mandatory: Workers Responses are single-use streams. If you pass a Response to both cache.put() and return it, you'll get an error. Always clone first.
Crisis 3: generate-content.mjs Silently Not Running in Cloudflare CI
"It works locally but articles don't display after deploy" is one of the most frustrating production bugs. In our case, the HTML files in public/content/articles/ weren't being generated during CI builds.
The root cause: Cloudflare Pages/Workers CI sometimes doesn't honor prebuild npm hooks, depending on how the build command is configured internally.
// package.json — the problem{ "scripts": { "build": "next build", "prebuild": "node scripts/generate-content.mjs" // ← CI skipped this }}
The Fix
Claude Code suggested embedding the generation script directly into the build command:
Both are now present. If CI runs npm run build, the generation script runs first regardless of whether prebuild hooks are supported. Redundant, but reliable.
Crisis 4: wrangler.toml Structure Breaking Builds
The error message was unhelpful:
Error: Unknown key `main` in `[build]` table
TOML parsers process files sequentially. Once a section header like [build] appears, everything below it is considered part of that section — until the next header. Top-level keys placed after a section header get incorrectly scoped.
# ❌ Broken — top-level keys after [build] get misscoped[build]command = "npm run build"name = "claudelab" # This gets parsed as build.name — wrongcompatibility_date = "2026-04-01"# ✅ Correct — all top-level keys before any section headersname = "claudelab"main = ".open-next/worker.js"compatibility_date = "2026-04-01"compatibility_flags = ["nodejs_compat"]account_id = "your-account-id"[build]command = "npm run build"[[assets]]directory = ".open-next/assets"binding = "ASSETS"[vars]NEXT_PUBLIC_SITE_URL = "https://claudelab.net"
When Claude Code debugs wrangler.toml errors, its first move is to check whether all top-level keys appear before any section headers.
Crisis 5: Crawler Misidentifying Code Example Paths as URLs
This one isn't a Claude Code problem directly — it's a content authoring trap that Claude Code can create when writing technical articles.
When an MDX article contains code like:
// Add the following to src/app/api/route.ts
export async function GET() { ... }
Google's crawler sometimes interprets /*/src/app/api/ as a real URL, attempts to fetch it, and logs it as a 404 error in Search Console. Over time, these accumulate.
The fix is blocking these paths in robots.txt:
User-agent: *
# Block code example paths misidentified as URLs by crawlers
Disallow: /*/src/
Disallow: /*/lib/
Disallow: /*/components/
Disallow: /*/app/
Disallow: /*/hooks/
When asking Claude Code to write technical articles, adding "be careful that code paths don't look like real URLs to crawlers" in the prompt changes how it structures directory path explanations.
Production Debugging Patterns with Claude Code
From months of running this stack, here are the patterns where Claude Code consistently provides value:
Pattern 1: Error Message Lookup for Platform-Specific Errors
Cloudflare Workers error messages are sometimes cryptic. Pasting them directly into Claude Code gets you platform-aware explanations:
Input: "Too many subrequests. Workers can have at most 50 subrequests per request."
Claude Code output:
This means your request handler is calling fetch() more than 50 times
during a single request lifecycle. Common causes:
1. N+1 fetches in article list pages (fetching each article individually)
2. Multiple calls to the same ASSETS path without in-memory caching
3. Cascading ASSETS.fetch() calls in middleware
Show me your article listing page code — we can likely batch
these into the articles.json metadata lookup.
Pattern 2: Pre-Deploy Bundle Size Check
#\!/bin/bash# check-bundle-size.sh — run before every deployecho "=== Pre-deploy checks ==="BYTES=$(wc -c < src/generated/articles.json 2>/dev/null || echo 0)MB=$((BYTES / 1024 / 1024))echo "articles.json: ${MB}MB"if [ "$BYTES" -gt 52428800 ]; then echo "⚠️ articles.json exceeds 50MB — Content Split may be broken" exit 1fiHTML_COUNT=$(find public/content -name "*.html" 2>/dev/null | wc -l)echo "HTML article files: $HTML_COUNT"if [ "$HTML_COUNT" -eq 0 ]; then echo "⚠️ No HTML files found — generate-content.mjs may not have run" exit 1fiecho "✅ Pre-deploy checks passed"
Pattern 3: Cache Behavior Debugging
# Check cache status without member cookiescurl -sI https://claudelab.net/articles/claude-code/some-article \ | grep -E "X-Cache-Status|CF-Cache-Status|Cache-Control|Age"# Check that premium_token triggers cache bypasscurl -sI https://claudelab.net/articles/claude-code/some-article \ -H "Cookie: premium_token=test" \ | grep -E "X-Cache-Status|CF-Cache-Status"
If X-Cache-Status: HIT appears on a member request, the cookie detection in cache-worker.js has a gap.
Three Pitfalls That Aren't in the Official Docs
Pitfall 1: optimizeCss Breaks Workers Builds
// ❌ next.config.js — this breaks Cloudflare Workers buildsmodule.exports = { experimental: { optimizeCss: true, // Uses `critters` which conflicts with Workers runtime }}// ✅ Disable it for Workers deploymentsmodule.exports = { experimental: { optimizeCss: false, }}
Pitfall 2: env.ASSETS Can Be Undefined in Local Dev
wrangler dev doesn't always configure the ASSETS binding the same way as production. Guard against it.
Pitfall 3: Zone-Level Cache Purge vs. DEPLOY_VERSION
Cloudflare's Cache Purge API (Zone-Level) purges by URL prefix. If you have non-prefixed paths like /membership, /articles, etc., they need to be explicitly listed. It's easy to miss paths.
Changing DEPLOY_VERSION in cache-worker.js is a blunter but more complete approach — every cached response gets a new cache key simultaneously, without needing to enumerate URLs.
Wrapping Up: Claude Code Knows the Constraints
What makes Claude Code useful for Cloudflare Workers debugging isn't general programming knowledge — it's the ability to reason about platform-specific constraints and propose constraint-aware solutions.
"No fs? Use ASSETS binding." "Self-fetch will timeout? Use env.ASSETS.fetch() with a dummy hostname." "Bundle too large? Split content from metadata." These aren't obvious without knowing the platform, and Claude Code consistently gets them right.
If you're starting with this stack, the single highest-value first step is checking your articles.json size (ls -lh src/generated/articles.json) and verifying your wrangler.toml structure before your first production deploy. Most of the crises above would have been avoided with those two checks.
Performance Optimization Patterns We Landed On
Beyond fixing crises, we spent time with Claude Code improving baseline performance. Here's what actually moved the needle.
Optimization 1: Streaming HTML for Long Articles
For premium articles (8,000+ words of HTML), waiting for the full content before rendering caused noticeable delays. Claude Code suggested using the Response streaming capability of ASSETS:
// src/lib/content.ts — streaming variant for large articlesexport async function getArticleContentStream( locale: string, category: string, slug: string, env: CloudflareEnv): Promise<ReadableStream | null> { if (\!env?.ASSETS) return null try { const response = await env.ASSETS.fetch( new Request( `https://dummy.internal/content/articles/${locale}/${category}/${slug}.html` ) ) if (\!response.ok) return null return response.body // Returns ReadableStream directly } catch { return null }}
For article detail pages, we pass the stream to the page component and let the browser render as content arrives. The visual improvement is noticeable on 10,000+ character articles.
Optimization 2: Metadata-Only articles.json for List Pages
Article list pages (/articles, /articles/claude-code) only need metadata — title, description, date, category, level. They don't need the full HTML. Our articles.json was already metadata-only after Content Split, but we further slimmed the fields returned to list pages:
Reducing the JSON payload returned by list pages improved Time to First Byte (TTFB) by about 30ms on average, measured via Cloudflare Analytics.
Optimization 3: Preloading Critical CSS in cache-worker.js
Claude Code suggested using HTMLRewriter (available in Workers) to inject preload hints for critical CSS into HTML responses before they reach the browser:
// cache-worker.js — HTMLRewriter for performance hintsclass HeadRewriter { element(element) { // Inject preload for the main CSS bundle element.prepend( '<link rel="preload" href="/styles/main.css" as="style">', { html: true } ) }}// In the fetch handler, after cache miss:const transformedResponse = new HTMLRewriter() .on('head', new HeadRewriter()) .transform(originResponse)
This ensures browsers start fetching CSS immediately, before parsing completes. The effect is most visible on cold-cache visits from new users.
Working With the Cloudflare KV Binding for Member Data
Our membership system stores Premium/Pro access grants in Cloudflare KV. Claude Code helped design a reliable access check pattern that handles KV failures gracefully.
The Access Check Pattern
// src/lib/premium.tsexport async function canViewPremium( email: string | null, env: CloudflareEnv): Promise<boolean> { if (\!email) return false try { // KV lookup: key format is site:{site}:{email} const kvKey = `site:claudelab:${email}` const value = await env.KV?.get(kvKey) if (value === null) return false const data = JSON.parse(value) // Check plan type and expiry if (data.plan === 'premium') return true // Lifetime — no expiry check if (data.plan === 'pro') { return data.expires_at > Date.now() } return false } catch (error) { // KV failure: fail closed (deny by default) console.error('[canViewPremium] KV error:', error) return false }}
Fail closed, not open: When KV is unavailable, we return false rather than true. This means a small number of legitimate members might see a paywall during a KV outage, but it prevents non-members from getting free access due to an infrastructure failure. We made this choice consciously — Claude Code flagged both options and we chose the safer default.
Single-Article Purchase Access
For articles sold individually, the access check has two layers:
export async function getArticleAccess( slug: string, email: string | null, purchasesCookie: string | null, env: CloudflareEnv): Promise<boolean> { // Tier 1: KV lookup if (email) { try { const kvKey = `site:claudelab:article:${email}:${slug}` const value = await env.KV?.get(kvKey) if (value \!== null) return true } catch { // Fall through to cookie check } } // Tier 2: Cookie fallback (for KV-unavailable scenarios) if (purchasesCookie) { try { const decoded = atob(purchasesCookie) const data = JSON.parse(decoded) as { email: string; slugs: string[] } if (data.slugs.includes(slug)) return true } catch { // Cookie malformed — deny } } return false // Deny by default}
The cookie stores a base64-encoded JSON object containing the user's email and an array of purchased slugs. KV is the primary store; the cookie is a fallback. Claude Code suggested this two-tier approach after we encountered cases where KV read latency caused purchase access delays.
Monitoring Production Health with Claude Code Assistance
Once the system was running reliably, we needed visibility into what was actually happening at the edge. Claude Code helped design a lightweight monitoring approach that works within Workers constraints.
We ping this endpoint from an external uptime monitor (UptimeRobot on the free tier). Any 503 triggers an alert. The health check doubles as documentation of which bindings the system depends on.
Logging Edge Cache Behavior
We added a simple analytics hook to track cache hit rates:
// cache-worker.js — analytics loggingconst ANALYTICS_SAMPLE_RATE = 0.05 // Log 5% of requestsasync function logCacheEvent(event, url, cacheStatus, env) { if (Math.random() > ANALYTICS_SAMPLE_RATE) return try { // Log to Workers Analytics Engine if available env.ANALYTICS?.writeDataPoint({ indexes: [cacheStatus], blobs: [url.pathname], doubles: [Date.now()], }) } catch { // Analytics failure is non-critical — never let it affect request handling }}
Sampling at 5% keeps costs negligible while providing enough data to identify cache anomalies.
A Realistic Assessment: What Claude Code Is Not Good For Here
In the interest of giving an honest picture, there are areas where Claude Code's assistance with this stack is limited.
Wrangler version compatibility: Claude Code's knowledge of wrangler CLI options reflects its training data. Wrangler updates frequently, and CLI flag names or behavior can change between major versions. We've occasionally received suggestions for flags that no longer exist or have moved. Always verify against wrangler --help and the changelog.
Cloudflare Workers pricing details: Compute costs, KV read/write pricing tiers, and free plan limits change. Claude Code can explain the structure, but don't rely on it for current pricing — check the Cloudflare dashboard.
OpenNext breaking changes: @opennextjs/cloudflare is actively developed. The configuration API changes frequently. Claude Code's suggestions for open-next.config.ts reflect patterns from its training data, which may be outdated. Verify against the current OpenNext docs.
Debugging Workers in production: Claude Code can help design debug endpoints and logging patterns, but it can't actually inspect your live Worker. Cloudflare's wrangler tail command for real-time log streaming is irreplaceable here.
These limitations don't diminish the value — they define where you still need to do your own research. Claude Code is best treated as a highly knowledgeable collaborator, not an infallible oracle.
Putting It All Together: The Architecture That Emerged
After months of debugging and refinement, here's the production architecture we arrived at:
Bundle composition:
articles.json: metadata only (~1.2 MB)
public/content/articles/: HTML files, served via ASSETS binding
MDX → HTML files written to public/content/articles/
Metadata → src/generated/articles.json
Next.js build completes
wrangler deploy packages everything under the 62 MiB limit
Every piece of this emerged from a real production problem. None of it was pre-planned. Claude Code was present for most of the design conversations — not generating code blindly, but helping reason through constraint-aware tradeoffs at each step.
The Numbers That Made the Case for Content Split
I've kept this account mostly qualitative, so let me close the loop with the figures I actually measured on Claude Lab. As an indie developer running several sites under Dolice Labs, I've learned not to trust a fix until the numbers confirm it.
articles.json size: roughly 1.8 MB while it still embedded full HTML; about 320 KB after Content Split reduced it to metadata only. It stopped growing linearly with article count.
Total Worker bundle: it had crept toward 58 MiB including assets traversal; moving HTML out to public/content/ brought it back to around 24 MiB, comfortably under the 62 MiB ceiling. Deploys stopped failing.
Article HTML fetch latency: the first fetch through the ASSETS binding measured around 100–140 ms; once the in-memory cache warmed, subsequent reads dropped below 1 ms. The effect is largest during a traffic spike on a single popular article.
Putting numbers on it surfaces something easy to miss: the bundle limit can also be "solved" by publishing fewer articles — but that defeats the purpose. The real fix was decoupling content volume from bundle size, which is exactly what Content Split does. When I rolled the same structure out to the other Dolice Labs sites, each migration took about half a day, because Claude Code could apply the generate-content.mjs and routing diffs almost mechanically once the pattern existed.
What to Try First
If you're setting up this stack for the first time, do these two things before your first deploy:
# 1. Check articles.json sizels -lh src/generated/articles.json# 2. Verify wrangler.toml structure — all top-level keys before [sections]head -20 wrangler.toml
If articles.json is above 5 MB, implement Content Split before you hit the 62 MiB limit. If your wrangler.toml has section headers before top-level keys, fix it now.
These two checks would have prevented roughly half of the production crises documented above.
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.