DEV Community

Cover image for Deploying Next.js 16 to Cloudflare Workers Static Assets (Not Pages) — A Real-World Setup
Scofield
Scofield

Posted on

Deploying Next.js 16 to Cloudflare Workers Static Assets (Not Pages) — A Real-World Setup

If you've shipped a Next.js app to Cloudflare in the last few years, you probably reached for Cloudflare Pages. It's the default answer in every tutorial, the Vercel-shaped slot in the Cloudflare ecosystem.

But in late 2025, Cloudflare quietly pushed Workers Static Assets as a first-class deployment target — and for a static-exported Next.js app, it's now the better choice. Smaller config, faster cold starts, and one fewer abstraction layer between your out/ folder and the edge.

This post walks through the exact setup I used to ship a real production Next.js 16 + React 19 site to Workers Static Assets in under 90 seconds — including the three wrangler.toml fields most tutorials skip.

Pages vs Workers Static Assets — Quick Context

Pages was built around a Git-integration-first workflow with a built-in CI runner. Workers Static Assets ships your built artifact directly to the Workers runtime — no separate Pages dashboard, no Pages-specific build configuration, just wrangler deploy.

For a fully static Next.js export (no SSR, no API routes), Workers Static Assets is lighter:

  • One config file (wrangler.toml), not two systems
  • Same edge network, same free tier (100K requests/day)
  • Cleaner integration if you later need a Workers function alongside the assets

If your app uses SSR, ISR, or middleware, Pages or the newer @opennextjs/cloudflare adapter is still the right call. This post is for the static-export case.

TL;DR — The Full Config

Here's everything you need. Two files.

next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  images: { unoptimized: true },
  trailingSlash: false,
  reactStrictMode: true,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

wrangler.toml

#:schema node_modules/wrangler/config-schema.json
name = "my-app"
compatibility_date = "2026-05-08"
workers_dev = false
preview_urls = false

[assets]
directory = "./out"
not_found_handling = "404-page"
Enter fullscreen mode Exit fullscreen mode

That's it. Run pnpm build then npx wrangler deploy, point your DNS at the Worker, and you're live. The rest of this post is why each line is there.

next.config.ts — Three Switches That Matter

output: "export"

This is the switch that turns Next.js into a static site generator. Every route becomes a pre-rendered HTML file in ./out/, with no Node runtime required. Workers Static Assets serves them directly.

What you lose: app/api routes, ISR, next/image runtime optimization, and middleware. What you gain: a build artifact that runs on any static host on the planet.

images: { unoptimized: true }

next/image needs a Node runtime to resize and reformat on the fly. Static export doesn't have one. Setting unoptimized: true tells the component to serve your image as-is.

The trade-off: you give up automatic AVIF/WebP conversion and per-viewport sizing. The fix: pre-process your images with sharp at build time, or use Cloudflare Images (the paid product) if you really need it. For most static sites, just shipping pre-optimized PNG/WebP from /public is fine.

trailingSlash: false

This one is a canonical hygiene issue, not a feature. With trailingSlash: false, every internal link, sitemap entry, and Open Graph URL ends without a /. Mix and match across pages and you'll get duplicate-content warnings in Google Search Console within a week.

Pick one, set it here, and never think about it again.

wrangler.toml — Three Fields Most Tutorials Skip

This is where the "I read the docs and it still didn't work" hours live.

not_found_handling = "404-page"

By default, Workers Static Assets returns a generic JSON 404 when a path isn't found. That's fine for an API, terrible for an app where you carefully wrote a not-found.tsx page in Next.js.

Setting not_found_handling = "404-page" tells the runtime: "if the path doesn't exist, look for ./out/404.html and serve that instead." Next.js generates that file automatically from your not-found.tsx.

workers_dev = false

This is the one I want every SEO-conscious developer to know.

By default, your Worker is reachable at both your-app.com (your custom domain) and my-app.workers.dev (Cloudflare's free subdomain). Google can and does index both. Result: duplicate content, split PageRank, and a workers.dev URL outranking your real domain in long-tail searches.

Setting workers_dev = false kills the .workers.dev URL entirely. Your app is only reachable on your custom domain. One canonical URL, no leakage.

preview_urls = false

Same idea, different surface. Cloudflare gives every deployment a preview URL like <hash>-<worker>.preview-domain.workers.dev. Useful for QA. Also indexable by Google if you're unlucky.

Set this to false in production. Use a separate [env.preview] block in wrangler.toml if you want previews on staging without leaking them on prod.

Deploy in One Line

pnpm build && npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

First deploy walks you through OAuth, picks an account, and uploads ./out/. Subsequent deploys take ~30 seconds for a typical app.

Point your DNS at the Worker via the Cloudflare dashboard (Workers & Pages → your Worker → Custom Domains), and you're live on your own domain.

Common Gotchas

A short list of things that cost me time, so they don't cost you any:

  • Sitemap absolute URLs: Static export means every URL in your sitemap must be absolute. Use a SITE_URL env var in next.config.ts and reference it in app/sitemap.ts.
  • No app/api: If you have API routes, either move them to a separate Worker, use a third-party form/auth backend, or switch to Pages with the Functions adapter.
  • next/font works: Despite being a "runtime" feature in name, next/font resolves at build time and ships static CSS/woff2 with your export. No config needed.
  • Cache headers: Workers Static Assets sets sensible defaults (1 year for hashed assets, no-cache for HTML). Override with a Worker handler if you need custom logic.
  • pnpm vs npm: Wrangler doesn't care. Use whatever you build with.

What I Built With This Setup

I used the exact config above to ship Toon Tone — a daily cartoon color memory game running on Next.js 16, React 19, and Tailwind 4. First production deploy took 87 seconds. Ongoing cost: $0 on Cloudflare's free tier.

If you want to see the result live, the daily game is at toontone.win and the HSB color-match page is the one that uses every feature mentioned above — static export, edge caching, custom 404, the lot.

Happy to answer setup questions in the comments — especially around the workers_dev = false SEO gotcha, since it's not in the official docs and bit me on a previous project before this one.

Top comments (0)