So I spent too long figuring this out. After implementing the Caching recommendations from the OpenNext docs, I hit a cryptic error, which you'll read about below, and then I found the answer (thanks to a random Discord comment). I'm writing this article so you can just get it working too.
Goals:
- Use
@opennextjs/cloudflare
. - Deploy via GitHub Actions.
- Connect to a custom domain.
- Use a monorepo.
We started with the standard command:
pnpm create cloudflare@latest example.com --framework=next
Note: the OpenNext page uses a --platform=workers
arg that apparently doesn't do anything (I diffed it). The Cloudflare page omits it.
That command generates a bunch of files and I'm going to assume that you ran that. I'm not going to print every file here.
The one important thing is that my project is a monorepo, with a web
directory for the website workers project. You'll see this web
path in the GitHub actions job.
wrangler.jsonc
Here is the wrangler config.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "web",
"main": ".open-next/worker.js",
"compatibility_date": "2025-07-20",
"compatibility_flags": [
"nodejs_compat",
"global_fetch_strictly_public",
],
"observability": {
"enabled": true,
},
"routes": [
{
"pattern": "example.com",
"custom_domain": true,
},
],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets",
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "web",
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "next-inc-cache",
},
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler"],
},
],
"d1_databases": [
{
"binding": "NEXT_TAG_CACHE_D1",
"database_id": "______________",
"database_name": "next-tag-cache",
},
],
"send_metrics": false,
}
The routes
block connects it to my custom domain (which is not example.com
, opsec or whatever). I don't think you need to set up the custom domain in the dashboard manually, but you do definitely need to have the domain set up in the dashboard as a zone. You can't use someone else's domain, sorry.
My domain didn't have any records, and cloudflare sets up the records automatically when it creates the "custom domain" for the worker.
I wasn't sure if WORKER_SELF_REFERENCE
was required, but it shouldn't hurt.
You do need to manually create an R2 bucket named next-inc-cache
.
Also, create a D1 database named next-tag-cache
and paste its id in database_id
.
Yeah I disabled metrics. Mostly for maximal performance... Cloudflare should understand.
open-next.config.ts
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import d1NextTagCache from '@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
queue: doQueue,
tagCache: d1NextTagCache,
});
NBD. The hardest part here was changing the double quotes to single quotes.
By the way, can you believe that wrangler uses tabs in its generated types file? Unconscionable! How do these people make it out of bed in the morning!
.github/workflows/deploy.yml
name: Deploy Workers
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- uses: actions/checkout@v4
# Install node and npm with cached pnpm installation.
# https://github.com/pnpm/action-setup?tab=readme-ov-file#use-cache-to-reduce-installation-time
- uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/web/.next/cache
# Generate a new cache whenever packages or source files change
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# If source files changed but packages didn't, rebuild from a prior cache
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Deploy
run: |
cd web
pnpm run deploy
You need to add these repository secrets:
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_ID
About the API token. It needs (some if not all of) these permissions:
- Your Account
- Workers KV Storage:Edit
- Workers Scripts:Edit
- Account Settings:Read
- Workers Tail:Read
- Workers R2 Storage:Edit
- Cloudflare Pages:Edit
- Workers Builds Configuration:Edit
- Workers Observability:Edit
- D1:Edit
- Your zone - Workers Routes:Edit
- All users - User Details:Read
- Memberships:Read
I got these permissions by choosing the API token preset called "Edit Cloudflare Workers". However, I also needed to add the D1:Edit
permission.
If there is no D1:Edit
permission, you'll see this error:
Creating D1 table if necessary...
ERROR Wrangler command failed
ELIFECYCLE Command failed with exit code 1.
So helpful, right? (I wonder if prefixing WRANGLER_LOG="debug"
would make it more verbose.)
Technically the CLOUDFLARE_ACCOUNT_ID
doesn't need to be a secret, but I'm doing what I know works.
This job has some npm caching stuff, and the actions are all up to date and using best practices.
Oh, remember to create a .nvmrc
file containing the string 22
, or whatever version of node you use.
Beware the line 33 has the path web
. Change that if you don't use a monorepo or if your path is different.
Also the cd web
line in the deploy step.
And here's a snippet from my package.json
scripts:
"opennext:build": "opennextjs-cloudflare build",
"deploy": "pnpm run opennext:build && opennextjs-cloudflare deploy",
That's all
Yeah I guess that's it!
Top comments (1)
Interesting. This makes me grateful for my usual Terraform-based approach to CF interactions. Doing infrastructure work with TS and wrangler does not look like something I'd be jealous of! Glad you figured it out.