Cloudflare Pages to Workers Static Assets Migration

In late 2025, Cloudflare officially placed Pages into maintenance mode, shifting all future investments and new feature developments exclusively to the Workers platform. While existing Pages projects will continue to run without interruption, new capabilities—such as Cron Triggers, enhanced Observability, and direct Durable Objects bindings—are available only on Workers.
This guide walks you through the complete process of migrating static sites and full-stack applications from Cloudflare Pages to Workers Static Assets with zero downtime. Since this blog (effidev.dev) was originally configured as a Pages project with pages_build_output_dir: "dist", this guide is written from the practical perspective of executing this exact migration.
Key Takeaways
- Pages is in Maintenance Mode: New features and optimization investments have stopped. Workers is now the official recommended approach for serving static assets and dynamic logic in a single deployment unit.
- Free & Unlimited Static Asset Requests: Static files (HTML, CSS, images) served via Workers Static Assets do not count as Worker invocations, making them 100% free.
- 100,000 File Limit: Supports up to 100,000 files per Worker version and up to 25 MiB per individual file on Paid plans (Wrangler 4.34.0+).
- One-Line
wrangler.jsoncChange: Replacingpages_build_output_dirwithassets.directorycompletes the primary transition.- Framework Adapter Integration: SSR frameworks such as Astro, Next.js, and SvelteKit deploy both static assets and server-side logic into a single Worker simply by configuring Cloudflare Workers adapters.
1. Pages vs. Workers Static Assets: Key Differences
Here are the primary differences outlined in the official Cloudflare migration guide.
| Comparison Item | Cloudflare Pages | Workers Static Assets |
|---|---|---|
| Platform Status | Maintenance mode (No new features) | Active development + Heavy investment |
| Static Asset Serving | Built-in | Same support via assets.directory config |
| Server-Side Logic | _worker.js (Limited) |
Full Worker script (Unlimited bindings) |
| Durable Objects | Direct binding not supported | Direct binding supported |
| Cron Triggers | Not supported | Supported |
| Observability | Basic logs only | Workers Logs, Tail Workers, Logpush |
| File Limit | 20,000 files | 100,000 files (Paid plans) |
| Deploy Command | wrangler pages deploy |
wrangler deploy |
| Static Asset Pricing | Free | Free (Not a Worker invocation) |
The game-changer is direct binding support for Durable Objects and Cron Triggers. On Pages, you had to isolate these into separate Workers. With Workers Static Assets, you can configure static asset serving, Durable Objects, Cron Triggers, D1, and R2 all inside a single wrangler.jsonc.
2. Pre-Migration Checklist
Review these prerequisites before migrating. Failing to verify any item can result in downtime after deployment.
| Checklist Item | Verification Method |
|---|---|
| Wrangler version ≥ 4.34.0 | npx wrangler --version |
| Build output directory | ls dist/ or framework-specific build output path |
_worker.js usage |
If used in Pages advanced mode, convert to Worker script |
| Custom domain DNS config | Reconfigure Pages project CNAME record to Workers routes |
_headers / _redirects files |
Not supported in Workers → Migrate logic into Worker script |
| Environment variables / Secrets | Pages dashboard → wrangler.jsonc [vars] or wrangler secret put |
# Check current Wrangler version
npx wrangler --version
# -> Must be 4.34.0 or higher to support the 100,000 file limit
# Check current Pages configuration
cat wrangler.jsonc
3. Core Migration: Converting wrangler.jsonc
Here is the minimal diff required to transition your configuration from Pages to Workers Static Assets.
// wrangler.jsonc
{
"name": "effidev",
"compatibility_date": "2026-08-04",
- "pages_build_output_dir": "dist"
+ "main": "src/worker.ts",
+ "assets": {
+ "directory": "./dist",
+ "binding": "ASSETS"
+ }
}
With this single change, deployment shifts from wrangler pages deploy dist to wrangler deploy. Static assets are automatically uploaded from the ./dist directory, while your Worker script (src/worker.ts) handles dynamic server logic.
Worker Script (src/worker.ts)
Here is the simplest Worker entry point for serving a static-only site.
// src/worker.ts
// Minimal Worker serving static assets only—sufficient if you have no dynamic logic
export default {
async fetch(
request: Request,
env: { ASSETS: Fetcher },
): Promise<Response> {
// Forward all incoming requests to the static asset binding
return env.ASSETS.fetch(request);
},
};
Watch Out for Pitfalls: If you set assets.run_worker_first to true, every request passes through the Worker script, incurring Worker invocation charges. For static sites, keep the default value (false)—static asset requests bypass the Worker and are served directly for free.
4. Migrating _headers and _redirects to Worker Script
The _headers and _redirects files used in Cloudflare Pages do not work in Workers. You must handle header modification and HTTP redirects directly within your Worker script.
// src/worker.ts — Integrated headers and redirects version
export default {
async fetch(
request: Request,
env: { ASSETS: Fetcher },
): Promise<Response> {
const url = new URL(request.url);
// Replace _redirects: 301 redirect old URL patterns to new paths
const redirects: Record<string, string> = {
'/old-blog/': '/ko/blog/',
'/legacy-page/': '/ko/',
};
const redirect = redirects[url.pathname];
if (redirect) {
return Response.redirect(new URL(redirect, request.url).toString(), 301);
}
// Fetch static asset
const response = await env.ASSETS.fetch(request);
// Replace _headers: Customize response headers
const headers = new Headers(response.headers);
// Add security headers
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Cache control: Images for 1 year, HTML for 10 minutes
if (url.pathname.match(/\.(webp|png|jpg|svg|woff2)$/)) {
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
} else if (url.pathname.endsWith('.html') || url.pathname.endsWith('/')) {
headers.set('Cache-Control', 'public, max-age=600, s-maxage=3600');
}
return new Response(response.body, {
status: response.status,
headers,
});
},
};
In this Worker, you must set assets.run_worker_first to true because custom header modification requires executing Worker logic first. Although this incurs Worker invocation fees, the Workers Paid plan ($5/month) includes 10 million requests per month, which is more than enough for most static sites.
// wrangler.jsonc — Enable run_worker_first
{
"name": "effidev",
"compatibility_date": "2026-08-04",
"main": "src/worker.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": true
}
}
5. Framework Adapter Setup (Astro, Next.js, SvelteKit)
Static sites using Astro—like this blog—do not require a framework adapter; simply point assets.directory to the build output. However, if your framework relies on server-side rendering (SSR), you need a Cloudflare Workers adapter.
Astro (Static Build — Simplest)
# astro.config.mjs — Static build without adapter
# If output is 'static' (default), HTML files are generated directly into dist/
npm run build
# -> Point assets.directory in wrangler.jsonc to the dist/ directory
Astro (SSR Mode)
npx astro add cloudflare
// astro.config.mjs — Cloudflare Workers SSR adapter
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare({
platformProxy: { enabled: true },
}),
});
Next.js (opennextjs-cloudflare)
npx create-next-app@latest --typescript
npm install @opennextjs/cloudflare
The "use cache" directive and RSC streaming covered in our Next.js 16 "use cache" RSC Streaming Guide work seamlessly through the Workers adapter.
6. Full-Stack Integration: Durable Objects, Cron, D1, and R2
This was the biggest limitation in Cloudflare Pages. Transitioning to Workers Static Assets lets you manage static assets alongside backend bindings inside a single wrangler.jsonc.
// wrangler.jsonc — Full-stack integrated configuration
{
"name": "effidev",
"compatibility_date": "2026-08-04",
"main": "src/worker.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": true
},
// D1 Database
"d1_databases": [
{
"binding": "DB",
"database_name": "effidev-analytics",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
// R2 Object Storage
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "effidev-media"
}
],
// Cron Triggers (Impossible in Pages!)
"triggers": {
"crons": ["0 */6 * * *"]
}
}
R2 bucket bindings—previously detailed in our AWS S3 to Cloudflare R2 Migration Guide—can now be bound directly to your main Worker without deploying a separate Worker.
// src/worker.ts — Integrated Cron + D1 + R2 handler
interface Env {
ASSETS: Fetcher;
DB: D1Database;
BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// API endpoints are handled directly by the Worker
if (url.pathname.startsWith('/api/')) {
return handleApi(request, env);
}
// All other requests serve static assets
return env.ASSETS.fetch(request);
},
// Cron trigger running every 6 hours (Required a separate Worker on Pages!)
async scheduled(
_controller: ScheduledController,
env: Env,
): Promise<void> {
// Aggregate analytics data from D1
const result = await env.DB.prepare(
'SELECT COUNT(*) as total FROM page_views WHERE date = date("now")',
).first();
// Save daily report to R2
await env.BUCKET.put(
`reports/${new Date().toISOString().slice(0, 10)}.json`,
JSON.stringify(result),
);
},
};
async function handleApi(request: Request, env: Env): Promise<Response> {
// Implement API logic
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' },
});
}
7. Deployment Command Changes and DNS Switchover
Deployment Command
# Before (Pages)
npx wrangler pages deploy dist --project-name=effidev
# After (Workers Static Assets)
npx wrangler deploy
Workers deployments automatically use the name field specified in wrangler.jsonc as the project name.
DNS Switchover (Custom Domains)
Pages custom domains use CNAME records pointing to <project>.pages.dev. When switching to Workers, you must bind your custom domain via Workers → Routes in the Cloudflare dashboard.
| Step | Action |
|---|---|
| 1. Deploy Worker | Run wrangler deploy to publish Worker + static assets |
| 2. Connect Custom Domain | Cloudflare Dashboard → Workers → Select Worker → Settings → Domains & Routes → Add Custom Domain |
| 3. Detach Pages Custom Domain | Remove custom domain from Pages project to prevent DNS conflicts |
| 4. Verification | `curl -sI https://yourdomain.com/ |
Watch Out for Pitfalls: Binding the exact same custom domain to both Pages and Workers simultaneously causes routing conflicts. Always remove the domain from your Pages project before adding it as a Workers custom domain route.
8. Excluding Unnecessary Files with .assetsignore
Use .assetsignore to prevent unwanted files in your build output directory from being uploaded to production. It follows the exact same syntax as .gitignore.
# dist/.assetsignore
_worker.js # Legacy Pages advanced mode file—must not be uploaded as a static asset
*.map # Do not upload source maps to production
.DS_Store
Frequently Asked Questions
Do I need to migrate my existing Pages projects immediately?
No. Existing Cloudflare Pages projects will continue to operate normally. However, migration is strongly recommended if you need Cron Triggers, direct Durable Objects bindings, or Workers Logs, or if your project exceeds 20,000 total files.
Will I incur Worker invocation charges for a purely static site?
If you keep assets.run_worker_first set to its default (false), static asset requests bypass the Worker script entirely, resulting in zero invocation charges. Even if you enable true for custom header logic or API routing, the Workers Paid plan ($5/month) includes 10 million requests per month, which covers most static websites without additional fees.
What happens to my _headers and _redirects files?
They are not evaluated by Workers Static Assets. You must handle custom response headers and HTTP redirects inside your Worker script (see §4). Managing these rules in code makes them much easier to version-control and test.
How do I migrate existing Pages Functions (/functions/)?
Consolidate each Pages Function file into the routing logic of your central Worker script. In Workers, a single entry point (main) routes requests based on URL path patterns. Using a lightweight router framework like Hono allows you to maintain a file-based routing architecture similar to Pages Functions.