<?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>I Built a Data-Driven Palworld Breeding Calculator for Version 1.0</title>
      <dc:creator>Airball Nick</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:53:36 +0000</pubDate>
      <link>https://dev.to/airball_nick_f1598c78e1cd/i-built-a-data-driven-palworld-breeding-calculator-for-version-10-dg0</link>
      <guid>https://dev.to/airball_nick_f1598c78e1cd/i-built-a-data-driven-palworld-breeding-calculator-for-version-10-dg0</guid>
      <description>&lt;p&gt;Palworld breeding looks simple when you are just putting two Pals into the farm.&lt;br&gt;
From a code point of view, it is a nice little data-modeling trap.&lt;/p&gt;

&lt;p&gt;I built and maintain a free &lt;a href="https://www.palworldonline.net/" rel="noopener noreferrer"&gt;Palworld Breeding Calculator&lt;/a&gt; for Palworld 1.0. It lets players pick two parents and see the egg, pick a target and find valid parents, plan multi-generation breeding trees, and inspect the data behind the tool.&lt;/p&gt;

&lt;p&gt;Disclosure: this is my project, and the link above goes to it. It is free to use, does not require an account, and is not affiliated with Pocketpair or Garena.&lt;/p&gt;

&lt;p&gt;This post is the build story: how the calculator ended up as a static Next.js app powered by bundled game data instead of a runtime API.&lt;/p&gt;
&lt;h2&gt;
  
  
  The core mechanic is deterministic
&lt;/h2&gt;

&lt;p&gt;The first useful constraint is that Palworld breeding is not fuzzy.&lt;/p&gt;

&lt;p&gt;For ordinary pairs, every Pal has a hidden value usually called CombiRank. The game averages the two parents' ranks, rounds up, and returns the closest Pal in the ordinary breeding pool.&lt;/p&gt;

&lt;p&gt;Conceptually:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateTargetRank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firstRank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secondRank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;firstRank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;secondRank&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&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;p&gt;Then the calculator searches the ordinary pool for the Pal closest to that target rank.&lt;/p&gt;

&lt;p&gt;The wrinkle is that ordinary formula does not cover everything. Palworld 1.0 has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;299 Pal entries in the breeding dataset&lt;/li&gt;
&lt;li&gt;183 entries in the ordinary formula pool&lt;/li&gt;
&lt;li&gt;164 special combo rows that override the formula&lt;/li&gt;
&lt;li&gt;297 Pals that can actually enter a Breeding Farm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the algorithm is not just "average two numbers." It is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check whether the parent pair has a special combo.&lt;/li&gt;
&lt;li&gt;If so, return the fixed child.&lt;/li&gt;
&lt;li&gt;If not, calculate the target CombiRank.&lt;/li&gt;
&lt;li&gt;Search only the ordinary formula pool.&lt;/li&gt;
&lt;li&gt;Apply the dataset's tie rule consistently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is small enough to run in the browser, but important enough that I did not want every component to reimplement it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared breeding module became the heart of the app
&lt;/h2&gt;

&lt;p&gt;The app has a shared breeding library that loads the local JSON data and exposes the same primitives everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;calculateOffspring(first, second)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buildReverseBreedingMap()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rankedParentPairs(target)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Pal lookup by name and slug&lt;/li&gt;
&lt;li&gt;farm eligibility and unbreedable flags&lt;/li&gt;
&lt;li&gt;display helpers for data dates and gender conditions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That lets these pages agree with each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the homepage pair calculator&lt;/li&gt;
&lt;li&gt;the reverse planner&lt;/li&gt;
&lt;li&gt;the breeding tree&lt;/li&gt;
&lt;li&gt;every generated per-Pal guide&lt;/li&gt;
&lt;li&gt;the open dataset endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because breeding calculators drift easily. If the homepage, the tree planner, and the data export each carry their own copy of the formula, one of them will eventually be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static pages are a feature, not a limitation
&lt;/h2&gt;

&lt;p&gt;I wanted the site to work more like a reference than an app shell. The important pages should be indexable, linkable, and fast without waiting on runtime data.&lt;/p&gt;

&lt;p&gt;The current site is a Next.js App Router project with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a calculator-first homepage&lt;/li&gt;
&lt;li&gt;an All Pals chart at &lt;code&gt;/breeding&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;generated per-Pal breeding guides under &lt;code&gt;/breeding/[pal]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a target-first planner at &lt;code&gt;/reverse-planner&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a visual breeding tree at &lt;code&gt;/breeding-tree&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;passive inheritance odds at &lt;code&gt;/breeding/passives&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;open data at &lt;code&gt;/data&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The breeding data and Pal artwork are bundled locally. That means the core calculator does not depend on a third-party image CDN or a database request at runtime.&lt;/p&gt;

&lt;p&gt;For this kind of tool, static generation is a good fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the dataset changes by game version, not by user&lt;/li&gt;
&lt;li&gt;every Pal page can be generated up front&lt;/li&gt;
&lt;li&gt;search engines can crawl real content instead of a blank client app&lt;/li&gt;
&lt;li&gt;the calculator still hydrates where interactivity is useful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is closer to a game reference with tools attached than a single-page calculator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverse lookup changes the user experience
&lt;/h2&gt;

&lt;p&gt;Most players do not ask, "What does this pair produce?"&lt;/p&gt;

&lt;p&gt;They ask, "How do I get Anubis?" or "What can I breed from the Pals I already have?"&lt;/p&gt;

&lt;p&gt;So the app builds a reverse map by iterating through valid farm-eligible parents and storing every child outcome:&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="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;parents&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="mi"&gt;1&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;let&lt;/span&gt; &lt;span class="nx"&gt;j&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="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;parents&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;j&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parents&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateOffspring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;second&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// store [first, second] under result.child&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;p&gt;That reverse index powers the target-first planner and the per-Pal pages. Each target page can show the parent pairs that produce it, while the breeding tree can continue expanding parent routes backward by generation.&lt;/p&gt;

&lt;p&gt;The useful UI pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick the target Pal.&lt;/li&gt;
&lt;li&gt;Compare parent pairs.&lt;/li&gt;
&lt;li&gt;Prefer the pair whose rarest parent is easiest to obtain.&lt;/li&gt;
&lt;li&gt;Expand the harder parent another generation if needed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That workflow is much closer to how players actually breed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model had a few sharp edges
&lt;/h2&gt;

&lt;p&gt;The more interesting part of this project was not the UI. It was the corrections.&lt;/p&gt;

&lt;p&gt;The first trap: Katress + Wixen.&lt;/p&gt;

&lt;p&gt;Most breeding combos are unordered. Parent A and parent B can be swapped and the child stays the same. Katress + Wixen is the exception: the child depends on which species is female.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;female Katress + male Wixen gives Katress Ignis&lt;/li&gt;
&lt;li&gt;male Katress + female Wixen gives Wixen Noct&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your special-combo map uses a sorted pair key like &lt;code&gt;Katress|Wixen&lt;/code&gt;, the second row overwrites the first one. One child silently disappears.&lt;/p&gt;

&lt;p&gt;The fix was to store all outcomes for a pair, then expose alternates with their gender condition.&lt;/p&gt;

&lt;p&gt;The second trap: game-file rows are not always enough to decide whether a Pal can breed in game.&lt;/p&gt;

&lt;p&gt;Panthalus and Astralym have self-pair rows in the data, but neither can enter a Breeding Farm. Panthalus cannot use base facilities, and Astralym is unobtainable. That rule cannot be inferred safely from &lt;code&gt;special_combos&lt;/code&gt;, so the dataset now has an explicit &lt;code&gt;breeding_exclusions&lt;/code&gt; block with sources and verification dates.&lt;/p&gt;

&lt;p&gt;The third trap was my own bug: I initially treated any &lt;code&gt;X + X -&amp;gt; X&lt;/code&gt; row as "catch-only." That was too broad. Relaxaurus Lux and Mossanda Lux have self-pair rows, but they also have real cross-species combos. The correct rule is: a Pal is catch-only only when every listed combo for it is the self-pair.&lt;/p&gt;

&lt;p&gt;That kind of mistake is exactly why the site now has a public corrections log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dataset is public on purpose
&lt;/h2&gt;

&lt;p&gt;The calculator exposes the same data it uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/pals.json" rel="noopener noreferrer"&gt;All Pals JSON&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/pals.csv" rel="noopener noreferrer"&gt;All Pals CSV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/combos.json" rel="noopener noreferrer"&gt;Special combos JSON&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/combos.csv" rel="noopener noreferrer"&gt;Special combos CSV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/data" rel="noopener noreferrer"&gt;Data documentation and corrections&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoints are static files or static route outputs with CORS open, so another browser-based tool can fetch them directly.&lt;/p&gt;

&lt;p&gt;Example:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.palworldonline.net/api/pals.json&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;anubis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pal&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;pal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Anubis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anubis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent_pair_count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data includes fields that are easy to get wrong if every tool re-derives them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;breedable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;breeding_farm_eligible&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;special_only&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;parent_pair_count&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;elements&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;male_percent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;gender fields for special combos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would rather other tools reuse corrected data than repeat the same bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do again
&lt;/h2&gt;

&lt;p&gt;Three decisions paid off:&lt;/p&gt;

&lt;p&gt;First, centralize the formula. Every route and export should call the same calculator logic.&lt;/p&gt;

&lt;p&gt;Second, make corrections explicit data. If something cannot be inferred from game-file combo rows, it deserves its own field.&lt;/p&gt;

&lt;p&gt;Third, publish the data. A tool with a visible corrections log is easier to trust than a black box that only shows the final answer.&lt;/p&gt;

&lt;p&gt;If you play Palworld, the calculator is here:&lt;/p&gt;

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

&lt;p&gt;If you build tools, the dataset is here:&lt;/p&gt;

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

&lt;p&gt;If you spot a breeding result that looks wrong, I want to know. Game data changes, and the only useful calculator is one that can be corrected.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>gamedev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Opened the Palworld Breeding Dataset Behind My Calculator</title>
      <dc:creator>Airball Nick</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:51:13 +0000</pubDate>
      <link>https://dev.to/airball_nick_f1598c78e1cd/i-opened-the-palworld-breeding-dataset-behind-my-calculator-11gp</link>
      <guid>https://dev.to/airball_nick_f1598c78e1cd/i-opened-the-palworld-breeding-dataset-behind-my-calculator-11gp</guid>
      <description>&lt;p&gt;I built a Palworld breeding calculator, but the part I most want other developers to use is the data.&lt;/p&gt;

&lt;p&gt;The dataset behind the site is now public as JSON and CSV:&lt;/p&gt;

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

&lt;p&gt;Disclosure: I built and maintain the calculator I am linking to here. It is free to use, does not require an account, and is not affiliated with Pocketpair or Garena.&lt;/p&gt;

&lt;p&gt;This post is about what is in the dataset, why I opened it, and the breeding-data bugs it is designed to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is available
&lt;/h2&gt;

&lt;p&gt;There are four lightweight endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/pals.json" rel="noopener noreferrer"&gt;All Pals JSON&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/pals.csv" rel="noopener noreferrer"&gt;All Pals CSV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/combos.json" rel="noopener noreferrer"&gt;Special combos JSON&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.palworldonline.net/api/combos.csv" rel="noopener noreferrer"&gt;Special combos CSV&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data page also links the full Paldeck profile JSON used by newer lookup features.&lt;/p&gt;

&lt;p&gt;The breeding exports cover Palworld 1.0 breeding data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;299 Pal entries&lt;/li&gt;
&lt;li&gt;297 Pals that can enter a Breeding Farm&lt;/li&gt;
&lt;li&gt;183 ordinary formula-pool entries&lt;/li&gt;
&lt;li&gt;164 special combo rows&lt;/li&gt;
&lt;li&gt;CombiRank values&lt;/li&gt;
&lt;li&gt;parent-pair counts&lt;/li&gt;
&lt;li&gt;element types&lt;/li&gt;
&lt;li&gt;gender ratio&lt;/li&gt;
&lt;li&gt;breedable and farm-eligible flags&lt;/li&gt;
&lt;li&gt;gender fields for special combos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoints are served with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Access-Control-Allow-Origin: *
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means you can fetch them from another browser-based tool without needing a proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PalRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&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="nl"&gt;slug&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="nl"&gt;combi_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rank_position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;special_only&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;breedable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;breeding_farm_eligible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;parent_pair_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;elements&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="nl"&gt;male_percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;icon_url&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="nl"&gt;guide_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="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PalsResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PalRow&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PalsResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.palworldonline.net/api/pals.json&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;pals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pals&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;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pals&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pal&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;pal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;breedable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent_pair_count&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent_pair_count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent_pair_count&lt;/span&gt;&lt;span class="p"&gt;)&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pal&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="na"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent_pair_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;combi_rank&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;p&gt;For spreadsheet work, use the CSV versions. The schema is intentionally flat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just scrape a calculator?
&lt;/h2&gt;

&lt;p&gt;Because breeding tools tend to hide the assumptions that matter.&lt;/p&gt;

&lt;p&gt;If a site only shows the final child, you cannot tell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether special combos were applied before the formula&lt;/li&gt;
&lt;li&gt;whether special-only children were excluded from the ordinary pool&lt;/li&gt;
&lt;li&gt;whether the calculator knows about gender-dependent combo rows&lt;/li&gt;
&lt;li&gt;whether farm-ineligible Pals were excluded&lt;/li&gt;
&lt;li&gt;whether &lt;code&gt;X + X -&amp;gt; X&lt;/code&gt; was interpreted too broadly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public dataset exists so those assumptions are visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction 1: Katress + Wixen is gender-dependent
&lt;/h2&gt;

&lt;p&gt;This was the row that broke my first implementation.&lt;/p&gt;

&lt;p&gt;Katress + Wixen can produce two children:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;female Katress + male Wixen gives Katress Ignis&lt;/li&gt;
&lt;li&gt;male Katress + female Wixen gives Wixen Noct&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most special combos are unordered. This one is not.&lt;/p&gt;

&lt;p&gt;If you store special combos in a map keyed by sorted parent names, both rows become the same key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Katress|Wixen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row overwrites the other, and one child disappears from your reverse lookup.&lt;/p&gt;

&lt;p&gt;The combo export avoids that by carrying gender fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parent_a"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Katress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parent_a_gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parent_b"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wixen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parent_b_gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"male"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"child"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Katress Ignis"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"breeds_true"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gender_dependent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your tool builds a reverse planner, you need this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction 2: farm eligibility is not the same as having a row
&lt;/h2&gt;

&lt;p&gt;Some data rows exist because they are in the game files, but they do not mean the Pal can actually use the Breeding Farm.&lt;/p&gt;

&lt;p&gt;Panthalus and Astralym are the two important cases in this dataset.&lt;/p&gt;

&lt;p&gt;They have self-pair rows in the game data. A naive tool might read those rows and conclude that each Pal breeds true.&lt;/p&gt;

&lt;p&gt;In practice, they cannot be used as normal breeding parents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Panthalus cannot use base facilities, including the Breeding Farm.&lt;/li&gt;
&lt;li&gt;Astralym is unobtainable by the player.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the Pal export separates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;breedable
breeding_farm_eligible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those fields are not duplicates. A Pal can be present in the data while still being excluded from real parent-pair planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction 3: a self-pair row is not enough
&lt;/h2&gt;

&lt;p&gt;This one was my own mistake.&lt;/p&gt;

&lt;p&gt;I originally treated any &lt;code&gt;X + X -&amp;gt; X&lt;/code&gt; row as proof that a Pal was catch-only.&lt;/p&gt;

&lt;p&gt;That is too aggressive.&lt;/p&gt;

&lt;p&gt;Relaxaurus Lux and Mossanda Lux have self-pair rows, but they also have real cross-species combos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relaxaurus + Sparkit&lt;/li&gt;
&lt;li&gt;Mossanda + Grizzbolt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The correct rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A Pal is catch-only only when every listed route for that Pal is the self-pair.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That bug is now in the corrections log because I want downstream users to know what changed and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CombiRank formula still matters
&lt;/h2&gt;

&lt;p&gt;The data is not just a table of special cases.&lt;/p&gt;

&lt;p&gt;For ordinary breeding pairs, Palworld averages both parents' CombiRank, rounds up, and returns the closest Pal in the ordinary formula pool.&lt;/p&gt;

&lt;p&gt;CombiRank runs opposite to player intuition: common Pals sit near the high end, rare Pals near the low end.&lt;/p&gt;

&lt;p&gt;That is why the export includes the raw &lt;code&gt;combi_rank&lt;/code&gt; instead of only a precomputed pair table. You can build your own planning logic if you want to rank routes differently.&lt;/p&gt;

&lt;p&gt;For example, you might prefer parent pairs where the rarer parent is still relatively common:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;routeDifficulty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;PalRow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PalRow&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pair&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="nx"&gt;combi_rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;combi_rank&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;p&gt;Higher is usually easier in that comparison, because the lower-ranked parent is less rare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the corrections log is part of the product
&lt;/h2&gt;

&lt;p&gt;Game datasets are not static truth.&lt;/p&gt;

&lt;p&gt;They are interpreted. Sometimes the interpretation is wrong. Sometimes the source table is real but incomplete for gameplay. Sometimes a game update moves the goalposts.&lt;/p&gt;

&lt;p&gt;That is why the data page includes a corrections log with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what the common mistake is&lt;/li&gt;
&lt;li&gt;what the actual behavior is&lt;/li&gt;
&lt;li&gt;why the correction matters&lt;/li&gt;
&lt;li&gt;the sources checked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not just for transparency. It is a maintenance tool. If someone tells me the calculator is wrong, I want to know which assumption changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;

&lt;p&gt;The dataset license on the page is intentionally permissive:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Free to use, including commercially. Attribution with a link to palworldonline.net is appreciated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are building a Palworld tool, spreadsheet, wiki helper, Discord bot, route planner, or visualization, you can start here:&lt;/p&gt;

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

&lt;p&gt;If you only want the calculator, it is here:&lt;/p&gt;

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

&lt;p&gt;And if you find an error, please treat that as useful signal. I opened the data because I would rather have one corrected table than a dozen tools quietly repeating the same bug.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
      <category>api</category>
      <category>opensource</category>
    </item>
    <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>
