<?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: solca</title>
    <description>The latest articles on DEV Community by solca (@solca).</description>
    <link>https://dev.to/solca</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%2F4034713%2F3d3c4681-d04b-4e25-bfd9-997e357f41bb.png</url>
      <title>DEV Community: solca</title>
      <link>https://dev.to/solca</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/solca"/>
    <language>en</language>
    <item>
      <title>I added an AI helper to my JSON mock-API tool — the hybrid design, and a Workers AI gotcha</title>
      <dc:creator>solca</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:28:27 +0000</pubDate>
      <link>https://dev.to/solca/i-added-an-ai-helper-to-my-json-mock-api-tool-the-hybrid-design-and-a-workers-ai-gotcha-pb9</link>
      <guid>https://dev.to/solca/i-added-an-ai-helper-to-my-json-mock-api-tool-the-hybrid-design-and-a-workers-ai-gotcha-pb9</guid>
      <description>&lt;p&gt;I run &lt;a href="https://temptools.webcli.jp" rel="noopener noreferrer"&gt;TempTools&lt;/a&gt; — a small suite of free, no-signup web tools that expire and delete themselves. The one I use most is &lt;strong&gt;Temp API&lt;/strong&gt;: paste JSON or CSV, get a live mock endpoint in seconds.&lt;/p&gt;

&lt;p&gt;I just added an &lt;strong&gt;AI helper&lt;/strong&gt; to it, and the design turned out more interesting than "call an LLM." The rule I set for myself was: &lt;strong&gt;AI is never allowed to touch correctness.&lt;/strong&gt; Here's how that shook out — plus a Cloudflare Workers AI gotcha that broke two of the three features while the third worked fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the helper does
&lt;/h2&gt;

&lt;p&gt;Three buttons on the Temp API editor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fix &amp;amp; format&lt;/strong&gt; — clean up messy/broken JSON and explain what changed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate schema&lt;/strong&gt; — a JSON Schema (draft 2020-12) from your data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate sample&lt;/strong&gt; — realistic mock data with the same shape&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The design rule: keep AI away from your data
&lt;/h2&gt;

&lt;p&gt;Here's the thing I didn't want: an AI silently &lt;em&gt;rewriting&lt;/em&gt; my JSON while pretending to "format" it. If you paste &lt;code&gt;{"id": 42}&lt;/code&gt; and the tool hands back &lt;code&gt;{"id": 43}&lt;/code&gt;, that's not a fix — that's a bug you'll chase for an hour.&lt;/p&gt;

&lt;p&gt;So repair and formatting are &lt;strong&gt;100% deterministic&lt;/strong&gt;. No AI. I use &lt;a href="https://www.npmjs.com/package/jsonrepair" rel="noopener noreferrer"&gt;&lt;code&gt;jsonrepair&lt;/code&gt;&lt;/a&gt;:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatOrRepair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="k"&gt;try&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="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;null&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="na"&gt;repaired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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="cm"&gt;/* not valid — try to repair */&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;repaired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jsonrepair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repaired&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;null&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="na"&gt;repaired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;p&gt;The AI is only used for things where being "approximately right" is fine and there's no source of truth to corrupt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;explaining&lt;/strong&gt; what the deterministic repair changed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;generating&lt;/strong&gt; a schema&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;generating&lt;/strong&gt; brand-new sample data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split matters for the copy too. It would be tempting to market this as "AI fixes your JSON!" — but that's not true, and someone will call it out. The UI says the repair runs locally and reserves "AI" for the schema/sample/explanation. Honest &lt;em&gt;and&lt;/em&gt; it dodges a whole class of complaints.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI calls (Cloudflare Workers AI)
&lt;/h2&gt;

&lt;p&gt;The generation runs on &lt;strong&gt;Workers AI&lt;/strong&gt; with an &lt;code&gt;ai&lt;/code&gt; binding — no external API keys, it just runs on the edge:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AI_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@cf/qwen/qwen2.5-coder-32b-instruct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxTokens&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AI_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← this line is a trap. more below.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;generateSchema&lt;/code&gt; and &lt;code&gt;generateSample&lt;/code&gt; are just &lt;code&gt;runText&lt;/code&gt; with a system prompt that says "output ONLY raw JSON, no markdown fences," and then I strip any stray fences/prose defensively before parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: &lt;code&gt;response&lt;/code&gt; isn't always a string
&lt;/h2&gt;

&lt;p&gt;Here's the bug that had me confused for a while. In production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fix &amp;amp; format&lt;/strong&gt; worked perfectly (including its AI explanation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate schema&lt;/strong&gt; and &lt;strong&gt;Generate sample&lt;/strong&gt; both failed with a generic 502&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same model. Same &lt;code&gt;runText&lt;/code&gt;. Same binding. So why did one of three AI calls work and two fail?&lt;/p&gt;

&lt;p&gt;I temporarily surfaced the real error in the response and got this:&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="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;intermediate&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;response&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="nx"&gt;trim&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;out.response&lt;/code&gt; wasn't a string — so &lt;code&gt;.trim()&lt;/code&gt; didn't exist on it.&lt;/p&gt;

&lt;p&gt;The pattern clicked once I saw &lt;em&gt;which&lt;/em&gt; calls failed. The explanation prompt returns &lt;strong&gt;prose&lt;/strong&gt;, so &lt;code&gt;response&lt;/code&gt; is a string. The schema and sample prompts return &lt;strong&gt;JSON&lt;/strong&gt; — and when the model's output is JSON, Workers AI can hand you &lt;code&gt;response&lt;/code&gt; as an already-parsed &lt;strong&gt;object&lt;/strong&gt;, not a string. Calling &lt;code&gt;.trim()&lt;/code&gt; on an object throws.&lt;/p&gt;

&lt;p&gt;The fix is boring but worth knowing: don't assume &lt;code&gt;response&lt;/code&gt; is a string.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxTokens&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AI_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="k"&gt;as&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="nx"&gt;unknown&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&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="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;If &lt;code&gt;response&lt;/code&gt; is a string, use it. If it's an object (parsed JSON), &lt;code&gt;JSON.stringify&lt;/code&gt; it back — which is exactly what I want to hand to the schema/sample path anyway. &lt;code&gt;null&lt;/code&gt;/&lt;code&gt;undefined&lt;/code&gt; becomes an empty string instead of crashing.&lt;/p&gt;

&lt;p&gt;Two debugging lessons I keep re-learning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Some calls work, some don't" is a gift.&lt;/strong&gt; The difference between the working and broken calls &lt;em&gt;is&lt;/em&gt; the bug. Here it was the output type (prose vs JSON), not the model or the binding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generic &lt;code&gt;catch → 502&lt;/code&gt; hides the answer.&lt;/strong&gt; One temporary line echoing the real error message turned a guessing game into a one-line fix. (Then I took it back out.)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Keeping it cheap and abuse-resistant
&lt;/h2&gt;

&lt;p&gt;Because repair/formatting never calls the model, the common case (paste valid-ish JSON, format it) costs &lt;strong&gt;zero&lt;/strong&gt; AI. The model only runs when you ask for an explanation, schema, or sample.&lt;/p&gt;

&lt;p&gt;On top of that, AI calls are rate-limited per IP with a tiny rolling log table (same trick I use for uploads), and the input is size-capped before it ever reaches the model. It all stays comfortably inside the Cloudflare free tier.&lt;/p&gt;

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

&lt;p&gt;It's live at &lt;strong&gt;&lt;a href="https://temptools.webcli.jp/tools/temp-api" rel="noopener noreferrer"&gt;temptools.webcli.jp/tools/temp-api&lt;/a&gt;&lt;/strong&gt; — paste some rough JSON and hit the AI buttons. No signup, and the endpoint you create expires on its own.&lt;/p&gt;

&lt;p&gt;If you're building on Workers AI, keep that &lt;code&gt;response&lt;/code&gt;-type gotcha in your back pocket. And if you find a rough edge in Temp API, I'd genuinely love to hear it. 🛠️&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>ai</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Add analytics to your site without a cookie consent banner</title>
      <dc:creator>solca</dc:creator>
      <pubDate>Mon, 20 Jul 2026 02:25:26 +0000</pubDate>
      <link>https://dev.to/solca/add-analytics-to-your-site-without-a-cookie-consent-banner-4a11</link>
      <guid>https://dev.to/solca/add-analytics-to-your-site-without-a-cookie-consent-banner-4a11</guid>
      <description>&lt;p&gt;I wanted to know if anyone was actually visiting my new side project — but I really didn't want to slap a GDPR cookie-consent banner on a tool that's supposed to be &lt;em&gt;frictionless&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The default move is Google Analytics. But GA sets cookies, which (in the EEA/UK and friends) means a consent banner, a CMP, and a pop-up in every new visitor's face. That's a lot of baggage just to answer "did anyone show up?"&lt;/p&gt;

&lt;p&gt;Turns out there's a much lighter option: &lt;strong&gt;Cloudflare Web Analytics&lt;/strong&gt;. It's free, &lt;strong&gt;cookieless&lt;/strong&gt;, doesn't fingerprint, and doesn't track people across sites — so in many cases, you don't need a consent banner.&lt;/p&gt;

&lt;p&gt;Here's how I added it, and the one gotcha that bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get (and what you don't)
&lt;/h2&gt;

&lt;p&gt;Cloudflare Web Analytics gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page views, visits, top pages&lt;/li&gt;
&lt;li&gt;Referrers and countries&lt;/li&gt;
&lt;li&gt;Core Web Vitals (LCP / INP / CLS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you &lt;em&gt;don't&lt;/em&gt; get: per-user journeys, custom events, funnels — the granular stuff GA does. Because there are no cookies and no client-side state, it can't follow a single user around.&lt;/p&gt;

&lt;p&gt;For "is anyone using this, and where are they coming from?", that's plenty. For deep product analytics, it's not GA. Know which question you're answering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup, option 1: automatic (zero code)
&lt;/h2&gt;

&lt;p&gt;If your site is proxied through Cloudflare, you don't need to touch your code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cloudflare dashboard → &lt;strong&gt;Analytics &amp;amp; Logs → Web Analytics&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a site&lt;/strong&gt;, enter your hostname&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Automatic setup&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cloudflare injects the beacon at the edge. Done. Nothing to deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup, option 2: the manual beacon
&lt;/h2&gt;

&lt;p&gt;If you want control (or you're on a framework where you'd rather drop it in yourself), use the beacon snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://static.cloudflareinsights.com/beacon.min.js"&lt;/span&gt;
        &lt;span class="na"&gt;data-cf-beacon=&lt;/span&gt;&lt;span class="s"&gt;'{"token": "YOUR_TOKEN"}'&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a Next.js app, I load it only in production via &lt;code&gt;next/script&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Script&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/script&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;TOKEN&lt;/span&gt; &lt;span class="o"&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;NEXT_PUBLIC_CF_BEACON_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CloudflareAnalytics&lt;/span&gt;&lt;span class="p"&gt;()&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;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;NEXT_PUBLIC_APP_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&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="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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"afterInteractive"&lt;/span&gt;
      &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://static.cloudflareinsights.com/beacon.min.js"&lt;/span&gt;
      &lt;span class="na"&gt;data-cf-beacon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&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="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;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;p&gt;Gating on production keeps localhost out of your numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: Content-Security-Policy
&lt;/h2&gt;

&lt;p&gt;I ship a fairly strict &lt;code&gt;Content-Security-Policy&lt;/code&gt;, and the beacon just… didn't fire. No error I noticed at first — it was silently blocked.&lt;/p&gt;

&lt;p&gt;The beacon loads a script from &lt;code&gt;static.cloudflareinsights.com&lt;/code&gt; and sends data to &lt;code&gt;cloudflareinsights.com&lt;/code&gt;. If you have a CSP, you have to allow both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;script&lt;/span&gt;-src  ... https://static.cloudflareinsights.com;
connect-src ... https://cloudflareinsights.com;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your analytics look empty and you have a CSP, this is the first thing to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you still need a privacy policy?
&lt;/h2&gt;

&lt;p&gt;The consent &lt;em&gt;banner&lt;/em&gt; goes away because there are no cookies and no personal profiling. But "no banner" isn't "no disclosure" — I still mention it in the privacy policy, something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For usage measurement we use Cloudflare Web Analytics, which is privacy-first: it doesn't use cookies, doesn't fingerprint, and doesn't track you across sites.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Transparent, and no pop-up nagging every visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I added this to &lt;a href="https://temptools.webcli.jp" rel="noopener noreferrer"&gt;TempTools&lt;/a&gt; and it's honestly a relief: I get country-level traffic and Core Web Vitals, users get zero pop-ups, and I didn't have to wire up a consent platform.&lt;/p&gt;

&lt;p&gt;If your site mostly needs "how many people, from where" — and especially if it's a small tool where a cookie banner feels heavier than the product itself — cookieless analytics is a really nice default.&lt;/p&gt;

&lt;p&gt;(Bonus: it's also great for confirming that most of my traffic is, so far, still just me. 😅)&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How I built a suite of self-destructing web tools on Cloudflare (solo, free tier)</title>
      <dc:creator>solca</dc:creator>
      <pubDate>Sat, 18 Jul 2026 06:08:25 +0000</pubDate>
      <link>https://dev.to/solca/how-i-built-a-suite-of-self-destructing-web-tools-on-cloudflare-solo-free-tier-2dpe</link>
      <guid>https://dev.to/solca/how-i-built-a-suite-of-self-destructing-web-tools-on-cloudflare-solo-free-tier-2dpe</guid>
      <description>&lt;p&gt;Most cloud tools are built around &lt;strong&gt;saving&lt;/strong&gt; things. But a lot of the time I don't want to save anything — I just want to hand something off for a few minutes and have it disappear on its own.&lt;/p&gt;

&lt;p&gt;A mock API for the frontend I'm testing. A file that's too big for chat. A password I need to send a teammate without pasting it into Slack forever. Every existing tool wants me to create an account, and then clean up after myself later.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://temptools.webcli.jp" rel="noopener noreferrer"&gt;TempTools&lt;/a&gt;&lt;/strong&gt; — a small set of free, no-signup web tools that expire and delete themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temp API&lt;/strong&gt; — paste JSON/CSV, get a live mock endpoint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temp QR / Temp Link&lt;/strong&gt; — expiring QR codes and short links&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temp File&lt;/strong&gt; — share a file (up to 100MB) that self-deletes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temp Note / Temp Password&lt;/strong&gt; — a note or a secret that can self-destruct after a single view&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's all built solo, on the Cloudflare free tier. Here are the parts I found interesting to build.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) deployed to &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; via &lt;strong&gt;&lt;a href="https://opennext.js.org/cloudflare" rel="noopener noreferrer"&gt;OpenNext&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; (SQLite) for metadata + the text payloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; for files&lt;/li&gt;
&lt;li&gt;A separate &lt;strong&gt;Workers Cron&lt;/strong&gt; worker for garbage collection&lt;/li&gt;
&lt;li&gt;Free tier the whole way&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Expiry done right: lazy expiry + a cron janitor
&lt;/h2&gt;

&lt;p&gt;The obvious way to expire things is a cron job that deletes stuff on a schedule. But if you rely &lt;em&gt;only&lt;/em&gt; on cron, an item stays "alive" until the next run — a 5-minute window where an expired secret is still readable. Not great.&lt;/p&gt;

&lt;p&gt;So the correctness lives on the &lt;strong&gt;read path&lt;/strong&gt;, and cron is just the janitor:&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;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readAndConsume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntryKind&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;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT * FROM entries WHERE id = ?`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;kind&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_found&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consumed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`DELETE FROM entries WHERE id = ?`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// lazy delete&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gone&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="c1"&gt;// ...serve it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expiry is exact because it's checked the moment someone reads. The &lt;strong&gt;5-minute cron&lt;/strong&gt; then sweeps up anything that expired but was never accessed (and deletes the matching R2 objects for files):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;consumed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Burn-after-read
&lt;/h2&gt;

&lt;p&gt;For one-time secrets, the row is deleted the moment it's opened. For files, the R2 object is deleted with &lt;code&gt;ctx.waitUntil&lt;/code&gt; &lt;em&gt;after&lt;/em&gt; the response streams out:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;burn_after_read&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&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;BUCKET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// delete the file after serving it&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;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ...headers */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Next.js 16 gotcha that cost me a deploy
&lt;/h2&gt;

&lt;p&gt;I wanted &lt;code&gt;/&lt;/code&gt; to redirect to &lt;code&gt;/en&lt;/code&gt;, so I reached for middleware. In Next.js 16 middleware was renamed to &lt;strong&gt;&lt;code&gt;proxy&lt;/code&gt;&lt;/strong&gt; — and here's the catch:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Proxy defaults to the Node.js runtime. The &lt;code&gt;runtime&lt;/code&gt; config option is not available in Proxy files.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;OpenNext on Cloudflare doesn't support Node.js middleware, so the build failed with &lt;em&gt;"Node.js middleware is not currently supported."&lt;/em&gt; You can't force it to edge either.&lt;/p&gt;

&lt;p&gt;The fix was to drop the proxy entirely and do the redirect in &lt;code&gt;next.config.ts&lt;/code&gt;:&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;async&lt;/span&gt; &lt;span class="nf"&gt;redirects&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;No middleware needed. If you're moving a Next 16 app to Cloudflare, watch out for this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Analytics without a cookie banner
&lt;/h2&gt;

&lt;p&gt;I wanted to know if anyone actually visited without slapping a GDPR consent banner on a tool that's supposed to be frictionless. &lt;strong&gt;Cloudflare Web Analytics&lt;/strong&gt; is cookieless and doesn't fingerprint, so no consent banner is required — one snippet and done. It's less detailed than Google Analytics, but for country-level traffic and basic numbers it's plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Staying inside the free tier on purpose
&lt;/h2&gt;

&lt;p&gt;Free tiers are generous, but files can blow through them, so I added guardrails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-file cap&lt;/strong&gt; of 100MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-IP upload limit&lt;/strong&gt; (a small &lt;code&gt;upload_log&lt;/code&gt; table + a rolling 24h count)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total R2 cap&lt;/strong&gt; — I track each file's size in D1 and reject uploads once the sum would exceed a configurable limit (well under R2's 10GB free storage)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloudflare's free limits that made this viable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workers&lt;/strong&gt;: 100,000 requests/day (static assets served by Workers Assets don't count)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt;: 5M reads/day, 100k writes/day, 5GB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt;: 10GB storage, &lt;strong&gt;egress free&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: it ships in 10 languages
&lt;/h2&gt;

&lt;p&gt;Everything lives under a &lt;code&gt;[lang]&lt;/code&gt; segment with one dictionary per locale (&lt;code&gt;en&lt;/code&gt;, &lt;code&gt;ja&lt;/code&gt;, &lt;code&gt;zh&lt;/code&gt;, &lt;code&gt;es&lt;/code&gt;, &lt;code&gt;de&lt;/code&gt;, &lt;code&gt;fr&lt;/code&gt;, &lt;code&gt;ko&lt;/code&gt;, &lt;code&gt;pt&lt;/code&gt;, &lt;code&gt;it&lt;/code&gt;, &lt;code&gt;tr&lt;/code&gt;). &lt;code&gt;hreflang&lt;/code&gt; + a sitemap handle the SEO side. Doing it from day one was far less painful than retrofitting it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;None of this is groundbreaking on its own, but it's a fun reminder of how much one person can run on a free serverless stack today: a multi-tool app, in 10 languages, with real storage, a cron janitor, rate limiting, and analytics — for $0.&lt;/p&gt;

&lt;p&gt;If you want to poke at it, it's live at &lt;strong&gt;&lt;a href="https://temptools.webcli.jp" rel="noopener noreferrer"&gt;temptools.webcli.jp&lt;/a&gt;&lt;/strong&gt; (no signup, everything expires). I'd genuinely love feedback — especially on the developer bits like Temp API.&lt;/p&gt;

&lt;p&gt;I'll be writing more about the individual pieces (the AI JSON helper, the i18n setup, the deploy pipeline) — follow along if that's your kind of thing. 🛠️&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
