<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Airball Nick</title>
    <description>The latest articles on DEV Community by Airball Nick (@airball_nick_f1598c78e1cd).</description>
    <link>https://dev.to/airball_nick_f1598c78e1cd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4165946%2Fc587a231-6e6c-4ad0-ae2c-8ec26189af9d.png</url>
      <title>DEV Community: Airball Nick</title>
      <link>https://dev.to/airball_nick_f1598c78e1cd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/airball_nick_f1598c78e1cd"/>
    <language>en</language>
    <item>
      <title>Next.js on Cloudflare Workers: 3 bugs that only showed up in production</title>
      <dc:creator>Airball Nick</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:28:26 +0000</pubDate>
      <link>https://dev.to/airball_nick_f1598c78e1cd/nextjs-on-cloudflare-workers-3-bugs-that-only-showed-up-in-production-1p0g</link>
      <guid>https://dev.to/airball_nick_f1598c78e1cd/nextjs-on-cloudflare-workers-3-bugs-that-only-showed-up-in-production-1p0g</guid>
      <description>&lt;p&gt;I recently shipped a side project, &lt;a href="https://tikstory.net" rel="noopener noreferrer"&gt;TikTok Story Viewer&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;I deployed it to &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; with the &lt;a href="https://opennext.js.org/cloudflare" rel="noopener noreferrer"&gt;OpenNext Cloudflare adapter&lt;/a&gt;. Everything worked in &lt;code&gt;next dev&lt;/code&gt;. Then three problems appeared that I had never seen locally. Here's what they were, how I tracked them down, and what I changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Every thumbnail was broken: the query string got decoded twice
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;In production every avatar and video cover was a broken image. My media proxy endpoint returned &lt;code&gt;502&lt;/code&gt; for all of them, while opening the same CDN URLs directly in the browser worked fine.&lt;/p&gt;

&lt;p&gt;The proxy itself is simple. The client passes the CDN URL as a query parameter, and the Worker fetches it and streams it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// client&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proxied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`/api/media/?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// app/api/media/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The wrong guess
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent:     https://p19-…webp?dr=9640&amp;amp;refresh_token=…&amp;amp;x-expires=…&amp;amp;x-signature=…
received: https://p19-…webp?dr=9640
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after the first &lt;code&gt;&amp;amp;&lt;/code&gt; was gone, including &lt;code&gt;x-expires&lt;/code&gt; and &lt;code&gt;x-signature&lt;/code&gt;. TikTok's image URLs are signed, so an unsigned URL gets a &lt;code&gt;403&lt;/code&gt;. Not IP blocking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;encodeURIComponent&lt;/code&gt; turns the inner &lt;code&gt;&amp;amp;&lt;/code&gt; into &lt;code&gt;%26&lt;/code&gt;. Somewhere between the Worker receiving the request and Next.js parsing &lt;code&gt;searchParams&lt;/code&gt; in this setup, the query string was decoded one extra time. &lt;code&gt;%26&lt;/code&gt; became a real &lt;code&gt;&amp;amp;&lt;/code&gt;, and the nested URL got split into separate parameters.&lt;/p&gt;

&lt;p&gt;The local Workers runtime (&lt;code&gt;wrangler dev&lt;/code&gt; on the OpenNext build) reproduced it. &lt;code&gt;next dev&lt;/code&gt; didn't, which is why I never noticed. My earlier "it works on Workers" test had used an audio URL &lt;strong&gt;without&lt;/strong&gt; a query string, so it passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix
&lt;/h3&gt;

&lt;p&gt;Stop putting a URL inside a URL. I now send the media URL as &lt;strong&gt;base64url&lt;/strong&gt;, which contains no &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt; or &lt;code&gt;%&lt;/code&gt;, so it survives any amount of decoding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/media-url.ts: used by both client and server&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeMediaUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byte&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeMediaUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proxied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`/api/media/?u=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeMediaUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. My API key ended up inside the Worker bundle
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;No user-visible symptom, which is what makes it dangerous. I keep a third-party API key in &lt;code&gt;.env.local&lt;/code&gt;, read it only in server code, and never prefix it with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. Standard Next.js hygiene.&lt;/p&gt;

&lt;p&gt;Then I read the adapter's build code and found it compiles your env files into the Worker. In &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;, a build step reads &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.production&lt;/code&gt;, &lt;code&gt;.env.local&lt;/code&gt; and &lt;code&gt;.env.production.local&lt;/code&gt; and writes the values into &lt;code&gt;.open-next/cloudflare/next-env.mjs&lt;/code&gt;, which is bundled into the Worker. That makes local &lt;code&gt;.env&lt;/code&gt; values available at runtime, but it also means &lt;strong&gt;any secret in an &lt;code&gt;.env*&lt;/code&gt; file gets uploaded as part of your Worker code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Secrets go in &lt;strong&gt;&lt;code&gt;.dev.vars&lt;/code&gt;&lt;/strong&gt; for local development (Wrangler reads it automatically) and in &lt;strong&gt;&lt;code&gt;wrangler secret put&lt;/code&gt;&lt;/strong&gt; for production. &lt;code&gt;.env*&lt;/code&gt; files are only for non-secret build settings like &lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next dev&lt;/code&gt; doesn't read &lt;code&gt;.dev.vars&lt;/code&gt;, so I load it with Node's built-in flag:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --env-file-if-exists=.dev.vars node_modules/next/dist/bin/next dev"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;A small check runs before every deploy and refuses to continue if a secret value shows up anywhere it shouldn't:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-no-secrets.mjs (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secrets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readSecrets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.dev.vars&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. the API key values&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.next/static&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.open-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; found in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// .env* files are compiled into the Worker, so secrets must not live there either&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"deploy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run cf:build &amp;amp;&amp;amp; node scripts/check-no-secrets.mjs &amp;amp;&amp;amp; opennextjs-cloudflare deploy"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; "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.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The build crashed on one machine: &lt;code&gt;Cannot find module 'X X'&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npm run deploy&lt;/code&gt; worked on my machine and failed on another one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same path twice, joined by a space, treated as one module.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why
&lt;/h3&gt;

&lt;p&gt;That machine is managed by IT. A system shell file prepends a security hook to &lt;code&gt;NODE_OPTIONS&lt;/code&gt; &lt;strong&gt;every time a shell starts&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--require /Library/sec_registry/npm-registry-hook.js&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;:+&lt;span class="p"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$NODE_OPTIONS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a shell inside another shell (an editor's terminal, a nested &lt;code&gt;zsh&lt;/code&gt;) and you get the flag twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;hook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;hook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node is fine with that. But Next.js parses &lt;code&gt;NODE_OPTIONS&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--require="/path/hook.js /path/hook.js"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed it in isolation by feeding both variants through Next's own helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/dist/server/lib/utils.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatNodeOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParsedNodeOptionsWithoutInspect&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// single  → "--require=/path/hook.js"&lt;/span&gt;
&lt;span class="c1"&gt;// doubled → "--require=\"/path/hook.js /path/hook.js\""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fix
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;NODE_OPTIONS&lt;/code&gt; and keeps one copy of each hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/run.mjs: usage: node scripts/run.mjs next build&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TAKES_VALUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-r&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--require&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--import&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--loader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dedupeNodeOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*"|'&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*'|&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TAKES_VALUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;units&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;units&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;units&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dedupeNodeOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inherit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node scripts/run.mjs next build"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"cf:build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node scripts/run.mjs opennextjs-cloudflare build"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build&lt;/code&gt; needs the wrapper too, because the OpenNext build runs &lt;code&gt;npm run build&lt;/code&gt; internally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if a build fails only on one machine, compare &lt;code&gt;echo $NODE_OPTIONS&lt;/code&gt; first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus: things that did work well
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fully static pages + Workers static assets.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron Triggers for quota alerts.&lt;/strong&gt; An hourly scheduled handler checks the upstream API quota and emails me (via Email Routing's &lt;code&gt;send_email&lt;/code&gt; binding) at 20%, 5% and 0% remaining, deduplicated with KV. One oddity: a temporary &lt;code&gt;* * * * *&lt;/code&gt; schedule never fired for me, while &lt;code&gt;0 * * * *&lt;/code&gt; has run every hour since. I didn't get to the bottom of that one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Web Analytics&lt;/strong&gt; needed zero code. Enabling it in the dashboard injected the beacon automatically, including SPA navigation tracking.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're moving a Next.js app to Workers, my short checklist is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Test URL-in-URL parameters on the real runtime.&lt;/li&gt;
&lt;li&gt;Keep secrets out of &lt;code&gt;.env*&lt;/code&gt;, and grep your build output.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;NODE_OPTIONS&lt;/code&gt; when only one machine fails.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The site is live at &lt;a href="https://tikstory.net" rel="noopener noreferrer"&gt;tikstory.net&lt;/a&gt; if you want to see the result. Feedback is welcome, especially if you've run into any of these differently. 🙏&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Built TikStory: A No-Login TikTok Story Viewer on Next.js and Cloudflare Workers</title>
      <dc:creator>Airball Nick</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:19:52 +0000</pubDate>
      <link>https://dev.to/airball_nick_f1598c78e1cd/i-built-tikstory-a-no-login-tiktok-story-viewer-on-nextjs-and-cloudflare-workers-35bi</link>
      <guid>https://dev.to/airball_nick_f1598c78e1cd/i-built-tikstory-a-no-login-tiktok-story-viewer-on-nextjs-and-cloudflare-workers-35bi</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zw3xqjleev2qkqao0vx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zw3xqjleev2qkqao0vx.png" alt="TikStory cover" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;TikTok Stories are easy to post and easy to miss. They disappear after 24 hours, and if you watch one while logged into TikTok, the creator can see your account in the viewer list.&lt;/p&gt;

&lt;p&gt;I wanted a simpler web experience for public TikTok content: paste a username, view what is publicly available, and do it without signing into TikTok.&lt;/p&gt;

&lt;p&gt;That became &lt;strong&gt;TikStory&lt;/strong&gt;, a free browser-based TikTok Story viewer and downloader:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tikstory.net" rel="noopener noreferrer"&gt;https://tikstory.net&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What TikStory does
&lt;/h2&gt;

&lt;p&gt;TikStory lets you enter a public TikTok username or profile URL and view:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active TikTok Stories&lt;/li&gt;
&lt;li&gt;Recent public videos&lt;/li&gt;
&lt;li&gt;Public reposts&lt;/li&gt;
&lt;li&gt;Profile details such as avatar, bio, follower count, likes and video count&lt;/li&gt;
&lt;li&gt;Full-size profile pictures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also supports downloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stories as MP4 or JPG&lt;/li&gt;
&lt;li&gt;Public videos as MP4&lt;/li&gt;
&lt;li&gt;Audio from videos or Stories as MP3&lt;/li&gt;
&lt;li&gt;Profile pictures in full size&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is deliberately simple from the user side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://tikstory.net" rel="noopener noreferrer"&gt;tikstory.net&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Paste a public TikTok username&lt;/li&gt;
&lt;li&gt;Watch or download from the browser&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No TikTok login. No app install. No browser extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy line I wanted to keep clear
&lt;/h2&gt;

&lt;p&gt;I built TikStory around a few product rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public accounts only.&lt;/strong&gt; It does not bypass private accounts or TikTok privacy settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No TikTok login.&lt;/strong&gt; Users never enter TikTok credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No stored search history.&lt;/strong&gt; The app does not keep a per-user lookup history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No media storage.&lt;/strong&gt; Media is streamed through the app and not stored as a library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creator removal requests are supported.&lt;/strong&gt; A creator can request that their username be blocked from search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters. Tools like this should not pretend the social context disappears just because the content is technically public. The project is meant for low-friction viewing, research and personal archiving of public content, not harassment, reposting, impersonation or scraping private material.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;The site is built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 15 App Router&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React 19&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OpenNext for Cloudflare&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Workers&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most pages are statically generated. Runtime work happens in the Worker for the TikTok lookup API, media proxying, redirects and locale handling.&lt;/p&gt;

&lt;p&gt;The live domain is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tikstory.net" rel="noopener noreferrer"&gt;https://tikstory.net&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;For this type of project, Cloudflare Workers are a good fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app is mostly read-heavy.&lt;/li&gt;
&lt;li&gt;The generated pages are static and cacheable.&lt;/li&gt;
&lt;li&gt;The runtime API layer is small.&lt;/li&gt;
&lt;li&gt;The media proxy can run close to users.&lt;/li&gt;
&lt;li&gt;Deployment stays lightweight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One design detail I cared about was keeping secrets out of the client bundle. The public site URL can be a build variable, but provider API keys live only in Worker secrets or local &lt;code&gt;.dev.vars&lt;/code&gt; during development.&lt;/p&gt;

&lt;p&gt;I also added a deploy-time secret check so API keys do not accidentally end up inside &lt;code&gt;.env&lt;/code&gt; files or build output. It is the kind of boring guardrail that saves you from a very un-boring cleanup later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multilingual SEO from day one
&lt;/h2&gt;

&lt;p&gt;TikStory currently ships in 13 languages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;English&lt;/li&gt;
&lt;li&gt;Spanish&lt;/li&gt;
&lt;li&gt;Portuguese&lt;/li&gt;
&lt;li&gt;Indonesian&lt;/li&gt;
&lt;li&gt;Malay&lt;/li&gt;
&lt;li&gt;German&lt;/li&gt;
&lt;li&gt;French&lt;/li&gt;
&lt;li&gt;Italian&lt;/li&gt;
&lt;li&gt;Dutch&lt;/li&gt;
&lt;li&gt;Russian&lt;/li&gt;
&lt;li&gt;Turkish&lt;/li&gt;
&lt;li&gt;Thai&lt;/li&gt;
&lt;li&gt;Vietnamese&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tool page has localized metadata, canonical URLs and hreflang alternates. There are also dedicated pages for adjacent intents like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TikTok Story Viewer&lt;/li&gt;
&lt;li&gt;Anonymous TikTok Viewer&lt;/li&gt;
&lt;li&gt;TikTok Profile Viewer&lt;/li&gt;
&lt;li&gt;TikTok Story Downloader&lt;/li&gt;
&lt;li&gt;TikTok Video Downloader&lt;/li&gt;
&lt;li&gt;TikTok Repost Viewer&lt;/li&gt;
&lt;li&gt;TikTok MP3 Downloader&lt;/li&gt;
&lt;li&gt;TikTok Profile Picture Downloader&lt;/li&gt;
&lt;li&gt;TikTok Follower Count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That structure is not accidental. Search traffic for tools like this is fragmented across many intents, and users often know the task they want before they know the product name.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few implementation decisions that paid off
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Keep result URLs out of the index
&lt;/h3&gt;

&lt;p&gt;Search result pages can easily create crawl traps if every username becomes an indexable URL. TikStory keeps lookup results out of canonical indexable paths and blocks API/result-style URLs from crawling.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Stream media instead of storing it
&lt;/h3&gt;

&lt;p&gt;Media is proxied with a host allowlist, and redirects are checked at each hop. The app does not keep copies. That keeps the system smaller, reduces operational risk and matches the privacy promise better.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Treat private accounts as a hard stop
&lt;/h3&gt;

&lt;p&gt;If an account is private, the app says so and stops there. No ambiguity, no "try again with another method" dark pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Write responsible product copy
&lt;/h3&gt;

&lt;p&gt;The copy avoids fake user counts, fake testimonials and exaggerated guarantees. It explains what the tool does, what it does not do and how users should handle downloaded content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Building a small utility product is less about the input box and more about the edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What happens when a story expires?&lt;/li&gt;
&lt;li&gt;What happens when the provider is rate limited?&lt;/li&gt;
&lt;li&gt;What should a private account response look like?&lt;/li&gt;
&lt;li&gt;How do you avoid indexing infinite lookup pages?&lt;/li&gt;
&lt;li&gt;Where do you put removal requests?&lt;/li&gt;
&lt;li&gt;How do you make the same tool page useful in 13 languages?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those decisions are not flashy, but they make the product feel trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you ever need to view or save a public TikTok Story without logging in, try it here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tikstory.net" rel="noopener noreferrer"&gt;TikStory: TikTok Story Viewer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I would love feedback from other builders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the positioning clear?&lt;/li&gt;
&lt;li&gt;What would you change on the landing page?&lt;/li&gt;
&lt;li&gt;Are there technical details you would want me to write about next?&lt;/li&gt;
&lt;li&gt;Would a browser extension version be useful?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
