Skip to content

fix(templates): reduce with-cloudflare-d1 Worker bundle for free-tier 3 MiB limit#17397

Open
SybyAbraham wants to merge 2 commits into
payloadcms:mainfrom
SybyAbraham:fix/cloudflare-d1-template-free-tier-bundle
Open

fix(templates): reduce with-cloudflare-d1 Worker bundle for free-tier 3 MiB limit#17397
SybyAbraham wants to merge 2 commits into
payloadcms:mainfrom
SybyAbraham:fix/cloudflare-d1-template-free-tier-bundle

Conversation

@SybyAbraham

@SybyAbraham SybyAbraham commented Jul 18, 2026

Copy link
Copy Markdown

Problem

The with-cloudflare-d1 template's Worker bundle is 3,364 KiB gzip (measured via wrangler deploy --dry-run total upload) — 292 KiB over the Cloudflare Workers free-tier 3 MiB (3,072 KiB) compressed size limit. This causes the template to fail to deploy on the Cloudflare Workers free tier — wrangler deploy rejects the upload for exceeding the 3 MiB compressed-size limit.

Root causes

1. @vercel/og (~2.9 MiB uncompressed, ~744 KiB gzip delta)

Payload's REST handler (@payloadcms/next/dist/routes/rest/index.js) statically imports its OG image endpoint (./og/index.js), which imports next/og.js (ImageResponse). That module re-exports from next/dist/server/og/image-response.js, which contains a NEXT_RUNTIME ternary that dynamically imports either @vercel/og/index.edge.js (edge) or @vercel/og/index.node.js (node).

Next.js's NFT traces the node entry (index.node.js) for API route bundles that include the REST handler. OpenNext's patchVercelOgLibrary (source) detects @vercel/og usage by scanning those .nft.json traces for @vercel/og/index.node.js. When found (useOg === true) it copies the edge variant (index.edge.js) into the output, patches the fallback font fetch to a .bin module import (patchVercelOgFallbackFont), and rewrites the node imports to edge imports in the route files (patchVercelOgImport). The full set of @vercel/og assets — both JS variants (~721 KiB each), resvg.wasm (1.4 MiB), yoga.wasm (88 KiB), and the fallback TTF (28 KiB), ~2.9 MiB uncompressed across six files — ends up in the .open-next output through this copy step plus Next.js's own NFT standalone tracing, and Wrangler bundles the reachable subset into the Worker.

When useOg === false (no @vercel/og traced), a separate change (#1221, shipped in @opennextjs/cloudflare@1.19.4) aliases the edge entry — next/dist/compiled/@vercel/og/index.edge.js, which Next.js's externalImport helper emits as a dynamic import even in apps that never use next/og — to a throw.js shim instead of leaving it external, so Wrangler no longer drags the library and resvg.wasm into the bundle.

@vercel/og is functional on Workers via the patchVercelOgLibrary patches above, but the ~744 KiB gzip cost is significant for a template targeting the free-tier 3 MiB limit.

The import is unconditional: the OG image endpoint is registered regardless of admin.meta.defaultOGImageType, so @vercel/og enters the bundle even when the feature is configured 'off'. With 'off', the endpoint returns a 400 response before reaching @vercel/og — meaning the ~2.9 MiB of @vercel/og code ships in the bundle but is never executed.

2. drizzle-kit/api (7.3 MiB — safety net, not a measured contributor)

drizzle-kit/api is transitively imported by @payloadcms/drizzle/sqliterequireDrizzleKitrequire('drizzle-kit/api'). It is only used by the Payload CLI for migrations — never at Worker runtime.

As of Payload 3.86.0, withPayload()'s built-in externalization keeps drizzle-kit/api out of the bundle in normal webpack builds, so the baseline numbers measured here don't include it. The stub in this PR exists because that externalization has regressed before (#16470) and isn't guaranteed to survive OpenNext's esbuild repackaging — it's insurance, not the source of the savings. The proper fix is to lazy-load drizzle-kit inside the function body (#17009, still open).

Solution

OG image endpoint stub

This template disables OG image generation (admin.meta.defaultOGImageType: 'off') and replaces the endpoint module with a stub that mirrors that 400 response — eliminating ~744 KiB gzip from the bundle:

  • stubs/payload-og-endpoint.js — exports runtime, contentType, and generateOGImage (returns Response.json({ error: 'Open Graph images are disabled' }, { status: 400 })), matching the real module's API surface and 'off' response exactly
  • next.config.ts — resolves the real endpoint path via createRequire(import.meta.url) and aliases it to the stub in both webpack resolve.alias and turbopack resolveAlias
  • src/payload.config.ts — sets admin.meta.defaultOGImageType: 'off' for runtime consistency

With the stub in place, the import chain routes/rest/index.js./og/index.jsnext/og.js@vercel/og is severed at the source. NFT no longer traces @vercel/og, patchVercelOgLibrary reports useOg === false, and no @vercel/og files appear in the .open-next output.

To re-enable OG image generation, remove the alias from next.config.ts and remove defaultOGImageType from src/payload.config.ts. The feature works on Workers via OpenNext's compatibility patches, but the bundle will exceed the free-tier 3 MiB limit — a Paid Workers plan is required.

drizzle-kit/api stub

  • stubs/drizzle-kit-api.js — throws on call, aliased via both turbopack and webpack

README documentation

The README documents the OG image trade-off, explains the stub, and provides instructions for re-enabling the feature (with the caveat about the free-tier size limit).

Measured results

All measurements on the template with identical dependencies (Next 15.4.11, OpenNext 1.20.1, Payload 3.86.0), using wrangler deploy --dry-run total upload gzip — the metric Cloudflare actually enforces.

Metric Before (no stubs) WASM/TTF stubbing only Endpoint stub (this PR)
handler.mjs raw 9,173 KiB 9,168 KiB 8,605 KiB
handler.mjs gzip 2,377 KiB 2,374 KiB 2,217 KiB
wrangler total raw 14,903 KiB 13,438 KiB 12,688 KiB
wrangler total gzip 3,364 KiB 2,798 KiB 2,620 KiB
@vercel/og files in .open-next 6 6 (WASM/fonts zeroed) 0
NFT traces with @vercel/og 2 2 0
vs 3 MiB (3,072 KiB) −292 KiB (over) +274 KiB +452 KiB
Live deploy

The "WASM/TTF stubbing only" column shows an intermediate approach (truncating @vercel/og asset files post-build) that was considered but not adopted: it zeroes the WASM and font files but leaves the ~721 KiB of @vercel/og JS in the bundle. The endpoint stub removes the entire dependency chain.

Runtime verification (live deploy to Cloudflare Workers, fresh D1 database with migrations applied):

  • GET /200 (homepage renders)
  • GET /api/og?title=Test400 + {"error":"Open Graph images are disabled"} (byte-for-byte parity with real Payload 'off' behavior)
  • GET /admin200 (admin panel loads)

What this PR does not do

  • Does not modify OpenNext's @vercel/og handling. The NFT-trace detection and edge-copy/patch logic live in patchVercelOgLibrary (longstanding adapter code, predating #1221); #1221 is the separate useOg === false shim that aliases the unused edge-entry dynamic import to throw.js. This PR removes the import that causes the NFT trace, so patchVercelOgLibrary reports useOg === false and its copy/patch steps don't run — and Add icons & colors to fields for easier navigation #1221's shim then keeps the dangling edge dynamic import from pulling @vercel/og back in.
  • Does not fix the unconditional import in @payloadcms/next — the REST handler statically imports the OG endpoint regardless of defaultOGImageType. A proper fix would move the import behind a runtime check or offer an explicit withPayload() option (e.g. { excludeOGImageEndpoint: true }). That's a Payload core change affecting every deploy target, not just Cloudflare — tracked as a follow-up.
  • Does not remove the drizzle-kit stub — it remains as a safety net against externalization regressions.

Related

…ages

The with-cloudflare-d1 template on main references several APIs that
have not been published to npm, making it impossible to build when
installed standalone via create-payload-app:

1. payload build (build script) - not available in any published
   payload CLI version. Changed to next build.

2. Config.storage (payload.config.ts) - unreleased API not in any
   published @payloadcms/* package. Changed to plugins, which has
   always existed. (A codemod migrate-storage-adapters-to-config
   exists upstream for this transition.)

3. generatePayloadViewport (layout.tsx) - unreleased export not in any
   published @payloadcms/next version. Changed to metadata, which has
   always been exported. (A codemod migrate-next-generate-viewport-export
   exists upstream.)

4. Missing CSS/SCSS type declarations - the monorepo provides these via
   tools/assets.d.ts (wired through tsconfig.base.json), but standalone
   templates lack this file. Added src/assets.d.ts with the same
   declarations.

5. isCLI detection - switching from payload build to next build means
   payload/bin.js is no longer in process.argv, so isCLI was always
   false during build. This caused the config to use
   getCloudflareContext() (production path requiring CLOUDFLARE_API_TOKEN)
   instead of getCloudflareContextFromWrangler() (local wrangler path).
   Extended isCLI to also detect next/dist/bin/next.

6. realpath crash - the realpath() helper returned undefined when
   fs.existsSync() is false (the case for every process.argv entry in
   the Workers runtime), and the subsequent .endsWith() call on
   undefined threw. Wrapped in try/catch with a null guard.

7. remoteBindings during build - getCloudflareContextFromWrangler() was
   called with remoteBindings: isProduction, which during next build
   (NODE_ENV=production) tried to connect to real Cloudflare D1/R2
   resources. Since this function is only called for CLI/build/dev (never
   production runtime), changed to remoteBindings: false. Also changed
   the D1 binding in wrangler.jsonc from remote: true to remote: false
   so the template builds out of the box without pre-configured
   Cloudflare resources.

8. engines.node - lowered from >=24.15.0 to >=22.0.0 for compatibility
   with Cloudflare Workers Builds (Node 22.16.0).

Bumped @payloadcms/* dependencies from 3.82.1 to 3.86.0 (latest
published). Verified with a clean next build: TypeScript compilation
passes, all 7 routes generate successfully.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​payloadcms/​storage-r2@​3.82.1 ⏵ 3.86.084 +710069 +1100 +1100
Updated@​payloadcms/​db-d1-sqlite@​3.82.1 ⏵ 3.86.082 +710080100 +1100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant