I recently shipped a side project, TikTok Story Viewer. You paste a public TikTok username and watch that account's Stories without logging in. It's a Next.js 15 App Router site: ~200 statically generated pages in 13 languages, two API routes, and a media proxy that streams images and videos from TikTok's CDN.
I deployed it to Cloudflare Workers with the OpenNext Cloudflare adapter. Everything worked in next dev. Then three problems appeared that I had never seen locally. Here's what they were, how I tracked them down, and what I changed.
1. Every thumbnail was broken: the query string got decoded twice
Symptom
In production every avatar and video cover was a broken image. My media proxy endpoint returned 502 for all of them, while opening the same CDN URLs directly in the browser worked fine.
The proxy itself is simple. The client passes the CDN URL as a query parameter, and the Worker fetches it and streams it back:
// client
const proxied = (url: string) => `/api/media/?url=${encodeURIComponent(url)}`;
// app/api/media/route.ts
const url = req.nextUrl.searchParams.get('url');
The wrong guess
My first theory was that TikTok's CDN blocks requests coming from Cloudflare IPs. It's a plausible story and I almost built a fallback around it. Then I logged what the Worker actually received and compared it with what the client sent:
sent: https://p19-…webp?dr=9640&refresh_token=…&x-expires=…&x-signature=…
received: https://p19-…webp?dr=9640
Everything after the first & was gone, including x-expires and x-signature. TikTok's image URLs are signed, so an unsigned URL gets a 403. Not IP blocking.
Why
encodeURIComponent turns the inner & into %26. Somewhere between the Worker receiving the request and Next.js parsing searchParams in this setup, the query string was decoded one extra time. %26 became a real &, and the nested URL got split into separate parameters.
The local Workers runtime (wrangler dev on the OpenNext build) reproduced it. next dev didn't, which is why I never noticed. My earlier "it works on Workers" test had used an audio URL without a query string, so it passed.
Fix
Stop putting a URL inside a URL. I now send the media URL as base64url, which contains no &, = or %, so it survives any amount of decoding:
// lib/media-url.ts: used by both client and server
export function encodeMediaUrl(url: string): string {
let bin = '';
for (const byte of new TextEncoder().encode(url)) bin += String.fromCharCode(byte);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function decodeMediaUrl(token: string): string | null {
try {
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
} catch {
return null;
}
}
const proxied = (url: string) => `/api/media/?u=${encodeMediaUrl(url)}`;
The proxy still validates the decoded URL against a host allowlist and re-checks every redirect hop, so it can't be used as an open proxy.
Lesson: if you pass a URL through a query parameter, test it with a URL that has its own query string, and test on the real runtime.
2. My API key ended up inside the Worker bundle
Symptom
No user-visible symptom, which is what makes it dangerous. I keep a third-party API key in .env.local, read it only in server code, and never prefix it with NEXT_PUBLIC_. Standard Next.js hygiene.
Then I read the adapter's build code and found it compiles your env files into the Worker. In @opennextjs/cloudflare, a build step reads .env, .env.production, .env.local and .env.production.local and writes the values into .open-next/cloudflare/next-env.mjs, which is bundled into the Worker. That makes local .env values available at runtime, but it also means any secret in an .env* file gets uploaded as part of your Worker code.
To be clear, that isn't the browser bundle. Visitors can't see it. But it's in your deploy artifact, and anyone with access to the script or the build output can read it.
Fix
- Secrets go in
.dev.varsfor local development (Wrangler reads it automatically) and inwrangler secret putfor production..env*files are only for non-secret build settings likeNEXT_PUBLIC_SITE_URL. -
next devdoesn't read.dev.vars, so I load it with Node's built-in flag:
"dev": "node --env-file-if-exists=.dev.vars node_modules/next/dist/bin/next dev"
- A small check runs before every deploy and refuses to continue if a secret value shows up anywhere it shouldn't:
// scripts/check-no-secrets.mjs (simplified)
const secrets = readSecrets('.dev.vars'); // e.g. the API key values
for (const dir of ['.next/static', '.open-next']) {
for (const file of walk(dir)) {
const content = fs.readFileSync(file);
for (const s of secrets) if (content.includes(s.value)) fail(`${s.name} found in ${file}`);
}
}
// .env* files are compiled into the Worker, so secrets must not live there either
"deploy": "npm run cf:build && node scripts/check-no-secrets.mjs && opennextjs-cloudflare deploy"
Lesson: "server-only" isn't the end of the story. Check what your deployment adapter does with env files, and grep your build output for your secrets.
3. The build crashed on one machine: Cannot find module 'X X'
Symptom
npm run deploy worked on my machine and failed on another one:
Error: Cannot find module '/Library/sec_registry/npm-registry-hook.js /Library/sec_registry/npm-registry-hook.js'
Require stack:
- internal/preload
Next.js build worker exited with code: 1
The same path twice, joined by a space, treated as one module.
Why
That machine is managed by IT. A system shell file prepends a security hook to NODE_OPTIONS every time a shell starts:
export NODE_OPTIONS="--require /Library/sec_registry/npm-registry-hook.js${NODE_OPTIONS:+ $NODE_OPTIONS}"
Open a shell inside another shell (an editor's terminal, a nested zsh) and you get the flag twice:
--require /path/hook.js --require /path/hook.js
Node is fine with that. But Next.js parses NODE_OPTIONS and re-serializes it for its build and dev workers. When an option appears twice in the space-separated form, the values get concatenated, and the worker receives:
--require="/path/hook.js /path/hook.js"
I confirmed it in isolation by feeding both variants through Next's own helpers:
const u = require('next/dist/server/lib/utils.js');
u.formatNodeOptions(u.getParsedNodeOptionsWithoutInspect());
// single → "--require=/path/hook.js"
// doubled → "--require=\"/path/hook.js /path/hook.js\""
Fix
I couldn't, and shouldn't, edit the managed system file. Instead, every npm script that starts Next.js goes through a tiny wrapper that removes exact duplicates from NODE_OPTIONS and keeps one copy of each hook:
// scripts/run.mjs: usage: node scripts/run.mjs next build
const TAKES_VALUE = new Set(['-r', '--require', '--import', '--loader']);
function dedupeNodeOptions(value = '') {
const tokens = value.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
const units = [];
for (let i = 0; i < tokens.length; i++) {
const next = tokens[i + 1];
if (TAKES_VALUE.has(tokens[i]) && next && !next.startsWith('-')) units.push(`${tokens[i]} ${tokens[++i]}`);
else units.push(tokens[i]);
}
return [...new Set(units)].join(' ');
}
const env = { ...process.env, NODE_OPTIONS: dedupeNodeOptions(process.env.NODE_OPTIONS) };
spawn(process.argv[2], process.argv.slice(3), { stdio: 'inherit', env });
"build": "node scripts/run.mjs next build",
"cf:build": "node scripts/run.mjs opennextjs-cloudflare build"
build needs the wrapper too, because the OpenNext build runs npm run build internally.
Lesson: if a build fails only on one machine, compare echo $NODE_OPTIONS first.
Bonus: things that did work well
- Fully static pages + Workers static assets. Every page is prerendered and there's no ISR, so I used OpenNext's static-assets incremental cache. No R2 bucket, no KV for page caching. The compressed Worker is about 1.2 MB, comfortably under the free plan's limit.
-
Cron Triggers for quota alerts. An hourly scheduled handler checks the upstream API quota and emails me (via Email Routing's
send_emailbinding) at 20%, 5% and 0% remaining, deduplicated with KV. One oddity: a temporary* * * * *schedule never fired for me, while0 * * * *has run every hour since. I didn't get to the bottom of that one. - Cloudflare Web Analytics needed zero code. Enabling it in the dashboard injected the beacon automatically, including SPA navigation tracking.
If you're moving a Next.js app to Workers, my short checklist is:
- Test URL-in-URL parameters on the real runtime.
- Keep secrets out of
.env*, and grep your build output. - Check
NODE_OPTIONSwhen only one machine fails.
The site is live at tikstory.net if you want to see the result. Feedback is welcome, especially if you've run into any of these differently. 🙏
Top comments (0)