<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: solca</title>
    <description>The latest articles on DEV Community by solca (@solca).</description>
    <link>https://dev.to/solca</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4034713%2F3d3c4681-d04b-4e25-bfd9-997e357f41bb.png</url>
      <title>DEV Community: solca</title>
      <link>https://dev.to/solca</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/solca"/>
    <language>en</language>
    <item>
      <title>How I built a suite of self-destructing web tools on Cloudflare (solo, free tier)</title>
      <dc:creator>solca</dc:creator>
      <pubDate>Sat, 18 Jul 2026 06:08:25 +0000</pubDate>
      <link>https://dev.to/solca/how-i-built-a-suite-of-self-destructing-web-tools-on-cloudflare-solo-free-tier-2dpe</link>
      <guid>https://dev.to/solca/how-i-built-a-suite-of-self-destructing-web-tools-on-cloudflare-solo-free-tier-2dpe</guid>
      <description>&lt;p&gt;Most cloud tools are built around &lt;strong&gt;saving&lt;/strong&gt; things. But a lot of the time I don't want to save anything — I just want to hand something off for a few minutes and have it disappear on its own.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;p&gt;So the correctness lives on the &lt;strong&gt;read path&lt;/strong&gt;, and cron is just the janitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readAndConsume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntryKind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT * FROM entries WHERE id = ?`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consumed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`DELETE FROM entries WHERE id = ?`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// lazy delete&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...serve it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;consumed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;For one-time secrets, the row is deleted the moment it's opened. For files, the R2 object is deleted with &lt;code&gt;ctx.waitUntil&lt;/code&gt; &lt;em&gt;after&lt;/em&gt; the response streams out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;burn_after_read&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// delete the file after serving it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ...headers */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;p&gt;The fix was to drop the proxy entirely and do the redirect in &lt;code&gt;next.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;redirects&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No middleware needed. If you're moving a Next 16 app to Cloudflare, watch out for this one.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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