<?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: Denny Kot</title>
    <description>The latest articles on DEV Community by Denny Kot (@dennykot).</description>
    <link>https://dev.to/dennykot</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%2F451974%2Fcd6d3bf2-03c2-4020-8253-e1f2c397e2c0.png</url>
      <title>DEV Community: Denny Kot</title>
      <link>https://dev.to/dennykot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dennykot"/>
    <language>en</language>
    <item>
      <title>I built 546 web tools that never upload your files</title>
      <dc:creator>Denny Kot</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:40:14 +0000</pubDate>
      <link>https://dev.to/dennykot/i-built-546-web-tools-that-never-upload-your-files-49ad</link>
      <guid>https://dev.to/dennykot/i-built-546-web-tools-that-never-upload-your-files-49ad</guid>
      <description>&lt;p&gt;Most "free online" converters work the same way. You upload your file, a server processes it, and you download the result. That file might be a passport scan, a contract or a private video, and now it sits on someone else's server.&lt;/p&gt;

&lt;p&gt;Browsers are fast enough now that this round trip often isn't needed. So I built &lt;strong&gt;&lt;a href="https://uttily.com" rel="noopener noreferrer"&gt;Uttily&lt;/a&gt;&lt;/strong&gt;: 546 tools across 29 sites, and none of them has a backend. Merging PDFs, compressing video, removing backgrounds and transcribing audio all happen on your own device.&lt;/p&gt;

&lt;p&gt;This post covers how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: no server, ever
&lt;/h2&gt;

&lt;p&gt;The whole project rests on one constraint: &lt;strong&gt;all processing happens in the browser&lt;/strong&gt; (JavaScript, Canvas or WebAssembly). No uploads, no API calls with user data, no backend.&lt;/p&gt;

&lt;p&gt;That constraint helps in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy is a fact, not a promise.&lt;/strong&gt; You can open DevTools → Network and see that your file never leaves the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting is almost free.&lt;/strong&gt; Every site is static HTML on Cloudflare Pages. A traffic spike costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works offline.&lt;/strong&gt; Once the page has loaded, most tools keep working without a connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What runs in the browser
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Merge, split, sign and redact PDFs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pdf-lib&lt;/code&gt;, &lt;code&gt;pdfjs-dist&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Convert, trim and compress video&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@ffmpeg/ffmpeg&lt;/code&gt; (ffmpeg.wasm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove image backgrounds&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@imgly/background-removal&lt;/code&gt; (ONNX model)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speech to text, translation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@huggingface/transformers&lt;/code&gt; (Whisper, Opus-MT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCR&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tesseract.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC → JPG&lt;/td&gt;
&lt;td&gt;&lt;code&gt;heic2any&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ZIP / TAR&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fflate&lt;/code&gt; plus a small hand-written TAR writer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hashing, encryption&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crypto.subtle&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few years ago, several of these would have needed a GPU server. Now they need a tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: one monorepo, 29 sites
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/shared-ui/     layouts, SEO, i18n, client helpers
projects/pdf-tools/     → pdf.uttily.com
projects/image-tools/   → image.uttily.com
projects/media-tools/   → media.uttily.com
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each project is an independent &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; site with its own subdomain. They all share &lt;code&gt;shared-ui&lt;/code&gt;, so a tool page is mostly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a &lt;code&gt;ToolMeta&lt;/code&gt; entry (slug, title, SEO title),&lt;/li&gt;
&lt;li&gt;a widget component: the markup plus an inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; with the real logic,&lt;/li&gt;
&lt;li&gt;a page that puts the widget inside a shared &lt;code&gt;ToolLayout&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The layout adds everything else: breadcrumbs, FAQ schema, related tools, a favorites star, keyboard shortcuts, "Copy link" with the widget's state in the URL, and a "Copy image" button next to every image download.&lt;/p&gt;

&lt;h2&gt;
  
  
  The helpers that made 546 tools manageable
&lt;/h2&gt;

&lt;p&gt;Most tools follow the same pattern: get a file, process it, return a file. So the shared client library handles input and output once:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;wireDrop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;downloadBlob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;withExtension&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tools/shared-ui/lib/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;wireDrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dropzone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nf"&gt;downloadBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;withExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpg&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;p&gt;&lt;code&gt;wireDrop&lt;/code&gt; handles drag and drop, the file picker, &lt;strong&gt;Ctrl+V paste&lt;/strong&gt;, PWA "Open with" launches, and files passed in from another tool. Because &lt;code&gt;downloadBlob&lt;/code&gt; emits an event, the layout can show a &lt;strong&gt;"Continue with…"&lt;/strong&gt; bar. After converting HEIC → JPG, one click sends the result to the compressor, with no new upload or download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading big engines without slowing the page
&lt;/h2&gt;

&lt;p&gt;ffmpeg.wasm is around 30 MB, and the background-removal model is similar. Loading them up front would ruin Core Web Vitals. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the page and widget load first and are interactive straight away,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;preloadAfterLoad()&lt;/code&gt; warms the engine &lt;strong&gt;after&lt;/strong&gt; the &lt;code&gt;load&lt;/code&gt; event, when the browser is idle,&lt;/li&gt;
&lt;li&gt;it skips preloading when the user has Data Saver turned on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When someone actually drops a file, the engine is usually already in the cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translation without making widgets translatable
&lt;/h2&gt;

&lt;p&gt;All tools come in 11 languages. The trick was deciding &lt;strong&gt;what not to translate&lt;/strong&gt;: widget buttons and scripts stay in English, and widget behavior never depends on display text (state lives in &lt;code&gt;data-*&lt;/code&gt; attributes). Only the content is translated: title, guide, steps and FAQ, stored as one JSON file per language per tool.&lt;/p&gt;

&lt;p&gt;A single dynamic route (&lt;code&gt;[lang]/[...slug].astro&lt;/code&gt;) finds every widget and translation with &lt;code&gt;import.meta.glob&lt;/code&gt;, and &lt;strong&gt;the build fails if any combination is missing&lt;/strong&gt;. That check has caught many mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick one hard constraint and design around it.&lt;/strong&gt; "No server" made hundreds of later decisions for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the boring parts in a shared layer early.&lt;/strong&gt; Tool number 500 took minutes because the layout already did everything except the actual processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the build enforce your rules.&lt;/strong&gt; Missing translations, overly long SEO titles and broken cross-links all fail CI.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;You can try it at &lt;strong&gt;&lt;a href="https://uttily.com" rel="noopener noreferrer"&gt;uttily.com&lt;/a&gt;&lt;/strong&gt;. Open the Network tab, drop a file into any of these, and watch nothing upload:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PDF:&lt;/strong&gt; &lt;a href="https://pdf.uttily.com/merge-pdf" rel="noopener noreferrer"&gt;merge&lt;/a&gt;, &lt;a href="https://pdf.uttily.com/compress-pdf" rel="noopener noreferrer"&gt;compress&lt;/a&gt;, &lt;a href="https://pdf.uttily.com/sign-pdf" rel="noopener noreferrer"&gt;sign&lt;/a&gt;, &lt;a href="https://pdf.uttily.com/redact-pdf" rel="noopener noreferrer"&gt;redact&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images:&lt;/strong&gt; &lt;a href="https://image.uttily.com/heic-to-jpg" rel="noopener noreferrer"&gt;HEIC to JPG&lt;/a&gt;, &lt;a href="https://image.uttily.com/image-compressor" rel="noopener noreferrer"&gt;compress&lt;/a&gt;, &lt;a href="https://image.uttily.com/background-remover" rel="noopener noreferrer"&gt;remove background&lt;/a&gt;, &lt;a href="https://image.uttily.com/face-blur" rel="noopener noreferrer"&gt;blur faces&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video &amp;amp; audio:&lt;/strong&gt; &lt;a href="https://media.uttily.com/compress-video" rel="noopener noreferrer"&gt;compress video&lt;/a&gt;, &lt;a href="https://media.uttily.com/video-to-gif" rel="noopener noreferrer"&gt;video to GIF&lt;/a&gt;, &lt;a href="https://media.uttily.com/transcribe-audio" rel="noopener noreferrer"&gt;transcribe audio&lt;/a&gt;, &lt;a href="https://media.uttily.com/auto-subtitles" rel="noopener noreferrer"&gt;auto subtitles&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files:&lt;/strong&gt; &lt;a href="https://files.uttily.com/zip-files" rel="noopener noreferrer"&gt;ZIP files&lt;/a&gt;, &lt;a href="https://files.uttily.com/extract-archive" rel="noopener noreferrer"&gt;extract any archive&lt;/a&gt;, &lt;a href="https://files.uttily.com/file-type-detector" rel="noopener noreferrer"&gt;detect file type&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions about the setup are welcome in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
