Skip to content

Next.js

Cloudflare.Website.Nextjs deploys a Next.js app as a Cloudflare Worker. It runs next build through the OpenNext pipeline (@opennextjs/cloudflare), bundles the resulting server into a self-contained Worker, and deploys client assets plus prerendered pages as Worker static assets. There is no adapter to configure and no Wrangler file.

App Router and Pages Router both work — server components, API routes, middleware, server actions, dynamic segments, streaming SSR, and getServerSideProps pages all run in the Worker.

The build integration is not bundled with alchemy — the resource dynamically imports @distilled.cloud/nextjs (and its peer, OpenNext) from your project at deploy time, so both must be installed alongside your framework. They are only used at build time, so dev dependencies are enough:

Terminal window
bun add -d @distilled.cloud/nextjs @opennextjs/cloudflare

Your next.config.* stays untouched. OpenNext needs one config file next to it — the static-assets incremental cache is the zero-infrastructure default, where prerendered ISR pages serve their build-time payloads:

open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
});

For revalidation that actually writes, see Writable ISR below.

Declare the site as a module-level const (rather than inline in the Stack) and derive the typed shape of its bindings from it:

alchemy.run.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Website = Cloudflare.Website.Nextjs("Website");
export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>;

WebsiteEnv is the typed shape of the Worker’s bindings, derived from the class — you’ll import it into your Next.js code in Read bindings in server code.

Yield the class from your Stack and return its URL:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyNextjsSite",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const site = yield* Website;
return { url: site.url };
}),
);

The OpenNext server runs under Node compatibility, so the nodejs_compat compatibility flag is added automatically.

See examples/cloudflare-website-nextjs for the checked-in example.

The resource returns a plain Worker, so env accepts the full binding vocabulary — KV namespaces, R2 buckets, Durable Objects, secrets:

alchemy.run.ts
import * as Config from "effect/Config";
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export const Website = Cloudflare.Website.Nextjs("Website", {
env: {
UPLOADS: Uploads,
API_KEY: Config.redacted("API_KEY"),
},
});

Uploads is a description, not a deploy — Alchemy provisions the real bucket because the Website binds it. Config.redacted reads API_KEY from your environment at deploy time and binds it as a Worker secret — see Secrets & env.

Route handlers, server components, and server actions read bindings through OpenNext’s getCloudflareContext(). Its env is typed by the global CloudflareEnv interface — extend it once with the inferred env type:

cloudflare-env.d.ts
import type { WebsiteEnv } from "./alchemy.run.ts";
declare global {
interface CloudflareEnv extends WebsiteEnv {}
}
export {};

Every server entry point now gets fully typed bindings:

app/api/upload/route.ts
import { getCloudflareContext } from "@opennextjs/cloudflare";
export async function PUT(request: Request) {
const { env } = getCloudflareContext();
await env.UPLOADS.put("hello.txt", await request.text());
return Response.json({ ok: true });
}

env.UPLOADS is typed as an R2 bucket and env.API_KEY as a string — renaming a binding in alchemy.run.ts is a type error in your routes.

Switch the incremental cache to KV and revalidation actually writes: revalidatePath / revalidateTag purge entries, and time-based revalidate windows regenerate pages in the background through a Durable Object queue hosted on the same Worker.

open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
import kvNextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/kv-next-tag-cache";
export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
queue: doQueue,
tagCache: kvNextTagCache,
});

Bind the pieces — the WORKER_SELF_REFERENCE self service binding OpenNext uses to re-render pages is wired automatically:

alchemy.run.ts
export const IncCache = Cloudflare.KV.Namespace("NextIncCache");
export const TagCache = Cloudflare.KV.Namespace("NextTagCache");
export const Website = Cloudflare.Website.Nextjs("Website", {
env: {
NEXT_INC_CACHE_KV: IncCache,
NEXT_TAG_CACHE_KV: TagCache,
NEXT_CACHE_DO_QUEUE: Cloudflare.DurableObject("NEXT_CACHE_DO_QUEUE", {
className: "DOQueueHandler",
}),
},
});

DOQueueHandler ships inside the OpenNext worker bundle — the binding declaration is all it takes.

Terminal window
bun alchemy dev

alchemy dev defaults to preview parity: the OpenNext worker is built and served under workerd locally, with the Worker’s bindings emulated — the same runtime behavior, asset routing, and ISR/cache semantics as production.

For an edit-refresh loop, switch to the real next dev (Turbopack HMR):

export const Website = Cloudflare.Website.Nextjs("Website", {
nextjs: { devMode: "hmr" },
});

In hmr mode your app code runs in Node with the Worker’s bindings proxied onto getCloudflareContext() — no initOpenNextCloudflareForDev() call needed. Cloudflare-specific runtime behavior (workerd APIs, ISR semantics) still needs preview mode.

These come from upstream @opennextjs/cloudflare:

  • Edge-runtime routes and pages (export const runtime = "edge") are unsupported and would fail at runtime, so the build fails early with the offending route list. Remove the directive — the node runtime runs on Workers. Middleware is fully supported.
  • next/image optimization requires a zone with Cloudflare Images. On workers.dev, pass unoptimized and images serve as raw static assets.
  • Partial Prerendering / "use cache" and Pages-Router i18n config are out of scope for now. App Router i18n via middleware works.