◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-04-21Advanced

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.

Claude Code264Next.js8Cloudflare Workers16production111troubleshooting92performance optimization

✦ Premium Article

"The deploy is failing. I have no idea why."

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 ran
ls -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 Architecture
 
import { 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.ts
 
import { getCloudflareContext } from '@opennextjs/cloudflare'
 
// In-memory cache for the Worker instance lifetime
const 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.

or
Unlock all articles with Membership →
Share

Thank You for Reading

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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-08-19
Fixing the Code Doesn't Evict a Broken Page From the Edge Cache
I shipped a fix and the broken page kept serving. The problem was not the code but the edge cache, which had stored a broken HTML response because the store decision looked only at the status code. Here is the integrity guard I now run before every cache write, and the thresholds I had to tune in production.
⟐ Claude Code2026-10-12
After Moving to SPM, My App Died the Moment It Launched — Reading One dyld Line with Claude Code
Right after moving from CocoaPods to Swift Package Manager, the build passed but the app died on launch. Here is how I traced one dyld line to its cause, the fix, and how to verify it.
⟐ Claude Code2026-10-05
I Wrote "I'll Do the Commits" in CLAUDE.md. The Next Morning git log Had One More Line.
When Claude Code commits despite being told not to: undo with git reset --soft, then stop it physically with permissions.deny plus a PreToolUse hook. Covers the git -c form that deny rules let through, and how to test the hook without starting Claude Code.