Next.js 16 on Cloudflare Workers: what broke and what didn't

작성자

카테고리:

← 피드로
DEV Community · neo xia · 2026-07-22 개발(SW)

I shipped a Next.js 16 app on Cloudflare Workers via OpenNext. Not a demo. A real product with streaming chat, server components, D1 at the edge, and anonymous user sessions.

Here is what broke, what barely worked, and what turned out to be surprisingly fine.

The stack

  • Next.js 16.2 (App Router)
  • @opennextjs/cloudflare 1.19
  • D1 for SQLite at the edge
  • Streaming chat via the AI binding (DeepSeek-V3 through a Workers proxy)
  • React 19
  • Tailwind CSS 4
  • No auth wall, no OAuth, no database on the origin

The site runs a few thousand sessions a week across ~30 persona pages, blog posts, guides, and learning content. Most pages are statically generated. The chat interaction is server-rendered components with streaming responses.

What worked surprisingly well

Static generation and ISR

Pages, blogs, guides, persona pages — everything that does not need user-specific rendering — runs as static HTML at deploy time.

Next.js 16 with generateStaticParams and fetch caching worked without modification. OpenNext handles the Cloudflare output format. The build step produces something Workers can serve. Revalidations are limited to Workers’ cache API, but since most content changes at deploy time, I never hit that limit in production.

The one caveat: revalidateTag() does not work the same way in a Workers runtime. Tags are Node.js memory constructs, and Workers are stateless. If you depend on tag-based revalidation for content updates, you need to either trigger deploys or accept stale-while-revalidate behavior from the CDN.

D1 at the edge

D1 was the least surprising part of the stack. SQL queries from Next.js route handlers feel like calling a regular database. Sessions store in D1, messages store in D1, and the latency is low enough that restoring a full chat thread from 30 messages takes under 200ms cold.

The only sharp edge: D1 connections count against your Worker’s concurrent request limit in development. With Next.js making its own fetch calls for compilation, I hit the D1 connection ceiling faster than expected. The fix was moving wrangler dev to use --experimental-json-config early, but the dev experience for D1 + Next.js hot reload is still rougher than it should be.

Streaming chat responses

The app streams model responses token by token. In dev, this was unreliable — the stream would drop mid-response on roughly 1 in 15 requests. I spent two days tracing it before realizing it was a local wrangler dev issue with HTTP chunked transfer encoding, not a production problem.

In production, streaming over Workers works. The AI binding handles the model request inside the Worker, and the response streams back through the Next.js route handler to the client without a hitch.

Anonymous user sessions

The product ships without required sign-in. Users get a UUID from crypto.randomUUID() stored in localStorage, sent as X-User-Id on every chat request. On the server, D1 writes sessions against that ID.

Workers not having durable session state actually helps here. The identity arrives in headers. No sticky sessions. No session table. The Worker pulls the user ID, checks D1 for existing sessions or quota limits, and proceeds.

What broke

Middleware and edge runtime mismatch

Next.js 16 middleware runs on every request. On Cloudflare Workers, middleware executes in the edge runtime, not the Node.js runtime. This means any middleware that imports crypto or uses APIs outside the Workers subset will fail at request time, not at build time.

I had a middleware that checked rate limits using a counter in D1. Simple enough. But the D1 binding is not available in the middleware context through OpenNext the way it is in route handlers. I had to move rate limiting to a Cloudflare WAF rule instead of doing it in Next.js middleware. Not a dealbreaker, but the middleware → Workers gap is larger than the documentation suggests.

next/image and Workers

Static images work fine. Dynamic image optimization through next/image does not, because Workers lack the image processing libraries that Node.js uses.

The fix: I pre-processed all images at build time and served them as static assets. No runtime optimization needed. For an app with fewer than 50 unique images, this was trivial. For an app with user-uploaded content, it would be a hard problem.

Environment variables in client components

Environment variables prefixed with NEXT_PUBLIC_ are baked into the client bundle at build time. That works fine. But runtime environment variables accessed through process.env in server components behave differently in Workers.

Cloudflare Workers use a env binding, not process.env. OpenNext bridges this, but the bridge is not seamless. Variables I set in wrangler.toml were available in the Worker context but not through the process.env API that Next.js server components expect at runtime. The workaround was importing the Cloudflare bindings through the @opennextjs/cloudflare types and passing them explicitly.

The first-deploy cold start

First deploy after building with OpenNext takes roughly 45-60 seconds for the first Worker request. After the initial cold start, response times drop to normal.

This is a known Workers characteristic compounded by Next.js route chunking. The more routes your app has, the more chunks the Worker needs to load on the first request. My app has about 30 routes. A smaller app with 5 routes would cold-start faster.

Subsequent deploys are faster because Cloudflare caches the compilation output. But the first deploy of the day always has a cold start window.

Streaming + D1 in the same route handler

This one was subtle. A route handler that reads from D1 and then streams a response — for example, loading session history and then starting the chat stream — sometimes lost the D1 response before the stream completed.

The issue: Workers terminate the request context after the response is sent. If you read from D1 inside the same handler that starts a streaming response, the D1 bindings can close before the stream finishes if the stream outlasts the initial response resolution.

The fix: resolve all D1 reads before starting the stream. Load session data early, store it in a closure, then begin streaming.

// Before: D1 read interleaved with streaming
export async function POST(req: Request) {
  const session = await db.prepare("SELECT * FROM sessions WHERE id = ?").bind(sessionId).first();
  const stream = await ai.beta.chat.completions.create({ model, messages, stream: true });
  return new Response(streamToReadableStream(stream));
}

// After: resolve D1 reads first, then stream
export async function POST(req: Request) {
  const session = await db.prepare("SELECT * FROM sessions WHERE id = ?").bind(sessionId).first();
  const messagesWithHistory = [...(session.messages || []), ...newMessages];
  const stream = await ai.beta.chat.completions.create({ model, messages: messagesWithHistory, stream: true });
  return new Response(streamToReadableStream(stream));
}

Enter fullscreen mode Exit fullscreen mode

Straightforward fix once I recognized the pattern. Cost me two evenings of debugging before I found it.

What I am still watching

  • ISR revalidation at scale. I have not reached the threshold where Workers cache API limits matter. If the app grows to hundreds of content pages with frequent revalidations, I may need to revisit the caching strategy.

  • D1 row limits. A few thousand sessions with 10-15 messages each is well under D1’s limits. If the app crosses 100k+ sessions, I will need either a message archiving strategy or a move to R2 for stored messages.

  • OpenNext churn. The OpenNext → Cloudflare pipeline changes version to version. Two minor bumps have already required wrangler.toml changes. The stack is stable enough for production but not mature enough to set and forget.

The honest take

Next.js 16 on Cloudflare Workers works for a real production app. The static generation path is smooth. D1 is solid. Streaming is fine after you learn the D1-resolution-before-stream rule.

The rough edges are in the gaps between ecosystems: middleware runtimes, environment variable access patterns, and image optimization. Each gap has a workaround, but the workarounds are not always documented.

The OpenNext team has done the heavy lift of bridging Next.js to Workers. The remaining friction is mostly about accepting that you are on a serverless platform with different constraints than Vercel. Once you internalize those constraints — resolve D1 first, pre-process images, avoid Node.js APIs in middleware — the stack holds.

For a production app with streaming chat, anonymous sessions, and static content: cosskill.com. The stack file is pinned on the repo if you want the full picture.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다