Deploying Next.js to Cloudflare Pages: A Complete Solution for Static Asset 404 Errors
- Published on
- ...
- Authors

- Name
- Huashan
- @herohuashan
Background
I recently migrated my blog from Hugo to Next.js and chose to deploy it on Cloudflare Pages. Although the deployment was successful and the page content displayed correctly, I ran into a tricky issue: all CSS and JavaScript files were returning 404, leaving the page completely unstyled.
Upon inspection with Chrome DevTools, I saw this error:
Refused to apply style from 'https://geekhuashan.com/_next/static/css/xxx.css'
because its MIME type ('text/html') is not a supported stylesheet MIME type
This error message indicates that although the browser requested a CSS file, the server returned HTML content (likely a 404 page) instead of the actual CSS file.
Tech Stack
- Framework: Next.js 15.5.2 (App Router)
- Adapter: @opennextjs/cloudflare 1.13.0
- Deployment Platform: Cloudflare Pages
- Styling: Tailwind CSS v4.1.17
Diagnosis Process
Step 1: Confirm File Existence
First, I checked the build output to confirm that the CSS files actually existed:
find .open-next/assets -name "*.css"
The file was there, so it wasn't a build issue.
Step 2: Check Path Mapping
I analyzed the HTML source and the actual file path:
- HTML Reference:
/_next/static/css/6f6a8d0aee9e0fd8.css - Actual File Location:
.open-next/assets/_next/static/css/6f6a8d0aee9e0fd8.css - Cloudflare Pages Root Directory:
.open-next/
Here's the problem! Cloudflare Pages uses .open-next/ as the root directory, so the actual accessible path would be:
/assets/_next/static/css/xxx.css(Actual path)/_next/static/css/xxx.css(Path expected by HTML)
The paths didn't match!
Step 3: Check Cloudflare Pages Configuration
I looked at wrangler.toml:
name = "blog-nextjs-cdj"
compatibility_date = "2024-11-18"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".open-next"
The configuration seemed correct, but it was missing the crucial _routes.json file to tell Cloudflare which paths should serve static files directly.
Attempted (and Failed) Solutions
Solution 1: Set assetPrefix ❌
I tried adding this to next.config.js:
const assetPrefix = '/assets'
Result: This made Next.js generate /assets/_next/... paths, which still didn't match the actual path /assets/_next/... and also interfered with other paths.
Solution 2: Rename the _next Directory ❌
I attempted to rename _next to next (since Cloudflare ignores directories starting with _):
mv .open-next/assets/_next .open-next/assets/next
Result: The HTML still referenced /_next/..., so the paths remained mismatched.
Solution 3: Add a _redirects File ❌
I created a Cloudflare Pages redirect rule:
/_next/* /assets/next/:splat 200
/static/* /assets/next/static/:splat 200
Result: In Worker mode (when a _worker.js file is present), the _redirects file is not processed.
The Final Solution ✅
After many attempts, I found the fundamental solution: adjust the directory structure + configure _routes.json.
Step 1: Modify the Build Script
I modified the pages:build script in package.json:
{
"scripts": {
"pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && node -e \"require('fs').writeFileSync('.open-next/_routes.json', JSON.stringify({version:1,include:['/*'],exclude:['/_next/static/*','/favicon.ico','/robots.txt','/sitemap.xml','/feed.xml','/404.html','/BUILD_ID','/search.json','/tags/*']},null,2))\""
}
}
Key changes:
Copy assets to the root directory:
cp -r .open-next/assets/* .open-next/- This moves
_next,feed.xml,search.json, etc., fromassets/to the root. - Now, the
/_next/...reference in the HTML correctly maps to the files.
- This moves
Automatically generate
_routes.json:node -e "require('fs').writeFileSync('.open-next/_routes.json', JSON.stringify({ version: 1, include: ['/*'], exclude: [ '/_next/static/*', '/favicon.ico', '/robots.txt', '/sitemap.xml', '/feed.xml', '/404.html', '/BUILD_ID', '/search.json', '/tags/*' ] }, null, 2))"
Step 2: Understand the Role of _routes.json
_routes.json is a Cloudflare Pages routing configuration file that controls which requests are handled by the Worker and which are served directly as static files:
{
"version": 1,
"include": ["/*"], // All paths go to the Worker by default
"exclude": [ // These paths are served as static files directly
"/_next/static/*", // Next.js static assets
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
"/feed.xml"
]
}
Why is this important?
- Bypass Worker Processing: Static files are served directly by the Cloudflare CDN, which is faster and doesn't incur Worker execution costs.
- Correct MIME Type: Cloudflare automatically sets the correct
Content-Typebased on the file extension. - Avoid 404s: It tells Cloudflare that these paths correspond to real static files.
Step 3: Verify the Deployment
Build and deploy:
npm run pages:build
wrangler pages deploy .open-next/ --project-name=blog-nextjs --branch=main
Check the final directory structure:
.open-next/
├── _worker.js # Cloudflare Worker entry point
├── _routes.json # Routing configuration
├── _next/ # Next.js static assets (copied from assets)
│ └── static/
│ ├── css/
│ │ └── 6f6a8d0aee9e0fd8.css
│ ├── chunks/
│ └── media/
├── assets/ # Original assets directory (retained)
├── feed.xml
├── search.json
└── tags/
Step 4: Test and Validate
Visit the website and check:
curl -I https://geekhuashan.com/_next/static/css/6f6a8d0aee9e0fd8.css
Response:
HTTP/2 200
content-type: text/css
cache-control: public, max-age=31536000, immutable
✅ Success! The CSS file is served correctly with the right MIME type!
Core Principle Summary
Root Cause of the Problem
Cloudflare Pages' directory structure requirements:
- The directory specified by
pages_build_output_dir(.open-next/) is the web root. - A browser request to
/path/to/filemaps to.open-next/path/to/file. - If a file is at
.open-next/assets/_next/..., its access path is/assets/_next/.... - But the HTML generated by Next.js references
/_next/....
Core of the Solution
Make the file path match the HTML reference:
- HTML Reference:
/_next/static/css/xxx.css - File Location:
.open-next/_next/static/css/xxx.css - Access Path:
/_next/static/css/xxx.css✅
Optimize access with _routes.json:
- Static assets bypass the Worker and are served directly by the CDN.
- The correct MIME type is set.
- Performance is improved, and costs are reduced.
Lessons Learned
1. Understand the Deployment Platform's Directory Structure
Different platforms have different requirements for the build output directory structure:
- Vercel: Uses the
.next/directory directly. - Cloudflare Pages: Requires the directory specified by
pages_build_output_diras the root. - AWS/Amplify: Typically uses an
out/directory.
2. Path Mapping is Key
80% of deployment issues are related to path mapping:
HTML Reference Path = Web Access Path = File System Path (relative to root)
A mismatch at any stage will lead to a 404.
3. Use Developer Tools for Diagnosis
The Network panel in Chrome DevTools is your best friend:
- Status Code: 200 (Success), 404 (Not Found), 403 (Forbidden).
- Content-Type: Check if the MIME type is correct.
- Headers: Look for redirects, caching issues, etc.
4. Check _routes.json
Cloudflare Pages' _routes.json file is very important. It determines:
- Which requests go to the Worker.
- Which requests are served as static files.
- How to optimize performance and cost.
5. Keep Debugging Information
Log every attempt (e.g., in commit messages):
git log --oneline
# 4b0d30e - fix: rename _next to next and add redirects
# 15c82a2 - fix: remove assetPrefix to fix static asset loading
# 87d7553 - fix: add /static/* redirect rule for CSS and JS assets
# c7d871c - fix: keep _next directory name and remove custom redirects
# 441e9d4 - fix: add _routes.json generation to build script
# 666857b - fix: copy assets to root directory for correct static file paths ✅
Complete Solution Code
package.json
{
"scripts": {
"pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && node -e \"require('fs').writeFileSync('.open-next/_routes.json', JSON.stringify({version:1,include:['/*'],exclude:['/_next/static/*','/favicon.ico','/robots.txt','/sitemap.xml','/feed.xml','/404.html','/BUILD_ID','/search.json','/tags/*']},null,2))\"",
"pages:deploy": "npm run pages:build && wrangler pages deploy .open-next/ --project-name=blog-nextjs"
}
}
wrangler.toml
name = "blog-nextjs"
compatibility_date = "2024-11-18"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".open-next"
next.config.js
module.exports = () => {
const plugins = [withContentlayer, withBundleAnalyzer]
return plugins.reduce((acc, next) => next(acc), {
reactStrictMode: true,
// No need to set assetPrefix or basePath
images: {
unoptimized: true, // Required for Cloudflare Pages
},
// ... other configurations
})
}
Addendum: Blog Post 404 Issue
After fixing the static asset 404s, I encountered a new problem: all blog post pages were returning 404! While the homepage and list pages worked, accessing an individual post (e.g., /blog/chai-shu-reading-notes) resulted in a 404 page.
The Problem
curl -I https://geekhuashan.com/blog/chai-shu-reading-notes
# HTTP/2 404 Not Found
But everything worked fine in the local development environment (npm run dev):
curl -I http://localhost:3000/blog/chai-shu-reading-notes
# HTTP/1.1 200 OK
Diagnosis
Step 1: Check Prerendered Files
First, I checked if the prerendered HTML files existed in the Next.js build output:
ls .next/server/app/blog/
# chai-shu-reading-notes.html ✅ File exists
# chai-shu-reading-notes.meta
# chai-shu-reading-notes.rsc
The file was there and had been copied to the deployment directory:
ls .open-next/blog/
# chai-shu-reading-notes.html ✅
Step 2: Check the Prerender Manifest
I checked the Next.js prerender manifest to confirm the route was generated correctly:
cat .next/prerender-manifest.json | grep chai-shu
# "/blog/chai-shu-reading-notes": {} ✅
The route configuration was correct, meaning the issue wasn't in the build phase.
Step 3: Test the Worker Locally
I used Wrangler to start a local preview of Cloudflare Pages:
cd .open-next && npx wrangler pages dev . --port 8788
Testing the access:
curl -I http://localhost:8788/blog/chai-shu-reading-notes
# HTTP/1.1 404 Not Found
I saw a critical error in the console:
[ERROR] Uncaught Error: Internal: NoFallbackError Error
at responseGenerator
at null.<anonymous>
Found the root cause!
Root Cause: NoFallbackError
NoFallbackError is a known issue with the OpenNext Cloudflare adapter. It's thrown when the Worker tries to access the cache for a prerendered page but fails due to improper configuration or inaccessibility of the cache system.
By default, OpenNext uses the local file system as a cache, but in the Cloudflare Pages production environment:
- The Worker cannot directly access the file system cache.
- It needs to use R2 or KV storage as a cache backend.
- Alternatively, these pages can be served as static files, bypassing the Worker.
Solution: Serve Blog Posts Statically
Since blog posts are prerendered static content, why should the Worker handle them at all? The simplest solution is to serve the blog posts as static files directly from the CDN.
Step 1: Create a Directory Structure Compliant with Cloudflare Pages
Cloudflare Pages supports "pretty URLs," which automatically map /blog/post-name to /blog/post-name/index.html.
We need to convert:
blog/
└── chai-shu-reading-notes.html
into:
blog/
└── chai-shu-reading-notes/
└── index.html
Step 2: Create an Automation Script
I created scripts/create-blog-structure.mjs:
import fs from 'fs'
import path from 'path'
const blogDir = '.open-next/blog'
const tagsDir = '.open-next/tags'
console.log('🔧 Creating directory structure for blog posts...')
function createIndexStructure(dir) {
if (!fs.existsSync(dir)) {
console.log(` ⚠️ Directory ${dir} does not exist, skipping...`)
return
}
const files = fs.readdirSync(dir)
let count = 0
for (const file of files) {
if (file.endsWith('.html')) {
const basename = file.replace('.html', '')
const targetDir = path.join(dir, basename)
const targetFile = path.join(targetDir, 'index.html')
const sourceFile = path.join(dir, file)
// Create directory
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
// Copy HTML file as index.html
fs.copyFileSync(sourceFile, targetFile)
count++
}
}
return count
}
try {
const blogCount = createIndexStructure(blogDir)
const tagsCount = createIndexStructure(tagsDir)
console.log(`✅ Created ${blogCount || 0} blog post directories`)
console.log(`✅ Created ${tagsCount || 0} tag directories`)
console.log('✨ Directory structure created successfully!')
} catch (error) {
console.error('❌ Error creating directory structure:', error.message)
process.exit(1)
}
Step 3: Update _routes.json
I modified scripts/fix-routes.mjs to exclude /blog/* from the Worker:
routes = {
version: 1,
include: ['/*'],
exclude: [
'/_next/static/*',
'/_next/data/*',
'/static/*',
'/images/*',
'/blog/*', // ✅ Serve blog posts as static files
'/tags/*', // ✅ Serve tag pages as static files too
'/favicon.ico',
'/robots.txt',
'/sitemap*.xml',
'/feed.xml',
'/rss.xml',
'/404.html',
'/BUILD_ID',
'/search.json',
],
}
Key Changes:
- Added
/blog/*and/tags/*to the exclude list. - Now, when a blog post is accessed, Cloudflare serves the static HTML file directly.
- The Worker no longer handles these requests, avoiding the
NoFallbackError.
Step 4: Update the Build Script
I modified package.json:
{
"scripts": {
"pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && mkdir -p .open-next/blog && cp -r .next/server/app/blog/*.html .open-next/blog/ 2>/dev/null || true && node scripts/fix-routes.mjs && node scripts/create-blog-structure.mjs"
}
}
Step 5: Rebuild and Deploy
# Clean old build
rm -rf .open-next .next
# Rebuild
npm run pages:build
Build output:
🔧 Creating directory structure for blog posts...
✅ Created 41 blog post directories
✅ Created 0 tag directories
✨ Directory structure created successfully!
Deploy:
wrangler pages deploy .open-next --project-name blog-nextjs
Verifying the Result
Test blog post access:
# Test the new deployment
curl -I https://geekhuashan.com/blog/chai-shu-reading-notes
# Response:
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate
✅ Success! All blog posts are now accessible!
Final Directory Structure
.open-next/
├── _worker.js
├── _routes.json
├── _next/
│ └── static/ # CSS, JS, etc.
├── blog/
│ ├── chai-shu-reading-notes/
│ │ └── index.html # ✅ Cloudflare automatically maps /blog/chai-shu-reading-notes
│ ├── chai-shu-reading-notes.html
│ ├── another-post/
│ │ └── index.html
│ └── another-post.html
└── tags/
└── ... (same structure)
Core Takeaways
- Root Cause of
NoFallbackError: The Worker cannot access the local file system cache. - Solution: Exclude static pages from the Worker's scope.
- Directory Structure: Use Cloudflare Pages' "pretty URLs" feature.
- Automation: Use scripts to automate the directory structure transformation.
Performance Comparison
| Metric | Worker Mode | Static File Mode |
|---|---|---|
| Response Time | ~100-200ms | ~10-30ms |
| Cost | Counts towards Worker requests | Free (CDN) |
| Reliability | Depends on cache config | 100% reliable |
| TTFB | Slower | Extremely fast |
Conclusion: For prerendered blog posts, the static file mode offers better performance, lower cost, and simpler configuration!
Related Resources
- OpenNext Cloudflare Official Docs
- Cloudflare Pages
_routes.jsonConfiguration - Next.js Static Asset Configuration
- Cloudflare Pages Pretty URLs
Summary
When deploying a Next.js app to Cloudflare Pages, the root cause of static asset 404 errors is a path mapping mismatch. The core of the solution is:
- Adjust the directory structure: Copy the contents of
assets/to the root to ensure file paths match HTML references. - Configure
_routes.json: Tell Cloudflare Pages which paths are static files to optimize performance. - Keep it simple: No need for complex redirects,
assetPrefix, or other configurations.
I hope this article helps developers facing similar issues to quickly solve their deployment problems!
Encountered an issue? Feel free to share your experience or ask questions in the comments.
Found this useful? Give it a star to show your support! ⭐
Related Posts
One-Click Blog Publishing with Claude Agent Skills: From Tedious Workflows to Natural Language Interaction
Enter Your GA4 Property ID (Numbers Only)
Page view count will be displayed in article meta information (after date, reading time, and author)
SEO Optimization in the AI Era - From Search Engines to AI Agents
AI is changing how information is accessed. This article introduces how to optimize blogs for AI agents (ChatGPT, Perplexity), including Schema.org structured data, FAQ markup, and robots.txt configuration.