DEV Community

Mashi Mashi
Mashi Mashi

Posted on

Shipping a Next.js App on Cloudflare Workers with OpenNext + D1

I recently shipped souzoku-baton.jp, a Next.js app, on Cloudflare Workers using OpenNext instead of Vercel. The motivation was mostly cost and consolidation — I already run a handful of other properties on Cloudflare and didn't want a second billing relationship and a second set of DNS quirks just to host one more app. OpenNext's Cloudflare adapter has matured a lot, but there were three areas where the defaults quietly did the wrong thing, and I want to write them down before I forget the exact symptoms.

Why OpenNext instead of just Pages

Cloudflare Pages can serve a Next.js static export directly, but the moment you need server-side rendering, API routes with real backend logic, or middleware that does more than redirect based on a cookie, a plain static export stops being enough. OpenNext (@opennextjs/cloudflare) compiles a standard Next.js app into a Cloudflare Worker, mapping the Next.js server runtime onto Workers' request/response model and giving you access to bindings — D1, KV, R2, Durable Objects — directly inside your Next.js server code. That binding access was the actual reason I picked it: the app needed a real relational store for inheritance/estate-planning records, and D1 was the natural fit given everything else was already on Cloudflare.

The setup itself is close to the documented quick start: create-cloudflare scaffolds the project, wrangler.jsonc declares the D1 binding, and open-next.config.ts controls how the adapter builds the output. Where things got interesting was after the first deploy actually worked.

Problem 1: the D1 migrations ledger silently diverges from reality

D1 tracks applied migrations in a d1_migrations table that it manages itself, and wrangler d1 migrations apply is supposed to be the only thing that touches it. Early on, while iterating quickly, I ran a couple of schema changes with wrangler d1 execute --file directly against the remote database — faster to type than generating a proper migration file when I was just testing a column rename. That was the mistake. d1_migrations only gets a row inserted when you go through migrations apply, so after those direct executes, the ledger no longer matched the actual schema: the table structure had my changes, but the migrations system had no record of them.

The failure mode was confusing rather than loud. wrangler d1 migrations list reported every migration as unapplied, including ones whose effects were clearly already live in production. Running apply at that point would have tried to re-run migrations whose CREATE TABLE and ALTER TABLE statements would immediately fail against a schema that already had those columns. The fix was to manually reconcile the ledger: for each migration file whose changes were already present in production, I ran an INSERT OR IGNORE INTO d1_migrations (id, name, applied_at) VALUES (...) matching the migration's expected id and filename, effectively telling D1 "trust me, this one is already done." After that, migrations list and migrations apply behaved correctly again. The lesson was simple in hindsight: never touch a D1 database with raw execute once migrations are in play, even for something that feels like a one-line fix — the ledger has no way to know about it.

Problem 2: KV-backed rate limiting has real latency and consistency limits

I used Workers KV to implement a basic per-IP rate limiter on a couple of public form endpoints — read the current count, check it against a threshold, write the incremented count back. This is a well-known anti-pattern for KV specifically because KV is eventually consistent and optimized for high-read/low-write workloads, not for fast read-modify-write cycles. Under light traffic it worked fine. Under a short burst — someone refreshing a form repeatedly, or a bot hammering an endpoint — the count reads and writes raced against each other enough that the limiter under-counted requests by a meaningful margin, letting more through than the configured threshold.

I didn't need to rearchitect the whole thing; I moved just the rate-limiting counter to a Durable Object, which gives you a single consistent point of coordination per key (per IP, in this case) instead of KV's distributed eventual-consistency model. The Durable Object holds the counter in memory and persists it to its own storage, and because each DO instance is single-threaded with respect to its own state, the race condition disappears by construction rather than by careful timing. KV is still fine for things like feature flags or cached config that tolerate staleness; it's just the wrong tool for a counter that needs read-then-write correctness under concurrent hits.

Problem 3: Next.js metadata's openGraph fields don't inherit the way you'd expect

Next.js's metadata API lets you define an openGraph object in a parent layout and have child routes inherit and extend it, which works nicely for things like title and description. What surprised me is that openGraph.images does not merge the way nested metadata fields usually do when a child route defines its own partial openGraph object — if a page-level generateMetadata returns an openGraph key at all, Next.js treats it as a full replacement for the object rather than a shallow merge on top of the layout default, and any field you don't re-specify (including images) is simply gone rather than falling back to the parent's value.

In practice this meant several inner pages exported an openGraph object to set a page-specific title, and as a side effect silently lost the default sharing image the layout had defined, so social previews for those pages showed a blank or default browser card instead of the intended graphic. The fix was to stop treating the layout's openGraph as something child routes could partially override, and instead centralize a buildOpenGraph(overrides) helper that always spreads the full default object first and applies only the specific keys a page needed to change. That guarantees images and other shared fields survive even when a page customizes just the title.

Net take

None of these are OpenNext bugs exactly — they're gaps between how the underlying Cloudflare primitives behave and what you'd naively assume coming from a more traditional Node/Vercel deployment model. D1's migration ledger, KV's consistency model, and Next.js's metadata merging rules are all documented, just not in a way that surfaces itself until you've already hit the edge case. Running on Workers with OpenNext has been solid otherwise — cold starts are noticeably better than serverless functions I've run elsewhere, and having D1, KV, and Durable Objects available in the same request without extra network hops made a few things simpler than they would have been split across separate services.

Top comments (0)