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. No OpenNext config or Wrangler file is required.

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. Install @alchemy.run/frontend-frameworks and its OpenNext peer; the resource loads the package’s /nextjs export from your project at deploy time. They are only used at build time, so dev dependencies are enough:

Terminal window
bun add -d @alchemy.run/frontend-frameworks @opennextjs/cloudflare

Alchemy leaves native next.config.* and Tailwind configuration unchanged. No Alchemy plugin is required. OpenNext configuration is optional; without it, the default cache serves prerendered pages from static assets. Use Writable ISR for revalidation.

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.

The resource declaration is the same in both cases:

export const Website = Cloudflare.Website.Nextjs("Website");
  • Without open-next.config.ts: Alchemy generates a temporary config with read-only static-assets caching, or matching KV adapters when isr is set. You do not need to create a config file.
  • With open-next.config.ts: Alchemy automatically loads the file from rootDir (the working directory by default) through OpenNext’s native compiler. It never rewrites the file. Imports and callbacks are preserved, and you can use the full native OpenNextConfig shape within Cloudflare’s adapter constraints. This does not make AWS-specific runtime features available on Workers.

For example, an optional native config retaining read-only static-assets caching:

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

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.

alchemy.run.ts
export const IncCache = Cloudflare.KV.Namespace("NextIncCache");
export const TagCache = Cloudflare.KV.Namespace("NextTagCache");
export const Website = Cloudflare.Website.Nextjs("Website", {
isr: { incrementalCache: IncCache, tagCache: TagCache },
});

Alchemy binds the cache namespaces, revalidation queue, and Worker self-reference. Without a native config, it also selects the matching adapters. If you supply open-next.config.ts, select kv-incremental-cache, kv-next-tag-cache, and do-queue there to match these bindings: isr does not override the file’s adapter choices. The static-assets example above is read-only, not a writable-ISR configuration.

The KV cache fills on demand; build-time cache entries are not uploaded to KV. Pages using writable ISR must be able to regenerate with runtime credentials and data sources.

export const Website = Cloudflare.Website.Nextjs("Website", {
openNext: { buildCommand: "pnpm exec next build", minify: true },
});

An explicit openNext.buildCommand overrides the native config’s buildCommand. If neither sets a command, the default is npx next build. openNext.minify and openNext.debug remain resource-level build controls.

Unchanged inputs skip the OpenNext build. By default, project files outside build outputs and node_modules, plus the nearest package-manager lockfile, participate in the content hash.

The native open-next.config.ts itself is always hashed, even when memo narrows the scope. When narrowing memo.include, include any helpers imported by the config too, for example config/**:

export const Website = Cloudflare.Website.Nextjs("Website", {
memo: {
include: ["app/**", "public/**", "package.json", "next.config.mjs", "config/**"],
},
});
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", {
dev: { mode: "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.