<?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: 彭勇</title>
    <description>The latest articles on DEV Community by 彭勇 (@_e4aae51541f4c5468758fb).</description>
    <link>https://dev.to/_e4aae51541f4c5468758fb</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%2F3852523%2Fdf6f3640-e2ff-4969-a4f7-f851046b588a.png</url>
      <title>DEV Community: 彭勇</title>
      <link>https://dev.to/_e4aae51541f4c5468758fb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_e4aae51541f4c5468758fb"/>
    <language>en</language>
    <item>
      <title>I built a free image upscaler on Cloudflare Workers — here's what bit me</title>
      <dc:creator>彭勇</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:52:45 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-free-image-upscaler-on-cloudflare-workers-heres-what-bit-me-52ph</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-free-image-upscaler-on-cloudflare-workers-heres-what-bit-me-52ph</guid>
      <description>&lt;p&gt;I kept running into the same annoyance: screenshots that arrive looking like LEGO, photos that get crushed by messaging apps, and every "fix" behind a signup wall or a subscription. So I built &lt;a href="https://unpixelate.net" rel="noopener noreferrer"&gt;unpixelate.net&lt;/a&gt; — upload a pixelated or blurry image, get a sharper one back in seconds. No account, no credit card.&lt;/p&gt;

&lt;p&gt;This post isn't a product tour. It's the stuff that actually cost me debugging time: an AI inference API with three hidden traps, a math bug that would have made my own marketing a lie, and a storage quirk where a legal promise ("we delete your images") can silently die while everything returns HTTP 200.&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) on &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; via &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; for image storage (zero egress fees — this matters later)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; for billing state, because credits need transactions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replicate&lt;/strong&gt; for inference: &lt;code&gt;prunaai/p-image-upscale&lt;/code&gt; as the workhorse, Topaz's &lt;code&gt;image-upscale&lt;/code&gt; as the paid HD engine&lt;/li&gt;
&lt;li&gt;Zero runtime dependencies in the integration layer — the Replicate client and the AWS SigV4 implementation are both hand-rolled on &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;node:crypto&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why no SDK for Replicate? The client is ~200 lines, and I wanted every header and timeout visible in code review rather than buried in a dependency. This turned out to be the right call, for reasons you'll see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap #1: three things the Replicate docs make non-obvious
&lt;/h2&gt;

&lt;p&gt;A naive client "works" in testing and then fails in production in three separate ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;POST /v1/predictions&lt;/code&gt; has no &lt;code&gt;model&lt;/code&gt; field.&lt;/strong&gt; The &lt;code&gt;owner/name&lt;/code&gt; shorthand only works for &lt;em&gt;official&lt;/em&gt; Replicate models, through a different endpoint. For community models you must send the full 64-character &lt;code&gt;version&lt;/code&gt; id. Pin it, and expose a &lt;code&gt;resolveLatestVersion()&lt;/code&gt; helper so drift is visible instead of silent:&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;created&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://api.replicate.com/v1/predictions&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&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="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Blocks for up to 60s server-side — usually returns a finished&lt;/span&gt;
    &lt;span class="c1"&gt;// prediction and the polling loop never runs.&lt;/span&gt;
    &lt;span class="na"&gt;prefer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait=60&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Bounds what we're billed for if this process dies mid-prediction.&lt;/span&gt;
    &lt;span class="c1"&gt;// The API enforces a 5s floor on this header.&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancel-after&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="s2"&gt;300s&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="na"&gt;body&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;version&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="c1"&gt;// version, NOT model&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Output URLs require your auth header.&lt;/strong&gt; Results are served from &lt;code&gt;replicate.delivery&lt;/code&gt;, and a bare &lt;code&gt;fetch(outputUrl)&lt;/code&gt; can 401. Every tutorial forgets this. You must send the same bearer token when downloading the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Predictions are deleted after about an hour.&lt;/strong&gt; Inputs, outputs, logs — gone. You cannot store the output URL and fetch it later from a queue consumer. The bytes must be copied somewhere durable &lt;em&gt;during the same request that polls them&lt;/em&gt;:&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;// After the prediction succeeds, immediately pull the bytes down&lt;/span&gt;
&lt;span class="c1"&gt;// with a hard ceiling, then upload to R2 ourselves.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;outputUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;signal&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;chunks&lt;/span&gt; &lt;span class="o"&gt;=&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;size&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="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_DOWNLOAD_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;output over ceiling&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;chunks&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;chunk&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 ceiling matters on Workers, where memory is capped and a runaway download shouldn't take the isolate down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap #2: the 4-megapixel promise
&lt;/h2&gt;

&lt;p&gt;The marketing page says "up to 4 MP output." Sounds like the model's job, right? Ask for 4 megapixels, ship it.&lt;/p&gt;

&lt;p&gt;Wrong. The model has two upscale modes and neither does what the label says. I measured against the live API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;mode&lt;/th&gt;
&lt;th&gt;asked&lt;/th&gt;
&lt;th&gt;got&lt;/th&gt;
&lt;th&gt;actual MP&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1400x1050&lt;/td&gt;
&lt;td&gt;factor&lt;/td&gt;
&lt;td&gt;1.65&lt;/td&gt;
&lt;td&gt;2308x1732&lt;/td&gt;
&lt;td&gt;3.997&lt;/td&gt;
&lt;td&gt;0.9987&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1600x900&lt;/td&gt;
&lt;td&gt;factor&lt;/td&gt;
&lt;td&gt;1.65&lt;/td&gt;
&lt;td&gt;2640x1484&lt;/td&gt;
&lt;td&gt;3.918&lt;/td&gt;
&lt;td&gt;0.9994&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1400x1050&lt;/td&gt;
&lt;td&gt;target&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;4.189&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0473&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1600x900&lt;/td&gt;
&lt;td&gt;target&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;3.144&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0480&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;target&lt;/code&gt; mode overshoots by ~4.8%, and its schema is an &lt;code&gt;integer&lt;/code&gt;, so you can't trim it with a fractional value — the API rejects that with a 422. &lt;code&gt;factor&lt;/code&gt; mode lands within 0.2% of what you asked for. So every viable plan uses &lt;code&gt;factor&lt;/code&gt;, computed backwards from the budget:&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;// Aim 1% under the cap. The model rounds output dimensions, so asking&lt;/span&gt;
&lt;span class="c1"&gt;// for exactly 4 MP can land a fraction ABOVE it — which would make the&lt;/span&gt;
&lt;span class="c1"&gt;// advertised "up to 4 MP" claim false. The headroom is free.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TARGET_MARGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.99&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;planUpscale&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sourceMp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxOutputMp&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxOutputMp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;TARGET_MARGIN&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;ideal&lt;/span&gt; &lt;span class="o"&gt;=&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sourceMp&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;factor&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&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;max&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="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;factor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;factor&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;There's a second, quieter decision in there: if the source is &lt;em&gt;already&lt;/em&gt; at or above the cap, we reject before spending the user's credit instead of silently handing back a smaller image than they uploaded. "Your input is too big" is a better answer than a silent downgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pixel art exception
&lt;/h2&gt;

&lt;p&gt;One more wrinkle in that planner. For most images, inventing detail is the product. For pixel art, inventing detail is always wrong — the art &lt;em&gt;is&lt;/em&gt; a grid, and the only correct result is that same grid, larger. But the cap-fitting math happily asks for ~8x on a small sprite, and past roughly 8x the model starts hallucinating pixels.&lt;/p&gt;

&lt;p&gt;So callers that know the input is pixel art pass an explicit &lt;code&gt;factor: 4&lt;/code&gt; and get exactly 4x. Small special case, but it's the difference between "this tool gets pixel art" and "this tool ruins pixel art" in a screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why everything is PNG (and why R2 makes that free)
&lt;/h2&gt;

&lt;p&gt;The default output format is PNG, deliberately. The model synthesizes pixels; re-encoding those to JPEG throws away detail we just paid for. On most hosts, storing larger files also means paying egress every time someone downloads a result — but R2 charges zero egress, so the bigger file costs storage only. This is one of those architecture decisions that's only obvious in retrospect: &lt;strong&gt;the storage pricing shaped the image quality policy.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap #3: the privacy promise that dies at HTTP 200
&lt;/h2&gt;

&lt;p&gt;The privacy policy promises uploads and results are deleted after at most 14 days. Enforcing that is a cron that sweeps two R2 prefixes (&lt;code&gt;in/&lt;/code&gt; and &lt;code&gt;out/&lt;/code&gt;). Boring code — except for one landmine:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3's &lt;code&gt;DeleteObjects&lt;/code&gt; answers HTTP 200 even when individual keys fail to delete.&lt;/strong&gt; If you only check the status code, the cron logs "success" every night while a growing pile of expired images survives forever. A legal promise, silently broken, with nothing in the logs.&lt;/p&gt;

&lt;p&gt;So the retention function collects every per-key failure instead of throwing on the first one, and the callers decide how to escalate:&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;// Returns { removed, failed } — never trust the HTTP status alone.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;}&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;runRetention&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&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;failed&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;&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&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="c1"&gt;// cron invocation shows as failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also merged the prefix list into a single module after noticing the local prune script and the Worker cron each held their own copy. That's the exact shape of bug a legal promise dies from: add a third storage prefix, update one caller, and the other silently stops covering it — with no error anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pin model versions and log version drift. "Latest" is not an API contract.&lt;/li&gt;
&lt;li&gt;Measure what the model actually returns, not what the schema says. The table above is 15 minutes of curl that saved the honesty of the landing page.&lt;/li&gt;
&lt;li&gt;Anytime a legal promise ("we delete after N days") depends on a cron, simulate a partial failure and check that someone notices.&lt;/li&gt;
&lt;li&gt;Zero-dependency integration modules are testable against live APIs with no mocking layer, and on Workers, small is a feature.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The site is live at &lt;a href="https://unpixelate.net" rel="noopener noreferrer"&gt;unpixelate.net&lt;/a&gt; — free to start, no signup, JPG/PNG/WEBP. One honest caveat that's on the site too: the AI &lt;em&gt;estimates&lt;/em&gt; missing detail. It's a reconstruction, not a forensic restore — a heavily pixelated face won't come back looking exactly like the real person. For screenshots, compressed photos, and text that went soft, though, the difference is night and day.&lt;/p&gt;

&lt;p&gt;If you've shipped AI features on Workers, I'd love to hear what bit you — especially around long-running inference on a platform built for short requests. My current answer is &lt;code&gt;Prefer: wait&lt;/code&gt; plus a bounded poll, but I don't love it.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Built an AI Tool for Editing Text Inside Images</title>
      <dc:creator>彭勇</dc:creator>
      <pubDate>Mon, 07 Sep 2026 14:21:19 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/i-built-an-ai-tool-for-editing-text-inside-images-68j</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/i-built-an-ai-tool-for-editing-text-inside-images-68j</guid>
      <description>&lt;p&gt;I built &lt;a href="https://edittextimage.net" rel="noopener noreferrer"&gt;EditTextImage&lt;/a&gt;, a small AI tool for one specific task: replacing visible text inside an existing image.&lt;/p&gt;

&lt;p&gt;The idea came from a familiar annoyance. You have a screenshot, poster, product graphic, or social image that is almost right, but one date, label, price, or typo needs to change. Recreating the asset in Photoshop or another design tool can take much longer than the actual change deserves.&lt;/p&gt;

&lt;p&gt;So I wanted to make the workflow deliberately narrow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upload an image&lt;/li&gt;
&lt;li&gt;Enter the text currently visible&lt;/li&gt;
&lt;li&gt;Enter the replacement text&lt;/li&gt;
&lt;li&gt;Generate and download the edited result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal is not to replace a design workflow. It is to help with small, practical text corrections while keeping the original layout, background, lighting, and typography as close as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is useful for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Updating labels or callouts in product screenshots&lt;/li&gt;
&lt;li&gt;Correcting dates, times, or sponsor information on posters&lt;/li&gt;
&lt;li&gt;Refreshing e-commerce banners and social graphics&lt;/li&gt;
&lt;li&gt;Adjusting prices or daily specials on menu boards&lt;/li&gt;
&lt;li&gt;Fixing small copy mistakes without recreating an entire image&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The editor supports JPG, PNG, and WebP files up to 10 MB. New accounts receive one free credit, and no credit card is required to try it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the limitations
&lt;/h2&gt;

&lt;p&gt;This is AI-assisted image editing, so it is not magic. Results can need another attempt, especially when text is very small, highly decorative, curved, or tightly integrated with a complex background.&lt;/p&gt;

&lt;p&gt;I would rather be clear about that than promise “pixel-perfect” output in every case. The tool works best when the requested change is focused and the original text is readable.&lt;/p&gt;

&lt;p&gt;It is also not intended for misleading edits. Please do not use it for government IDs, official documents, currency, copyrighted work you do not own, or any image modification intended to deceive someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I kept it focused
&lt;/h2&gt;

&lt;p&gt;There are already excellent full-featured image editors. I did not want to build another large creative suite with dozens of controls.&lt;/p&gt;

&lt;p&gt;Instead, I wanted to test whether a constrained workflow could reduce the friction of a very common request: “Can we just change this text?”&lt;/p&gt;

&lt;p&gt;The product uses AI image editing to handle the visual adjustment, while the interface asks for the original wording and the replacement text to keep the edit specific.&lt;/p&gt;

&lt;p&gt;You can try it here: &lt;a href="https://edittextimage.net" rel="noopener noreferrer"&gt;edittextimage.net&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I am still improving the result quality and the overall workflow. Honest feedback is especially useful: which image types work well, which ones fail, and what would make this genuinely useful in your day-to-day work?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I Built a Simple Tool to Remove Matcha Filters from Photos</title>
      <dc:creator>彭勇</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:08:09 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-simple-tool-to-remove-matcha-filters-from-photos-55eb</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-simple-tool-to-remove-matcha-filters-from-photos-55eb</guid>
      <description>&lt;p&gt;Hi dev.to community,&lt;br&gt;
I recently built a small web tool called Remove Matcha Filter:&lt;br&gt;
&lt;a href="https://removematchafilter.org" rel="noopener noreferrer"&gt;https://removematchafilter.org&lt;/a&gt;&lt;br&gt;
The idea is simple: many photos look overly green, soft, or tinted after using a “matcha” style filter. This tool helps remove that color cast and bring the image closer to a cleaner, more natural look.&lt;/p&gt;

&lt;p&gt;It is still a focused little project, not a huge product. My goal is to make it fast, easy to use, and useful for people who just want their original photo tones back without opening a heavy editing app.&lt;/p&gt;

&lt;p&gt;If you have a photo with this kind of greenish filter, feel free to try it here:&lt;br&gt;
👉 &lt;a href="https://removematchafilter.org" rel="noopener noreferrer"&gt;Remove Matcha Filter&lt;/a&gt;&lt;br&gt;
I’d also appreciate any feedback on the result quality, UI, or wording. Thanks for taking a look.&lt;/p&gt;

</description>
      <category>removematchafilter</category>
      <category>matchafilterremover</category>
    </item>
    <item>
      <title>I Built a Free NBA-Style Player Builder and Season Simulator</title>
      <dc:creator>彭勇</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:49:34 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-free-nba-style-player-builder-and-season-simulator-35a0</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-free-nba-style-player-builder-and-season-simulator-35a0</guid>
      <description>&lt;p&gt;I’ve been working on a small basketball web game called &lt;strong&gt;Build a Hooper&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You can try it here: &lt;a href="https://buildahooper.app/" rel="noopener noreferrer"&gt;https://buildahooper.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea is simple: instead of just picking one ready-made player, you build your own hooper piece by piece.&lt;/p&gt;

&lt;p&gt;You spin historic teams, draft legendary players, take one skill from each of them, and then run your custom player through a full 82-game season simulation.&lt;/p&gt;

&lt;p&gt;No signup is required to start playing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I made it
&lt;/h2&gt;

&lt;p&gt;A lot of basketball builder games are fun for a few minutes, but they usually stop at the draft card.&lt;/p&gt;

&lt;p&gt;I wanted the build to matter after the picks were done.&lt;/p&gt;

&lt;p&gt;So in Build a Hooper, the fun is not only “Can I make a stacked player?” but also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does this build actually win games?&lt;/li&gt;
&lt;li&gt;Is it balanced enough for a full season?&lt;/li&gt;
&lt;li&gt;Did I waste too many picks chasing scoring?&lt;/li&gt;
&lt;li&gt;Can this player survive playoff-style pressure?&lt;/li&gt;
&lt;li&gt;Would I build the same way next time?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part matters. I wanted each run to feel replayable, not solved after one try.&lt;/p&gt;




&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The main draft flow has &lt;strong&gt;13 skill picks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Each round gives you a team/player pool. You choose a player, then steal one skill from that player to add to your custom hooper.&lt;/p&gt;

&lt;p&gt;After the draft, the site simulates an &lt;strong&gt;82-game season&lt;/strong&gt; and gives you a result based on the player you built.&lt;/p&gt;

&lt;p&gt;There are also different draft modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classic&lt;/strong&gt;: you can see the ratings and make cleaner decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blind&lt;/strong&gt;: ratings are hidden, so you have to trust names, roles, and instinct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chaos&lt;/strong&gt;: the available skill board is limited each round, so you can’t always build the exact player you planned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classic is probably the best place to start. Blind and Chaos are more interesting once you already understand the system.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you can try
&lt;/h2&gt;

&lt;p&gt;If you want the fastest path, start here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://buildahooper.app/" rel="noopener noreferrer"&gt;Play Build a Hooper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to understand the rules first:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://buildahooper.app/how-to-play/" rel="noopener noreferrer"&gt;How to Play&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are also a few extra pages if you want to explore more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://buildahooper.app/nba-hooper-lab/" rel="noopener noreferrer"&gt;NBA Hooper Lab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://buildahooper.app/all-time-teams/" rel="noopener noreferrer"&gt;All-Time Teams&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://buildahooper.app/leaderboard/" rel="noopener noreferrer"&gt;Leaderboard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://buildahooper.app/blog/" rel="noopener noreferrer"&gt;Blog and guides&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I’m still improving
&lt;/h2&gt;

&lt;p&gt;This is an independent fan project, and I’m still tuning it.&lt;/p&gt;

&lt;p&gt;A few things I’m thinking about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;making the simulation feel more transparent&lt;/li&gt;
&lt;li&gt;improving the result page&lt;/li&gt;
&lt;li&gt;adding more interesting achievement paths&lt;/li&gt;
&lt;li&gt;making the leaderboard more fun to chase&lt;/li&gt;
&lt;li&gt;balancing draft choices so there are fewer obvious picks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it and something feels confusing, too easy, too random, or just not fun, I’d genuinely like to hear it.&lt;/p&gt;

&lt;p&gt;The site is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://buildahooper.app/" rel="noopener noreferrer"&gt;https://buildahooper.app/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thanks for checking it out.&lt;/p&gt;

</description>
      <category>buildabucket</category>
      <category>buildahooper</category>
      <category>buildabucketnba</category>
    </item>
    <item>
      <title>I Built a Free Text Repeater Tool for WhatsApp – Here's What I Learned About SEO and Cloudflare Pages Deployment</title>
      <dc:creator>彭勇</dc:creator>
      <pubDate>Tue, 31 Mar 2026 01:26:57 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-free-text-repeater-tool-for-whatsapp-heres-what-i-learned-about-seo-and-cloudflare-46bc</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-free-text-repeater-tool-for-whatsapp-heres-what-i-learned-about-seo-and-cloudflare-46bc</guid>
      <description>&lt;h2&gt;
  
  
  Why I Built This
&lt;/h2&gt;

&lt;p&gt;A friend kept manually copy-pasting "sorry 🙏" over and over to send dramatic apology messages on WhatsApp. I watched him do it for about 30 seconds before thinking — this is a solved problem.&lt;/p&gt;

&lt;p&gt;A few days later, &lt;a href="https://textrepeater.top" rel="noopener noreferrer"&gt;TextRepeater&lt;/a&gt; was live.&lt;/p&gt;

&lt;p&gt;It lets you type any text, set how many times to repeat it (up to 1000), pick a separator, add emoji scene modes, and apply 15 visual effects — all in the browser, no account, no backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;Deliberately simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 14&lt;/strong&gt; with &lt;code&gt;output: 'export'&lt;/code&gt; (fully static)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for styling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; for hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; for CI/CD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No database. No API routes. No auth. Everything runs client-side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deployment: GitHub Actions + Cloudflare Pages Direct Upload
&lt;/h2&gt;

&lt;p&gt;The interesting part was automating deployment. Cloudflare Pages has a GitHub App integration, but connecting it via API throws a cryptic error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error code: 8000011 — There is an internal issue with your Cloudflare Pages Git installation.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens because the GitHub App OAuth flow &lt;strong&gt;requires browser authorization&lt;/strong&gt; — it can't be done purely through the API.&lt;/p&gt;

&lt;p&gt;The workaround: use &lt;strong&gt;Direct Upload mode&lt;/strong&gt; instead of GitHub integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Create the Pages project via API (no &lt;code&gt;source&lt;/code&gt; field)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/pages/projects"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "text-repeater",
    "production_branch": "main"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2 — GitHub Actions workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Cloudflare Pages&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare/wrangler-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;apiToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CLOUDFLARE_API_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CLOUDFLARE_ACCOUNT_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pages deploy out --project-name=text-repeater --branch=main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every push to &lt;code&gt;main&lt;/code&gt; triggers a full build and deploy. Zero manual steps.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next.js Config for Static Export
&lt;/h2&gt;

&lt;p&gt;For Cloudflare Pages to serve routes correctly:&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;// next.config.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trailingSlash&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="c1"&gt;// important for Cloudflare routing&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;unoptimized&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="c1"&gt;// required for static export&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="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;trailingSlash: true&lt;/code&gt; is the one that trips people up — without it, direct URL navigation breaks on Cloudflare Pages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Custom Domain via Cloudflare API
&lt;/h2&gt;

&lt;p&gt;After purchasing the domain, instead of clicking through the dashboard, I wired everything via API:&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="c"&gt;# 1. Bind domain to Pages project&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/pages/projects/text-repeater/domains"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "textrepeater.top"}'&lt;/span&gt;

&lt;span class="c"&gt;# 2. Add CNAME record (proxied = orange cloud enabled)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/zones/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ZONE_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dns_records"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "type": "CNAME",
    "name": "textrepeater.top",
    "content": "text-repeater-9lr.pages.dev",
    "proxied": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSL certificate issued automatically within ~2 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  SEO for a Static Next.js Site
&lt;/h2&gt;

&lt;p&gt;Launching a new domain means starting from zero authority. Here's what I set up on day one:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Canonical URLs (every page)
&lt;/h3&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;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://textrepeater.top/apology-mode/&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, search engines might treat &lt;code&gt;http&lt;/code&gt; vs &lt;code&gt;https&lt;/code&gt; or trailing slash variants as duplicate pages, splitting your ranking signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. robots.txt
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Allow&lt;/span&gt;: /

&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;textrepeater&lt;/span&gt;.&lt;span class="n"&gt;top&lt;/span&gt;/&lt;span class="n"&gt;sitemap&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obvious but easy to forget on a static site.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. sitemap.xml with lastmod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://textrepeater.top/&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;lastmod&amp;gt;&lt;/span&gt;2026-03-30&lt;span class="nt"&gt;&amp;lt;/lastmod&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;changefreq&amp;gt;&lt;/span&gt;weekly&lt;span class="nt"&gt;&amp;lt;/changefreq&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;priority&amp;gt;&lt;/span&gt;1.0&lt;span class="nt"&gt;&amp;lt;/priority&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Open Graph + Twitter Card
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;siteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TextRepeater&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://textrepeater.top&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Text Repeater Online — Repeat Any Text Instantly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&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="nx"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Text Repeater Online — Repeat Any Text Instantly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. JSON-LD Structured Data
&lt;/h3&gt;

&lt;p&gt;For the homepage:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;
  &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;__html&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@context&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;https://schema.org&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;@type&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;WebSite&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TextRepeater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://textrepeater.top&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="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For tool pages, use &lt;code&gt;WebApplication&lt;/code&gt; with &lt;code&gt;offers.price: '0'&lt;/code&gt; — this can show a free badge in rich results.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Scene Mode" Idea
&lt;/h2&gt;

&lt;p&gt;The differentiator I'm most proud of is Scene Modes — instead of just repeating text, you pick a context (apology, birthday, anger) and the tool automatically inserts themed emoji patterns throughout your message.&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="err"&gt;Input:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"I'm really sorry"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Output:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"🙏 I'm really sorry 😔 I'm really sorry 💦 I'm really sorry 🥺"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It maps to specific long-tail keywords like "apology text generator" and "sorry message generator for WhatsApp" — much lower competition than "text repeater" alone.&lt;/p&gt;

&lt;p&gt;Each Scene Mode is its own page with targeted metadata, giving the site multiple entry points for organic traffic.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Completing the remaining Scene Modes (Heart, Anger, Birthday, Like)&lt;/li&gt;
&lt;li&gt;Adding more WhatsApp-specific templates&lt;/li&gt;
&lt;li&gt;Tracking which visual effects actually get used&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://textrepeater.top" rel="noopener noreferrer"&gt;textrepeater.top&lt;/a&gt;&lt;/strong&gt; — free, no sign-up, works on mobile.&lt;/p&gt;

&lt;p&gt;If you're building something similar (static Next.js + Cloudflare Pages), the Direct Upload + GitHub Actions approach is the smoothest path I've found. Happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>textrepeater</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
