<?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: PENG YONG</title>
    <description>The latest articles on DEV Community by PENG YONG (@_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%2F47d24cd4-5430-4806-9a9e-72ed5746131c.png</url>
      <title>DEV Community: PENG YONG</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 face-rating tool that never sees your photo — here's what I learned about on-device ML</title>
      <dc:creator>PENG YONG</dc:creator>
      <pubDate>Wed, 14 Oct 2026 01:11:19 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-face-rating-tool-that-never-sees-your-photo-heres-what-i-learned-about-on-device-ml-3k49</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/i-built-a-face-rating-tool-that-never-sees-your-photo-heres-what-i-learned-about-on-device-ml-3k49</guid>
      <description>&lt;h2&gt;
  
  
  Why I Built This
&lt;/h2&gt;

&lt;p&gt;A friend told me his face shape was better than mine.&lt;/p&gt;

&lt;p&gt;I'm not going to argue with him — I have no data. So I went looking for one of those "what's your face shape" tools. Every single one wanted me to upload a photo of my face. To a server. Run by someone I don't know.&lt;/p&gt;

&lt;p&gt;Look, I'm a developer. I know what happens to uploaded images. I've written the code that stores them. I did not want a photo of my own face sitting in someone's bucket with a &lt;code&gt;Date.now()&lt;/code&gt; filename and an ACL I can't see.&lt;/p&gt;

&lt;p&gt;So I built the version I wanted: one that measures your face and never uploads it. Not "we delete it after 24 hours" — the photo literally never leaves the browser tab. It goes into a canvas, MediaPipe reads it locally, and that's the end of it.&lt;/p&gt;

&lt;p&gt;It's called FaceRuler. It's free. There's no sign-up — that part wasn't a growth decision, I was just too lazy to build auth for a tool that doesn't need an account.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Next.js 16 (App Router)&lt;/li&gt;
&lt;li&gt;MediaPipe FaceLandmarker — 478 landmarks, compiled to WASM; the model is ~3.6 MB and both it and the WASM runtime are served from my own origin&lt;/li&gt;
&lt;li&gt;Cloudflare Workers via OpenNext, D1 for the few tables I actually need&lt;/li&gt;
&lt;li&gt;A hosted checkout for the paid deep report (the free report is the whole measurement, not a teaser)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part that matters: &lt;strong&gt;there is no server-side inference.&lt;/strong&gt; Nothing to opt out of, nothing to trust me about. The network tab stays quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring is just math (that's the point)
&lt;/h2&gt;

&lt;p&gt;Here's the decision that surprised people most: there is no LLM anywhere in the scoring.&lt;/p&gt;

&lt;p&gt;I could have asked a model to "rate this face". I didn't, for two reasons. One, it would mean uploading the photo — dead on arrival. Two, a score you can't reproduce is a score you can't argue with, and people who use face-rating tools &lt;em&gt;argue&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So it's geometry. Every sub-metric is scored with a Gaussian falloff around a reference target:&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;targetScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;100&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&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;Eight sub-metrics (symmetry, facial thirds, FWHR, canthal tilt, jaw ratio, eye spacing, eye aspect, golden ratios) roll up into five weighted dimensions. The weighted overall maps onto the PSL 0–8 scale with a logistic curve anchored so an average face lands at exactly 4.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// overall 50 -&amp;gt; exactly 4.0 on the 0-8 scale&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overallToPsl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overall&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Median at 4.0, not 5 — the PSL scale is deliberately stricter than a casual 1–10 rating, and the percentile uses a normal distribution (mean 4.0, SD 0.9).&lt;/p&gt;

&lt;p&gt;Because it's all pure functions, the entire engine is unit-tested (12/12 green) and "recalibrate" is a one-line change instead of a retrain. Same landmarks in, same report out, every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that were harder than they look
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MediaPipe coordinates will quietly shrink your face.&lt;/strong&gt; The landmarks come back normalized: x divided by image width, y divided by image height. Compute a height/width ratio naively and a 3:4 portrait reads about 25% "shorter" than the same face on a square canvas — enough to flip the score. You have to scale the y component back into pixel-equivalent space. Nobody warns you about this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tilted photo shouldn't change your score.&lt;/strong&gt; So before computing anything, I measure the midline angle from forehead-top to chin and rotate every landmark into a roll-corrected frame. Vertical distances — like the FWHR numerator — fall apart without it.&lt;/p&gt;

&lt;p&gt;One more that isn't a bug but deserves to be documented: the academic FWHR ratio (1.85–1.9) is measured on physical photographs. In normalized landmark space, the nasion-to-upper-lip distance runs ~36% of bizygomatic width, so the numbers aren't comparable at all. I calibrated my own center (2.75) from landmark batches instead of pretending the paper value applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  The front-end bug that ate a whole evening
&lt;/h2&gt;

&lt;p&gt;React 19's synthetic &lt;code&gt;onChange&lt;/code&gt; does not fire for programmatically dispatched change events.&lt;/p&gt;

&lt;p&gt;My upload component has to work for real users &lt;em&gt;and&lt;/em&gt; for my own automated tests, which inject a &lt;code&gt;File&lt;/code&gt; via &lt;code&gt;DataTransfer&lt;/code&gt;. The synthetic handler was silent in the injected path. Real users would have been fine — which is exactly why it's dangerous. The test would have lied to me.&lt;/p&gt;

&lt;p&gt;Fix: drop the synthetic handler, wire a native &lt;code&gt;addEventListener&lt;/code&gt; through a ref. Turns out it's more robust for real users too.&lt;/p&gt;

&lt;p&gt;And a rule I now enforce everywhere: &lt;strong&gt;never put a &lt;code&gt;ref&lt;/code&gt;-dependent DOM node inside a conditional render.&lt;/strong&gt; My result &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; lived inside &lt;code&gt;report &amp;amp;&amp;amp; (...)&lt;/code&gt;. Before the first report, &lt;code&gt;canvasRef.current&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, the draw function's guard returns early, and you get… nothing. No error, no output. I hit this exact bug twice, in two different components, before writing it down as a rule.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Server-side license verification. Right now entitlements live in &lt;code&gt;localStorage&lt;/code&gt;, which means clearing your cache loses your purchase. I say that out loud in the docs instead of hiding it.&lt;/li&gt;
&lt;li&gt;A second tool that shares the same credit system.&lt;/li&gt;
&lt;li&gt;Quarterly recalibration from anonymous aggregate data.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;It's live and free, no sign-up: &lt;strong&gt;&lt;a href="https://faceruler.com" rel="noopener noreferrer"&gt;https://faceruler.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop in a photo and you get your face shape, a PSL score, a percentile, and all eight metric readings — with the formulas behind each number instead of just a verdict. Your photo stays on your machine; you can watch the network tab if you don't believe me.&lt;/p&gt;

&lt;p&gt;Genuinely curious: if you've shipped something where "we don't upload it" is the entire pitch, how did you get people to &lt;em&gt;believe&lt;/em&gt; you? I ended up showing the calculation, but I'm not convinced that's the best answer.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your photo never leaves the browser — building a client-side face analysis app with MediaPipe</title>
      <dc:creator>PENG YONG</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:55:41 +0000</pubDate>
      <link>https://dev.to/_e4aae51541f4c5468758fb/your-photo-never-leaves-the-browser-building-a-client-side-face-analysis-app-with-mediapipe-4eka</link>
      <guid>https://dev.to/_e4aae51541f4c5468758fb/your-photo-never-leaves-the-browser-building-a-client-side-face-analysis-app-with-mediapipe-4eka</guid>
      <description>&lt;p&gt;FaceRuler is a free face shape detector and attractiveness test. You drop in one photo, and a few seconds later you get your face shape (one of seven), a score on the PSL 0–8 scale, a casual 1–10 rating, a percentile, and a breakdown of eight facial metrics — all computed on your device. The photo is never uploaded. Not "we delete it after processing" — it never leaves the browser tab.&lt;/p&gt;

&lt;p&gt;That single constraint shapes almost every engineering decision in the app. Here's the pipeline, and the parts that were actually hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-device at all
&lt;/h2&gt;

&lt;p&gt;Most "rate my face" tools send your selfie to a server and run a model there. That's a big ask: it's a photo of your face, tied to your identity, going to infrastructure you can't inspect. And for this specific product category — attractiveness scoring — the trust problem &lt;em&gt;is&lt;/em&gt; the product. If the privacy claim isn't literally, verifiably true, nothing else matters.&lt;/p&gt;

&lt;p&gt;So inference runs in the browser with MediaPipe's FaceLandmarker (478 landmarks) compiled to WASM. The model (~3.6 MB) and the WASM runtime are served from our own origin, so there's no third-party request at inference time either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline: detect → measure → map
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detect.&lt;/strong&gt; MediaPipe returns 478 normalized landmarks. We draw them over a canvas as a visual receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure.&lt;/strong&gt; Pure geometry converts landmarks into ratios — facial thirds, FWHR, canthal tilt, jaw ratio, and so on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map.&lt;/strong&gt; A deterministic function turns those ratios into 0–100 sub-scores, then into the PSL 0–8 scale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No LLM. No "AI vibes". The same landmarks always produce the same report. That's a product decision as much as a technical one: a score you can't reproduce is a score you can't argue with — and this audience argues.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring engine is just math (that's the point)
&lt;/h2&gt;

&lt;p&gt;Every sub-metric is scored with a Gaussian falloff around a reference target:&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;targetScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;100&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&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;Eight sub-metrics roll up into five dimensions with fixed weights — Symmetry 0.2, Proportions 0.25, Structure 0.25, Eyes 0.15, Harmony 0.15. The weighted overall (0–100) maps to PSL with a logistic curve anchored so a perfectly average profile lands at exactly 4.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// overall 50 -&amp;gt; exactly 4.0 on the 0-8 PSL scale&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overallToPsl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overall&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Median at 4.0, not 5. The PSL scale is deliberately stricter than a casual rating. Percentile uses a normal distribution with mean 4.0 and SD 0.9. Because it's all pure functions, the whole engine is unit-tested (12/12 green) and recalibration is a one-line change, not a retrain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that were genuinely hard
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Head roll.&lt;/strong&gt; A tilted photo shouldn't change your score. We measure the midline angle from forehead-top to chin, then rotate every landmark into a roll-corrected frame before computing anything. Vertical distances — like the FWHR numerator — are especially sensitive to this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Aspect ratio silently shrinking the face index.&lt;/strong&gt; MediaPipe returns normalized coordinates: x divided by image width, y divided by image height. Compute a height/width ratio naively and a 3:4 portrait reads about 25% "shorter" than the same face on a square canvas. We correct by scaling the y component back into pixel-equivalent space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. FWHR doesn't mean what the papers say.&lt;/strong&gt; The academic facial width-to-height ratio (1.85–1.9) is measured on physical photographs. In normalized landmark space the nasion-to-upper-lip distance runs ~36% of bizygomatic width, so the numbers simply aren't comparable. We calibrated our own center (2.75) from landmark batches and documented the mismatch instead of pretending the paper value applied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Harmony is not the average.&lt;/strong&gt; Averaging sub-scores rewards a face that's mediocre everywhere. We wanted the opposite — consistency. So "Harmony" is derived from the &lt;em&gt;standard deviation&lt;/em&gt; of the sub-scores: one dragging feature lowers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Front-end lessons that cost the most hours
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;React 19's synthetic &lt;code&gt;onChange&lt;/code&gt; doesn't fire for programmatically dispatched change events.&lt;/strong&gt; Our upload component has to work both for real users and for automated tests that inject a &lt;code&gt;File&lt;/code&gt; via &lt;code&gt;DataTransfer&lt;/code&gt;. The synthetic handler didn't fire in the injected path. Fix: drop the synthetic handler and wire a native &lt;code&gt;addEventListener&lt;/code&gt; through a ref. Bonus — it's more robust for real users too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never put a &lt;code&gt;ref&lt;/code&gt;-dependent DOM node inside a conditional render.&lt;/strong&gt; Our result &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; lived inside &lt;code&gt;report &amp;amp;&amp;amp; (...)&lt;/code&gt;. Before the first report, &lt;code&gt;canvasRef.current&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, so the draw function's guard silently returned — no error, no output, just nothing. We hit this exact bug twice, in two different components. The rule we now enforce: ref-dependent nodes are always mounted and toggled with &lt;code&gt;hidden&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single dependency mismatch can 500 your entire content layer.&lt;/strong&gt; All MDX pages broke with &lt;code&gt;TypeError: files.map is not a function&lt;/code&gt;. Root cause: the lockfile resolved &lt;code&gt;fumadocs-core&lt;/code&gt; to 15.8.5 while &lt;code&gt;fumadocs-mdx&lt;/code&gt; 11.10.1 expected the 15.7.x interface. Pinning &lt;code&gt;fumadocs-core@15.7.13&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node's &lt;code&gt;undici&lt;/code&gt; fetch ignores your system proxy.&lt;/strong&gt; Server-side OAuth silently timed out and surfaced as a misleading &lt;code&gt;invalid_code&lt;/code&gt;. A &lt;code&gt;ProxyAgent&lt;/code&gt; global dispatcher for dev fixed it — and no-ops in production on Workers.&lt;/p&gt;

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

&lt;p&gt;Next.js 16 (App Router), deployed on &lt;strong&gt;Cloudflare Workers via OpenNext&lt;/strong&gt;, with D1 for the license/entitlement tables and a hosted checkout for the paid deep report. MediaPipe runs entirely client-side; the server never sees an image.&lt;/p&gt;

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

&lt;p&gt;It's live, free, and no sign-up: &lt;strong&gt;&lt;a href="https://faceruler.com" rel="noopener noreferrer"&gt;https://faceruler.com&lt;/a&gt;&lt;/strong&gt; — one photo, on-device, and you get the full breakdown including the exact formulas behind every number.&lt;/p&gt;

&lt;p&gt;If you're building something similar — client-side ML, or a privacy-sensitive tool where "we don't upload it" is the whole pitch — I'd love to hear how you handled the trust story.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a free image upscaler on Cloudflare Workers — here's what bit me</title>
      <dc:creator>PENG YONG</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>PENG YONG</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>PENG YONG</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>PENG YONG</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>PENG YONG</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>
