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.

Harry Tran
6 min read · 1135 words

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 plan
to 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 KiB
Worker Startup Time: 28 ms
Uploaded 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 grammars
  • next-mdx-remote — MDX compilation pipeline with unified, acorn, vfile
  • @vercel/og — OG image generation including two WASM binaries, pulled in by Next.js even though the site has zero dynamic OG images
  • gray-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.

typescript
// next.config.ts
const 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:

FileSize
resvg.wasm1346 KB
index.edge.js797 KB
yoga.wasm87 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:

typescript
// next.config.ts
webpack: (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.

typescript
// src/app/blog/[slug]/page.tsx — before
export async function generateStaticParams() { ... }
typescript
// src/app/blog/[slug]/page.tsx — after
export const dynamic = 'force-static'
export const revalidate = false
export const dynamicParams = false // unknown slugs → 404, not a Worker render attempt
export 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:

typescript
// next.config.ts
const nextConfig: NextConfig = {
turbopack: {}, // dev uses Turbopack — no custom config needed
webpack: (config) => {
// only runs during `next build`, not `next dev`
config.resolve.alias = {
...config.resolve.alias,
'next/og': false,
}
return config
},
}

#Result

MetricBeforeAfter
Total upload17,300 KiB8,495 KiB
Gzipped bundle3,627 KiB1,996 KiB
Free plan limit3,072 KiB3,072 KiB
Worker startup28 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

typescript
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.