Deploy Next.js SSR to Cloudflare Workers with GitHub Actions
- Authors
- Name
- Hamza Rahman
- Published on
- -5 mins read-
I built a small nutrition app, FastFoodCalc, and hit a classic problem. I needed real SSR so search engines could index 2,000+ menu items, while keeping the app fast for users. Next.js made SSR easy. Deployment was… not.
I tried a few paths, tripped on the usual edges, and ended up with a simple, boring (in a good way) flow: build on CI with GitHub Actions and deploy to Cloudflare Workers.
Here’s what broke, what worked, and the setup I shipped.
The Goal: SSR Without the Headaches
My needs were simple:
- Real SSR: Serve real HTML for 2,000+ dynamic pages so search engines could see my content.
- Fast Performance: Users should get a snappy, client-side experience.
- Reliable Deploys: I wanted to
git pushand walk away, not futz with local environment issues.
The Traps: What I Tried First
Before I found the right flow, I fell into the two most common traps.
Trap 1: The Cloudflare Pages 404
Cloudflare Pages has a slick GitHub integration, so I tried it first. It felt right. I connected my repo, the build ran, and... 404.
Here's the "gotcha": OpenNext (the tool that packages Next.js for non-Vercel platforms) builds for Cloudflare Workers, not Pages. The build output was a worker.js file and an assets/ directory. Cloudflare Pages, however, expects either a static site or its own _worker.js Functions format. The result: a pretty dashboard and a broken site.
Trap 2: The "It Works on My Machine" Nightmare
Fine, I thought, I'll just deploy from my local machine. This is the path of pain.
On my Windows machine, I hit weird pathing and ESM loader errors. On a co-worker's Mac, it mostly worked. When I tried it in WSL (Windows Subsystem for Linux) from a mounted /mnt/d/ drive, the build would sometimes crash with a SIGBUS error.
This is the classic "works on my machine" problem. It's fragile, depends on your OS, and is impossible to debug. A deploy process you can't trust is worse than no deploy process at all.
The Winning Stack (And Why It Works)
I stepped back and defined what would work:
- A build target that natively understands the OpenNext output.
- A build environment that is consistent, clean, and OS-agnostic.
This led me to the final, simple stack.
1. Cloudflare Workers for SSR (The Target)
This was the "aha!" moment. OpenNext is designed to output a format that Cloudflare Workers understands perfectly.
My wrangler.toml (Cloudflare's config file) just needed to point to the OpenNext build output:
# wrangler.tomlname = "fastfoodcalc"main = ".open-next/worker.js"compatibility_date = "2024-09-23"compatibility_flags = ["nodejs_compat"]
[assets]directory = ".open-next/assets"binding = "ASSETS"That's it. The worker entry and assets map 1:1 to OpenNext's output. The nodejs_compat flag unlocks Node APIs Next.js relies on, and compatibility_date is required by @opennextjs/cloudflare; it must be 2024-09-23 or later, so set it to that date or any later one.
The npm run deploy the workflow runs is one script in package.json. It builds the OpenNext output and then ships it with Wrangler:
{ "scripts": { "deploy": "opennextjs-cloudflare build && wrangler deploy" }}2. GitHub Actions for Builds (The Environment)
This solved the "works on my machine" problem. By building on ubuntu-latest, I get the same clean environment on every push.
- No Windows path issues or WSL quirks
- No local
node_modulesdrift - Secrets live in GitHub, not on laptops
- Anyone can deploy by pushing to
main
The pipeline lives in .github/workflows/deploy.yml:
name: Deploy to Cloudflare Workers
on: push: branches: [ main ]
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4
- name: Setup Node.js v20 uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm'
- name: Install dependencies run: npm ci
- name: Deploy to Cloudflare run: npm run deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Setup is three steps:
- Create a Cloudflare API token (Workers → Edit template).
- Copy your Account ID from the Cloudflare dashboard.
- Add both as GitHub repo secrets.
Deploy flow:
git add .git commit -m "feat: update menu items"git push origin mainWatch the workflow run; a couple of minutes later, it’s live.
Why I Picked Cloudflare (not Vercel)
Vercel is excellent, but for this project Cloudflare fit better:
- Generous free tier (≈100k requests/day) for a growing SSR app
- Global edge Workers: fast cold starts, responses close to users
- Clean SSR + assets mapping from OpenNext
If your app leans on Vercel-specific features, that’s a great path too. For FastFoodCalc, Cloudflare’s speed + free quota won.
OS‑Specific Note (Windows)
- Windows paths can trigger ESM loader errors; WSL on mounted drives (e.g.,
/mnt/d/...) may throwSIGBUS. - Prefer CI builds. If building locally, use WSL’s native FS (e.g.,
~/projects/...).
The Result
FastFoodCalc now renders SSR at the edge and hydrates instantly on the client. Search engines see real HTML for all 2,000+ items, and users get a fast app.
The best part: the deploy flow is boring in the best way. Make a change, push, done. Less ceremony, more shipping.
Related articles
Nextjs Link onClick: Run Code, Redirect, and Handle Forms
How to use the onClick prop on the Next.js Link component to run code before navigation, redirect programmatically, and use preventDefault, with App Router and Pages Router examples in JavaScript and TypeScript.
Cheerio vs node-html-parser: which HTML parser to use
Cheerio vs node-html-parser for parsing HTML in Node.js: node-html-parser is lighter and faster, Cheerio has a fuller jQuery-style API. How to choose between them.
Cheerio vs Puppeteer: which to use for scraping
Cheerio vs Puppeteer for web scraping in Node.js: Cheerio parses static HTML and is fast, Puppeteer drives a real browser and handles JavaScript-rendered pages. When to use each, and how to combine them.

