Deploying Next.js SSR on Cloudflare: The Complete Guide to OpenNext vs next-on-pages


After you complete this article, you will have a solid understanding of:
Why deploying Next.js SSR on Cloudflare is different from traditional hosting
The real difference between Cloudflare Pages with next-on-pages and OpenNext adapter
How the free tier's 100,000 daily requests can handle production traffic
Common deployment pitfalls that will waste hours of debugging
Which adapter to choose for your specific use case
Have You Ever Seen This Error When Deploying to Cloudflare?
If you've tried deploying a Next.js app to Cloudflare Pages, you've probably encountered this frustrating message:
Error: Dynamic server usage: Page couldn't be rendered statically because it used `cookies`.
Or even worse:
Error: The edge runtime does not support Node.js 'fs' module.
You can use 'fs' module only in Node.js runtime.
And then you wonder: "But I thought Cloudflare supports Next.js SSR now?"
Let me clear up this confusion once and for all.
The Two Ways to Deploy Next.js on Cloudflare
There are two completely different approaches to deploying Next.js on Cloudflare, and choosing the wrong one will cause endless headaches.
Option 1: @cloudflare/next-on-pages (The Limited One)
This was the original way, and it comes with a massive limitation:
// Every server component needs this ๐
export const runtime = 'edge';
export default async function Page() {
// โ This will fail!
const fs = require('fs');
// โ This will also fail!
const bcrypt = require('bcrypt');
// โ
Only Web APIs work
const response = await fetch('https://api.example.com');
return <div>Limited to Edge Runtime</div>;
}
Option 2: @opennextjs/cloudflare (The Game Changer)
Released in 2024 and now in v1.0-beta, this adapter supports the Node.js runtime:
// No runtime declaration needed! ๐
export default async function Page() {
// โ
This works now!
const fs = require('fs');
// โ
This works too!
const crypto = require('crypto');
// โ
Even database connections work
const data = await prisma.user.findMany();
return <div>Full Node.js support!</div>;
}
Setting Up Next.js SSR with OpenNext (The Right Way)
Let's deploy a real Next.js app with full SSR support on Cloudflare.
Option 1: Using Cloudflare CLI (Recommended)
# Create new Cloudflare project
npm create cloudflare@latest my-nextjs-app -- \
--framework=next --platform=workers
# Deploy
npm run deploy
Ready to see this in action? Check out the complete working example with full source code, deployment configuration, and step-by-step setup instructions here
Migrating Your Existing Next.js App to Cloudflare
If you already have a Next.js app running on Vercel, AWS, or anywhere else, here's how to migrate it to Cloudflare.
Step 1: Check Your Current Setup
First, identify which features your app uses:
# Check your Next.js version
npm list next
# Look for these in your code:
grep -r "export const runtime" . # Edge runtime declarations
grep -r "getServerSideProps" . # SSR pages
grep -r "getStaticProps" . # SSG pages
grep -r "app/api" . # API routes
Step 2: Choose Your Migration Path
If your app uses Vercel:
# Use Diverce for automatic migration!
npx diverce migrate
# This tool automatically:
# - Adds OpenNext to your project
# - Updates your configuration
# - Creates a PR with all changes
For manual migration:
# Install the OpenNext adapter
npm install -D @opennextjs/cloudflare wrangler
# Remove any Vercel-specific packages
npm uninstall @vercel/analytics @vercel/og
Step 3: Update Your Configuration
Remove edge runtime declarations:
// โ Remove these from all your files
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
// โ
OpenNext handles this automatically
// Just delete these lines!
Update your next.config.js:
// next.config.js
const { setupDevPlatform } = require('@cloudflare/next-on-pages/next-dev');
// โ Remove this if you have it
if (process.env.NODE_ENV === 'development') {
await setupDevPlatform();
}
// โ
Add this instead
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
const nextConfig = {
// Your existing config stays the same!
images: {
domains: ['example.com'],
},
// Remove any Vercel-specific settings
// outputFileTracing: false, โ
};
module.exports = nextConfig;
Step 4: Handle Platform-Specific Code
If you're using Vercel KV:
// โ Before (Vercel KV)
import { kv } from '@vercel/kv';
await kv.set('key', 'value');
// โ
After (Cloudflare KV)
export async function GET(request: Request, env: Env) {
await env.MY_KV.put('key', 'value');
return new Response('Saved!');
}
If you're using Vercel Postgres:
// โ Before (Vercel Postgres)
import { sql } from '@vercel/postgres';
const { rows } = await sql`SELECT * FROM users`;
// โ
After (Any PostgreSQL client works!)
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const { rows } = await pool.query('SELECT * FROM users');
If you're using Vercel Edge Config:
// โ Before (Vercel Edge Config)
import { get } from '@vercel/edge-config';
const value = await get('featureFlag');
// โ
After (Cloudflare KV or D1)
export async function GET(request: Request, env: Env) {
const value = await env.CONFIG_KV.get('featureFlag');
return Response.json({ value });
}
Step 5: Update Environment Variables
Create a .dev.vars
file for local development:
# .dev.vars (like .env.local but for Cloudflare)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
NEXTAUTH_SECRET=your-secret-key
NEXT_PUBLIC_API_URL=https://api.example.com
Add bindings to wrangler.toml:
Before that
Optional: create the R2 bucket for the cache If you added R2 incremental cache in your open-next.config.ts
.
Run this command to create it:
npx wrangler r2 bucket create your-bucket-name
name = "my-nextjs-app"
main = ".open-next/worker.js"
compatibility_date = "2025-07-27"
compatibility_flags = ["nodejs_compat"]
# Static assets configuration
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
# R2 Buckets
[[r2_buckets]]
binding = "NEXT_INC_CACHE_R2_BUCKET"
bucket_name = "my-bucket-name"
Now let deploy :
//Run the command below
npm run deploy
If everything works well, your Next.js app will be live on Cloudflare's global network within seconds, accessible via a *.
workers.dev
URL or your custom domain, serving from 280+ locations worldwide with automatic SSL.
Common Migration Issues
Issue 1: Dynamic imports failing
// โ This might fail
const MyComponent = dynamic(() => import('./MyComponent'), {
ssr: false
});
// โ
Ensure proper configuration
const MyComponent = dynamic(
() => import('./MyComponent'),
{
ssr: false,
loading: () => <div>Loading...</div>
}
);
Issue 2: File system access
// โ This won't work in production
import fs from 'fs';
const data = fs.readFileSync('./data.json');
// โ
Use static imports or fetch
import data from './data.json';
// OR
const response = await fetch('/data.json');
const data = await response.json();
Issue 3: Image optimization
// โ
Next/Image works but needs configuration
// In next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './image-loader.js',
},
};
// image-loader.js
export default function cloudflareLoader({ src, width, quality }) {
const params = [`width=${width}`];
if (quality) {
params.push(`quality=${quality}`);
}
const paramsString = params.join(',');
return `/cdn-cgi/image/${paramsString}/${src}`;
}
Step 7: Test Your Migration
# 1. Build and preview locally
npm run preview
# 2. Check for errors
# Common errors and fixes:
# "Cannot find module 'fs'"
# โ Remove file system operations
# "window is not defined"
# โ Wrap in useEffect or check typeof window
# "Module not found: Can't resolve 'encoding'"
# โ Add to externals in next.config.js
# 3. Test all your routes
curl http://localhost:8787/api/health
curl http://localhost:8787/dashboard
The Free Tier: More Powerful Than You Think
Cloudflare's free tier includes:
100,000 requests per day (resets at midnight UTC)
10ms CPU time per request (plenty for SSR)
128MB memory per Worker
Unlimited static asset requests ๐
Real-World Capacity Example
// Let's calculate what 100k requests means:
// Average SSR page: ~50ms total time (including I/O)
// CPU time used: ~5-10ms
// Memory used: ~30-50MB
// Daily capacity on free tier:
// - 100,000 page views
// - ~4,166 page views per hour
// - ~69 page views per minute
// That's enough for:
// - A blog with 50k daily visitors (2 pages each)
// - A SaaS dashboard with 10k daily active users
// - An e-commerce site with 20k daily shoppers
Advanced Features That Actually Work
1. API Routes with Full Node.js
// app/api/process/route.ts
import { createHash } from 'crypto';
import { headers } from 'next/headers';
export async function POST(request: Request) {
const body = await request.json();
// โ
Node.js crypto works!
const hash = createHash('sha256')
.update(body.data)
.digest('hex');
// โ
Headers manipulation
const headersList = headers();
const userAgent = headersList.get('user-agent');
// โ
Complex processing
const result = await processDataWithNodeAPIs(body);
return Response.json({
hash,
processed: result,
userAgent
});
}
2. Middleware That Scales
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Runs at the edge for EVERY request
const country = request.geo?.country || 'US';
// Add custom headers
const response = NextResponse.next();
response.headers.set('x-user-country', country);
// Redirect based on geo
if (country === 'CN' && request.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/cn', request.url));
}
return response;
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};
3. ISR That Actually Works
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
// Pre-build these pages
return [
{ slug: 'getting-started' },
{ slug: 'advanced-features' }
];
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.blog.com/posts/${params.slug}`, {
next: { revalidate: 3600 } // Revalidate every hour
});
return <article>{/* Your content */}</article>;
}
Production Deployment Checklist
Before deploying to production, ensure:
โ
nodejs_compat flag is set
โ
Environment variables are configured in Cloudflare dashboard
โ
R2 bucket is created for caching (optional)
โ
Custom domain is configured
โ
Preview deployments are working
Deploy Command
# Deploy to production
npm run deploy -- --env production
# Deploy preview
npm run deploy -- --env preview
When to Use Which Approach?
Use @cloudflare/next-on-pages when:
Your app only uses Web APIs
You need the absolute fastest cold starts
You're building a simple marketing site
Use @opennextjs/cloudflare when:
You need Node.js APIs (crypto, fs, etc.)
You're using Prisma or other Node.js ORMs
You have existing Next.js apps to migrate
You want the full Next.js feature set
The Future is Here
With OpenNext adapter reaching v1.0, deploying production Next.js apps on Cloudflare is finally practical. You get:
True SSR with full Node.js support
Global edge deployment from 280+ locations
Generous free tier for getting started
Seamless scaling when you grow
Remember: You're not choosing between features and performance anymore. You can have both.
Was this article helpful for you? If so, let me know what you think in the comment section.
Subscribe to my newsletter
Read articles from Yusuf Adeyemo directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Yusuf Adeyemo
Yusuf Adeyemo
Yusuf Adeyemo is a DevOps Engineer from Nigeria. He loves helping startups deliver better software and provide more control over their environment and software development process with the help of modern tools and automation.