Shrinking a Next.js Cloudflare Worker from 3.6 MiB to 2 MiB
How I cut the gzipped Cloudflare Worker bundle of a Next.js blog in half by excluding build-time-only packages, removing unused WASM, and correctly marking static pages.
Deploying a Next.js blog to Cloudflare Workers with @opennextjs/cloudflare and hitting this error is a rite of passage:
✘ [ERROR] Your Worker failed validation because it exceeded size limits.- Your Worker exceeded the size limit of 3 MiB. Please upgrade to a paid planto deploy Workers up to 10 MiB.Here are the 5 largest dependencies included in your script:- .open-next/server-functions/default/handler.mjs - 14873.89 KiB- node_modules/next/dist/compiled/@vercel/og/resvg.wasm - 1346.05 KiB- node_modules/next/dist/compiled/@vercel/og/index.edge.js - 796.53 KiB- .open-next/middleware/handler.mjs - 125.81 KiB- node_modules/next/dist/compiled/@vercel/og/yoga.wasm - 86.58 KiB
The handler.mjs is 14.5 MiB uncompressed, gzipping to 3.6 MiB — just over the free plan's 3 MiB limit. Three fixes later, the bundle is 2.0 MiB gzipped, deployed, and running fine.
Total Upload: 8495.58 KiB / gzip: 1996.50 KiBWorker Startup Time: 28 msUploaded hoangtaiki (18.91 sec)Deployed hoangtaiki triggers (5.79 sec)
Here is exactly what I did.
#Why is a blog Worker so large?
The site is a standard Next.js App Router blog: all post pages are statically generated via generateStaticParams, served through OpenNext's staticAssetsIncrementalCache. The Worker should just be a thin router that serves pre-rendered HTML.
Instead, the Worker bundle contained:
codehike+shiki— syntax highlighting with full TextMate language grammarsnext-mdx-remote— MDX compilation pipeline withunified,acorn,vfile@vercel/og— OG image generation including two WASM binaries, pulled in by Next.js even though the site has zero dynamic OG imagesgray-matter— frontmatter parsing, needed only at build time
All of these are build-time tools. At request time, the Worker only needs to serve the already-compiled HTML. But the webpack bundler doesn't know that — it includes everything that is imported anywhere in the codebase.
#Fix 1 — Tell the bundler which packages are build-time only
Next.js has a serverExternalPackages config that opts packages out of automatic bundling. When OpenNext compiles the Cloudflare Worker, it respects this list and excludes those packages from the Worker script.
// next.config.tsconst nextConfig: NextConfig = {serverExternalPackages: ['next-mdx-remote','codehike','shiki','@shikijs/core','@shikijs/engine-oniguruma','@shikijs/vscode-textmate','rehype-slug','remark-gfm','gray-matter','unified','vfile','acorn','esprima',],// ...}
This is safe because all blog pages are statically generated. None of these packages are ever called by the Worker at request time — the Worker only serves the pre-rendered HTML that was produced at build time.
Note
If any of these packages were used in a dynamic API route or a server component without generateStaticParams, removing them from the bundle would cause a runtime error. Audit your imports carefully.
As a bonus, this also silences two direct-eval warnings from acorn (used by next-mdx-remote for MDX compilation) that showed up during the Cloudflare build:
▲ [WARNING] Using direct eval with a bundler is not recommended [direct-eval].open-next/server-functions/default/handler.mjs:241:8▲ [WARNING] Using direct eval with a bundler is not recommended [direct-eval].open-next/server-functions/default/handler.mjs:20601:8
#Fix 2 — Remove the unused @vercel/og WASM
The three @vercel/og files listed in the error account for roughly 2.2 MiB uncompressed:
| File | Size |
|---|---|
resvg.wasm | 1346 KB |
index.edge.js | 797 KB |
yoga.wasm | 87 KB |
These are for Next.js's ImageResponse API used to generate dynamic OG images at the edge. The site has no ImageResponse usage and no opengraph-image.tsx routes — yet Next.js includes these files anyway as part of its App Router edge bundle.
The webpack fix is one alias:
// next.config.tswebpack: (config) => {config.resolve.alias = {...config.resolve.alias,'next/og': false,}return config},
Aliasing a module to false tells webpack to treat it as an empty module. Since nothing in the codebase imports next/og, this has no runtime effect — it just stops the WASM files from being pulled into the bundle.
#Fix 3 — Mark all static pages explicitly
The blog post page (/blog/[slug]) had generateStaticParams but no explicit static export declarations. Next.js still includes the full rendering pipeline in the Worker bundle for pages without these signals.
// src/app/blog/[slug]/page.tsx — beforeexport async function generateStaticParams() { ... }
// src/app/blog/[slug]/page.tsx — afterexport const dynamic = 'force-static'export const revalidate = falseexport const dynamicParams = false // unknown slugs → 404, not a Worker render attemptexport async function generateStaticParams() { ... }
Same treatment for /blog/tags/[tag]/page.tsx which had dynamicParams = false but was missing dynamic and revalidate.
These three exports together tell Next.js — and more importantly, the OpenNext bundler — that this page never needs to run inside the Worker.
#Fix 4 — Silence the Turbopack warning (Next.js 16+)
Next.js 16 enables Turbopack by default for next dev. Adding a webpack config without a corresponding turbopack config triggers an error on startup:
⨯ ERROR: This build is using Turbopack, with a `webpack` config and no `turbopack` config.This may be a mistake.
The webpack function only runs during next build, not during Turbopack-powered dev. Setting an empty turbopack: {} acknowledges this split:
// next.config.tsconst nextConfig: NextConfig = {turbopack: {}, // dev uses Turbopack — no custom config neededwebpack: (config) => {// only runs during `next build`, not `next dev`config.resolve.alias = {...config.resolve.alias,'next/og': false,}return config},}
#Result
| Metric | Before | After |
|---|---|---|
| Total upload | 17,300 KiB | 8,495 KiB |
| Gzipped bundle | 3,627 KiB | 1,996 KiB |
| Free plan limit | 3,072 KiB | 3,072 KiB |
| Worker startup | — | 28 ms |
The gzipped bundle dropped from 3.6 MiB to 2.0 MiB — a 45% reduction, staying comfortably within the free plan's 3 MiB limit without any code changes to the blog content or rendering logic.
#The full next.config.ts
import type { NextConfig } from 'next'import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare'const nextConfig: NextConfig = {pageExtensions: ['js', 'jsx', 'ts', 'tsx'],serverExternalPackages: ['next-mdx-remote','codehike','shiki','@shikijs/core','@shikijs/engine-oniguruma','@shikijs/vscode-textmate','rehype-slug','remark-gfm','gray-matter','unified','vfile','acorn','esprima',],turbopack: {},webpack: (config) => {config.resolve.alias = {...config.resolve.alias,'next/og': false,}return config},images: {remotePatterns: [{ protocol: 'https', hostname: 'example.com', pathname: '/**' },],},}initOpenNextCloudflareForDev()export default nextConfig
#Key takeaway
The root problem is that webpack doesn't know which imports are build-time-only. For an SSG blog, the Worker needs almost nothing — just routing logic and static file serving. Every MDX compilation library, syntax highlighter, and frontmatter parser bundled into the Worker is wasted bytes.
serverExternalPackages is the escape hatch. Use it for any package that is only ever called during next build, never at request time. Pair it with dynamic = 'force-static' on every static page, and the Worker stays lean.