<?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: quickconv</title>
    <description>The latest articles on DEV Community by quickconv (@cc_quickconv_ff5b94a1d015).</description>
    <link>https://dev.to/cc_quickconv_ff5b94a1d015</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3884489%2Fdeeeed19-f1ff-48e7-bd96-4e922c2c08a6.jpg</url>
      <title>DEV Community: quickconv</title>
      <link>https://dev.to/cc_quickconv_ff5b94a1d015</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cc_quickconv_ff5b94a1d015"/>
    <language>en</language>
    <item>
      <title>WebP vs AVIF vs HEIC: The Real-World Image Format Comparison (2026)</title>
      <dc:creator>quickconv</dc:creator>
      <pubDate>Sat, 02 May 2026 03:42:27 +0000</pubDate>
      <link>https://dev.to/cc_quickconv_ff5b94a1d015/webp-vs-avif-vs-heic-the-real-world-image-format-comparison-2026-14bd</link>
      <guid>https://dev.to/cc_quickconv_ff5b94a1d015/webp-vs-avif-vs-heic-the-real-world-image-format-comparison-2026-14bd</guid>
      <description>&lt;h1&gt;
  
  
  WebP vs AVIF vs HEIC: The Real-World Image Format Comparison (2026)
&lt;/h1&gt;

&lt;p&gt;JPEG has been the default image format for over 30 years. But in 2026, three contenders have matured enough to replace it for most use cases: WebP, AVIF, and HEIC.&lt;/p&gt;

&lt;p&gt;This isn't a theoretical comparison. I run &lt;a href="https://quickconv.cc" rel="noopener noreferrer"&gt;QuickConv&lt;/a&gt;, an image conversion service that processes thousands of conversions per day. Here's what I've learned from real conversion data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Short Answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;vs JPEG (file size)&lt;/th&gt;
&lt;th&gt;Browser support&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;~25–34% smaller&lt;/td&gt;
&lt;td&gt;All modern browsers&lt;/td&gt;
&lt;td&gt;Safe default today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;~50–70% smaller&lt;/td&gt;
&lt;td&gt;Chrome 85+, Firefox 93+, Safari 16+&lt;/td&gt;
&lt;td&gt;Highest compression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC&lt;/td&gt;
&lt;td&gt;~40–50% smaller&lt;/td&gt;
&lt;td&gt;Safari only (natively)&lt;/td&gt;
&lt;td&gt;Apple ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you just want a recommendation: &lt;strong&gt;use AVIF with WebP fallback&lt;/strong&gt;. HEIC is great for iPhone photos but a headache for the web.&lt;/p&gt;




&lt;h2&gt;
  
  
  WebP
&lt;/h2&gt;

&lt;p&gt;WebP was developed by Google in 2010 and has been the "safe" next-gen format for years. Every major browser has supported it since 2020.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What makes it good:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supports both lossy and lossless compression&lt;/li&gt;
&lt;li&gt;Supports transparency (alpha channel) — unlike JPEG&lt;/li&gt;
&lt;li&gt;Supports animation — unlike JPEG and HEIC&lt;/li&gt;
&lt;li&gt;Available in every browser including older versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Real compression numbers:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Using Sharp (the Node.js image processing library) at quality 80, here's what I see in practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image type&lt;/th&gt;
&lt;th&gt;JPEG size&lt;/th&gt;
&lt;th&gt;WebP size&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Portrait photo (3MB)&lt;/td&gt;
&lt;td&gt;3,000 KB&lt;/td&gt;
&lt;td&gt;2,100 KB&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product shot (1MB)&lt;/td&gt;
&lt;td&gt;1,000 KB&lt;/td&gt;
&lt;td&gt;680 KB&lt;/td&gt;
&lt;td&gt;32%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot (500KB)&lt;/td&gt;
&lt;td&gt;500 KB&lt;/td&gt;
&lt;td&gt;320 KB&lt;/td&gt;
&lt;td&gt;36%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Illustration (2MB)&lt;/td&gt;
&lt;td&gt;2,000 KB&lt;/td&gt;
&lt;td&gt;1,100 KB&lt;/td&gt;
&lt;td&gt;45%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; WebP's compression algorithm is dated compared to newer codecs. At the same visual quality, AVIF consistently beats it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use WebP:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need maximum browser compatibility&lt;/li&gt;
&lt;li&gt;Your users might be on older browsers (pre-2021 Chromium)&lt;/li&gt;
&lt;li&gt;You need animation support without GIF&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  AVIF
&lt;/h2&gt;

&lt;p&gt;AVIF (AV1 Image File Format) is based on the AV1 video codec developed by the Alliance for Open Media. It's the youngest of the three formats (2019) but has the best compression of any widely-supported image format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What makes it good:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Best-in-class compression — significantly better than WebP at the same visual quality&lt;/li&gt;
&lt;li&gt;Supports HDR (High Dynamic Range)&lt;/li&gt;
&lt;li&gt;Supports wide color gamut (P3, Rec. 2020)&lt;/li&gt;
&lt;li&gt;Open standard — no licensing fees&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Real compression numbers (quality 65):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image type&lt;/th&gt;
&lt;th&gt;JPEG size&lt;/th&gt;
&lt;th&gt;AVIF size&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Portrait photo (3MB)&lt;/td&gt;
&lt;td&gt;3,000 KB&lt;/td&gt;
&lt;td&gt;900 KB&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product shot (1MB)&lt;/td&gt;
&lt;td&gt;1,000 KB&lt;/td&gt;
&lt;td&gt;320 KB&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot (500KB)&lt;/td&gt;
&lt;td&gt;500 KB&lt;/td&gt;
&lt;td&gt;190 KB&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Illustration (2MB)&lt;/td&gt;
&lt;td&gt;2,000 KB&lt;/td&gt;
&lt;td&gt;700 KB&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I use quality 65 for AVIF in QuickConv's defaults. This might sound low, but AVIF's perceptual quality at 65 is equivalent to JPEG at 85. The codec is fundamentally more efficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; Two issues.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;encoding is slow&lt;/strong&gt;. AVIF encoding takes significantly longer than JPEG or WebP. A 3MB JPEG converts to WebP in ~100ms; the same file to AVIF takes ~800ms–2s. For a conversion service this matters — it's why I run Sharp on Cloud Run rather than trying to do it in a serverless function.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;browser support has a floor&lt;/strong&gt;. Chrome 85+ (August 2020), Firefox 93+ (October 2021), Safari 16+ (September 2022). If you have users on old iOS devices (pre-iPhone 8 which maxes out at iOS 16 but some older models are stuck on iOS 15), they'll get a broken image without a fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use AVIF:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Maximum compression is the priority (e-commerce, media sites)&lt;/li&gt;
&lt;li&gt;You can serve WebP as fallback with &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element&lt;/li&gt;
&lt;li&gt;You control the encoding pipeline and can afford the extra CPU time&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  HEIC
&lt;/h2&gt;

&lt;p&gt;HEIC (High Efficiency Image Container) is Apple's format, introduced with iOS 11 in 2017. Your iPhone takes photos in HEIC by default unless you've changed settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What makes it good:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Excellent compression — similar to AVIF&lt;/li&gt;
&lt;li&gt;Native integration in Apple ecosystem (iOS, macOS)&lt;/li&gt;
&lt;li&gt;Supports Live Photos (image + video together)&lt;/li&gt;
&lt;li&gt;Apple devices shoot in this format by default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; HEIC is an Apple proprietary format (based on HEVC/H.265 which has patent licensing). Windows doesn't support it natively. Android doesn't support it. No browser outside Safari supports it.&lt;/p&gt;

&lt;p&gt;This is why HEIC is the #1 format people ask to convert on QuickConv. They shoot on iPhone, try to send the photo, and find that Windows/Gmail/Slack can't display it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser support as of 2026:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;HEIC support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Safari (macOS/iOS)&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;❌ None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;❌ None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge&lt;/td&gt;
&lt;td&gt;❌ None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;When to use HEIC:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're storing photos for personal use in the Apple ecosystem&lt;/li&gt;
&lt;li&gt;You're building Apple-specific apps (iOS, macOS)&lt;/li&gt;
&lt;li&gt;Never for web delivery&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Browser Support Deep Dive
&lt;/h2&gt;

&lt;p&gt;For web delivery, browser support is the deciding factor. Here's the current state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- The pattern that handles all cases --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"image.avif"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/avif"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"image.webp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"image.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This serves AVIF to browsers that support it, WebP as the first fallback, and JPEG as the final fallback. In 2026, the JPEG fallback only matters for IE11 (which you should have stopped supporting years ago) and a handful of obscure browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coverage breakdown:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AVIF: ~93% of global browser usage (caniuse.com, April 2026)&lt;/li&gt;
&lt;li&gt;WebP: ~97% of global browser usage&lt;/li&gt;
&lt;li&gt;HEIC: ~19% (Safari only)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Encoding Speed Comparison
&lt;/h2&gt;

&lt;p&gt;If you're building a pipeline that converts images, encoding speed matters. Here are real measurements from the QuickConv converter running on Node.js 22 with native Sharp:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3.6MB JPG&lt;/td&gt;
&lt;td&gt;WebP (q80)&lt;/td&gt;
&lt;td&gt;1.9MB&lt;/td&gt;
&lt;td&gt;~300ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.6MB JPG&lt;/td&gt;
&lt;td&gt;AVIF (q65)&lt;/td&gt;
&lt;td&gt;1.1MB&lt;/td&gt;
&lt;td&gt;~1,800ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.6MB JPG&lt;/td&gt;
&lt;td&gt;HEIC&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Not supported in Sharp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;134KB JPG&lt;/td&gt;
&lt;td&gt;WebP (q80)&lt;/td&gt;
&lt;td&gt;89KB&lt;/td&gt;
&lt;td&gt;~80ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;134KB JPG&lt;/td&gt;
&lt;td&gt;AVIF (q65)&lt;/td&gt;
&lt;td&gt;41KB&lt;/td&gt;
&lt;td&gt;~420ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AVIF encoding is consistently 5–6x slower than WebP. For batch processing or real-time conversion, this adds up. For static site assets pre-built at deploy time, it doesn't matter.&lt;/p&gt;

&lt;p&gt;HEIC encoding is not supported in most open-source libraries (Sharp, ImageMagick) due to patent licensing. You can decode HEIC (read iPhone photos), but encoding to HEIC requires Apple's frameworks or commercial software.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lossless Comparison
&lt;/h2&gt;

&lt;p&gt;All three formats support lossless compression, but there are differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebP lossless&lt;/strong&gt;: Reliably good. ~20–30% smaller than PNG for most images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AVIF lossless&lt;/strong&gt;: Better than WebP lossless in theory, but support is inconsistent across encoders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC lossless&lt;/strong&gt;: Supported but rarely used outside Apple's own stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For lossless web images (logos, icons, UI elements), I generally recommend &lt;strong&gt;WebP lossless&lt;/strong&gt; for its broad support and predictable behavior. For truly lossless archival, stick with PNG.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transparency (Alpha Channel)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Transparency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPEG&lt;/td&gt;
&lt;td&gt;❌ None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three next-gen formats support transparency, which makes them viable replacements for PNG as well as JPEG.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Recommendations by Use Case
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Web images (photos):&lt;/strong&gt; AVIF with WebP fallback&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"photo.avif"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/avif"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"photo.webp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"photo.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Web images (logos/icons with transparency):&lt;/strong&gt; WebP (lossless) with PNG fallback&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E-commerce product shots:&lt;/strong&gt; AVIF — the file size savings directly reduce LCP (Largest Contentful Paint) and improve Core Web Vitals&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iPhone photos for sharing:&lt;/strong&gt; Convert HEIC → JPEG or WebP — most recipients and platforms handle both&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iPhone photos for web upload:&lt;/strong&gt; Convert HEIC → WebP before upload if your backend doesn't handle HEIC natively&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Archival/editing:&lt;/strong&gt; Keep originals in whatever format your camera produces (HEIC for iPhone, RAW for DSLR). Convert to JPEG/WebP only for distribution.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Convert
&lt;/h2&gt;

&lt;p&gt;If you need to convert between these formats:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command line (Sharp/Node.js):&lt;/strong&gt;&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;# JPEG to WebP&lt;/span&gt;
sharp input.jpg &lt;span class="nt"&gt;--output&lt;/span&gt; output.webp &lt;span class="nt"&gt;--quality&lt;/span&gt; 80

&lt;span class="c"&gt;# JPEG to AVIF&lt;/span&gt;
sharp input.jpg &lt;span class="nt"&gt;--output&lt;/span&gt; output.avif &lt;span class="nt"&gt;--quality&lt;/span&gt; 65

&lt;span class="c"&gt;# HEIC to WebP (requires libvips with HEIC support)&lt;/span&gt;
sharp input.heic &lt;span class="nt"&gt;--output&lt;/span&gt; output.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Online (no install):&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://quickconv.cc" rel="noopener noreferrer"&gt;QuickConv&lt;/a&gt; — free tier handles 10 conversions/day, no account required. Supports HEIC, WebP, AVIF, PNG, JPG in any combination. Files auto-delete after 24 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API (for automated pipelines):&lt;/strong&gt;&lt;br&gt;
&lt;/p&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 https://api.quickconv.cc/v1/convert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer qc_YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@photo.heic"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"output_format=webp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebP&lt;/strong&gt; is the safe, universal choice. Use it if you want one format that works everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AVIF&lt;/strong&gt; is the best format technically. Use it with a WebP fallback for maximum compression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC&lt;/strong&gt; is for Apple devices only. Convert it to anything else before sending to the web.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;JPEG isn't going away — it's too deeply embedded in tooling and infrastructure. But for any new image pipeline built in 2026, there's no good reason to produce JPEG as your primary format.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Benchmarks measured on Node.js 22 with Sharp 0.33, running on GCP Cloud Run (2 vCPU). Results vary by image content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://quickconv.cc" rel="noopener noreferrer"&gt;QuickConv&lt;/a&gt; converts between WebP, AVIF, HEIC, PNG, JPEG, and more.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>images</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Claude Code on the Web: Why Your .env Vars Don't Reach the Setup Script (and How SessionStart Hook Fixes It)</title>
      <dc:creator>quickconv</dc:creator>
      <pubDate>Sun, 19 Apr 2026 14:41:09 +0000</pubDate>
      <link>https://dev.to/cc_quickconv_ff5b94a1d015/claude-code-on-the-web-why-your-env-vars-dont-reach-the-setup-script-and-how-sessionstart-hook-4n6</link>
      <guid>https://dev.to/cc_quickconv_ff5b94a1d015/claude-code-on-the-web-why-your-env-vars-dont-reach-the-setup-script-and-how-sessionstart-hook-4n6</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Environment variables you put in the &lt;code&gt;.env&lt;/code&gt; panel of Claude Code on the web (Cloud Sandbox) &lt;strong&gt;do not reach the setup script&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;They only reach the &lt;strong&gt;shell inside the running Claude Code session&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;So &lt;code&gt;git clone "https://x-access-token:${GH_TOKEN}@..."&lt;/code&gt; inside the setup script fails — &lt;code&gt;GH_TOKEN&lt;/code&gt; is empty at that point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the clone into a SessionStart hook&lt;/strong&gt; and it just works, because by then &lt;code&gt;$GH_TOKEN&lt;/code&gt; is populated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a write-up of how I narrowed down a behavior that the official docs don't spell out clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I wanted to do
&lt;/h3&gt;

&lt;p&gt;I've been collecting custom slash commands, skills, and a personal &lt;code&gt;CLAUDE.md&lt;/code&gt; under &lt;code&gt;~/.claude/&lt;/code&gt; locally. I wanted the same setup available inside Claude Code on the web. Everything is stored in a private repo called &lt;code&gt;miyashita337/agent-base&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;According to the docs, cloud sessions do &lt;strong&gt;not&lt;/strong&gt; carry over user-level settings like &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; — only what's committed to the repo is available. So my plan was: on session start, clone &lt;code&gt;agent-base&lt;/code&gt; and symlink its contents into &lt;code&gt;~/.claude/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  First attempt (failed)
&lt;/h3&gt;

&lt;p&gt;I put a GitHub PAT into the &lt;code&gt;.env&lt;/code&gt; panel of the Cloud Sandbox settings and tried to clone from the setup script.&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;# setup script&lt;/span&gt;
&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
git clone &lt;span class="s2"&gt;"https://x-access-token:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@github.com/miyashita337/agent-base.git"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/agent-base"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; panel:&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="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;github_pat_...   &lt;span class="c"&gt;# ~90 chars, real value&lt;/span&gt;
&lt;span class="nv"&gt;GIT_AUTHOR_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;miyashita337
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;&lt;code&gt;fatal: could not read Username&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrowing it down
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom 1: my &lt;code&gt;echo&lt;/code&gt; output never showed up
&lt;/h3&gt;

&lt;p&gt;For debugging I added &lt;code&gt;echo "GH_TOKEN length: ${#GH_TOKEN}"&lt;/code&gt; in the setup script. But the setup-script UI only shows the last few lines of output, so anything mid-script gets silently dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workaround: dump everything to a log file
&lt;/h3&gt;

&lt;p&gt;I rewrote the script to redirect diagnostics into &lt;code&gt;/tmp/env-diag.log&lt;/code&gt; and then &lt;code&gt;cat&lt;/code&gt; it from inside the session.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/env-diag.log

&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"===== ENV DIAGNOSTICS ====="&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"GH_TOKEN length: &lt;/span&gt;&lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"GIT_AUTHOR_NAME: [&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GIT_AUTHOR_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"TZ: [&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LANG: [&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LANG&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- All env vars (names only) ---"&lt;/span&gt;
  &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I started a new session and asked Claude Code to &lt;code&gt;cat /tmp/env-diag.log&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2: the cache trap
&lt;/h3&gt;

&lt;p&gt;On some runs I didn't even see the "setup script executed" log line. Per the docs, the setup script &lt;strong&gt;runs only on first creation&lt;/strong&gt; — after that, a filesystem snapshot is reused. Editing the &lt;code&gt;.env&lt;/code&gt; panel alone does &lt;strong&gt;not&lt;/strong&gt; invalidate the snapshot.&lt;/p&gt;

&lt;p&gt;What does invalidate it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Changing the setup script body&lt;/li&gt;
&lt;li&gt;Changing the allowed-domains list&lt;/li&gt;
&lt;li&gt;~7 days of age&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I added a throwaway comment line like &lt;code&gt;# cache-bust 2026-04-19-01&lt;/code&gt; at the top of the setup script. Semantically it's a no-op, but it's enough to force a rerun on the next session.&lt;/p&gt;

&lt;h3&gt;
  
  
  The result: every env var was empty
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== ENV DIAGNOSTICS =====
GH_TOKEN length: 0
GIT_AUTHOR_NAME: []
TZ: []
LANG: []
...
Custom vars found: 0 / expected 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It wasn't just &lt;code&gt;GH_TOKEN&lt;/code&gt; — &lt;strong&gt;nothing from the &lt;code&gt;.env&lt;/code&gt; panel was reaching the setup script&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6eb9n3qad1r6u5qunwnl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6eb9n3qad1r6u5qunwnl.png" alt="Diagnostic log: 0 / 8 expected custom vars found"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The clincher: the session shell has them
&lt;/h3&gt;

&lt;p&gt;Inside the running Claude Code session, I checked the same variables directly from the shell:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"GH=&lt;/span&gt;&lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, TZ=[&lt;/span&gt;&lt;span class="nv"&gt;$TZ&lt;/span&gt;&lt;span class="s2"&gt;], LANG=[&lt;/span&gt;&lt;span class="nv"&gt;$LANG&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
&lt;span class="nv"&gt;GH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;93, &lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;Asia/Tokyo], &lt;span class="nv"&gt;LANG&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;ja_JP.UTF-8]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3otvd9wfr8m7tv38inqn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3otvd9wfr8m7tv38inqn.png" alt="Shell output showing GH=93, TZ=[Asia/Tokyo], LANG=[ja_JP.UTF-8]"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All present. So:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When it runs&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.env&lt;/code&gt; panel vars available?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup script&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code session shell&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Root cause and fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; panel is injected &lt;strong&gt;only into the Claude Code session shell&lt;/strong&gt;, not into the setup script. It's not a bug — it's just not documented clearly. The docs show &lt;code&gt;GH_TOKEN&lt;/code&gt; as an example &lt;code&gt;.env&lt;/code&gt; value, but that's aimed at the &lt;code&gt;gh&lt;/code&gt; CLI picking it up inside the session, not at setup-script usage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix: move clone logic into a SessionStart hook
&lt;/h3&gt;

&lt;p&gt;Drop the clone from the setup script. Put it in a SessionStart hook defined in the repo's &lt;code&gt;.claude/settings.json&lt;/code&gt;. The hook runs &lt;strong&gt;after&lt;/strong&gt; Claude Code has started, so &lt;code&gt;$GH_TOKEN&lt;/code&gt; is in scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/strong&gt;&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"startup|resume"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/scripts/setup-agent-base.sh"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;scripts/setup-agent-base.sh&lt;/code&gt;&lt;/strong&gt;&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nv"&gt;AGENT_BASE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/agent-base"&lt;/span&gt;

&lt;span class="c"&gt;# Skip locally&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_CODE_REMOTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="c"&gt;# Clone (GH_TOKEN is available at this point)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"setup-agent-base: GH_TOKEN is not set"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
  &lt;span class="c"&gt;# Pass the token via extraHeader so it doesn't end up in .git/config&lt;/span&gt;
  git &lt;span class="nt"&gt;-c&lt;/span&gt; http.extraHeader&lt;span class="o"&gt;=&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;GH_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;
      clone &lt;span class="s2"&gt;"https://github.com/miyashita337/agent-base.git"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; config &lt;span class="nt"&gt;--unset-all&lt;/span&gt; http.extraHeader 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Symlink into ~/.claude/ (idempotent)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;dir &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;commands skills agents hooks&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="c"&gt;# If the destination is a real directory, back it up first&lt;/span&gt;
    &lt;span class="c"&gt;# (ln -sf into an existing dir creates a nested symlink inside it)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dst&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dst&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dst&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.bak.&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dst&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/CLAUDE.md"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/CLAUDE.md"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/CLAUDE.md"&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few deliberate choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CLAUDE_CODE_REMOTE&lt;/code&gt; guard&lt;/strong&gt; — early-exit outside of cloud sessions so local dev isn't affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent&lt;/strong&gt; — skip clone if the dir exists, but always refresh the symlinks. Partial-failure recovery just works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No token in the URL&lt;/strong&gt; — use &lt;code&gt;http.extraHeader&lt;/code&gt; so &lt;code&gt;.git/config&lt;/code&gt; never contains a plaintext token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ln -sfn&lt;/code&gt; not &lt;code&gt;ln -sf&lt;/code&gt;&lt;/strong&gt; — if the destination is already a real directory, &lt;code&gt;ln -sf&lt;/code&gt; nests the symlink &lt;em&gt;inside&lt;/em&gt; it (e.g. &lt;code&gt;~/.claude/commands/commands&lt;/code&gt;). Backing it up first and using &lt;code&gt;-n&lt;/code&gt; (&lt;code&gt;--no-dereference&lt;/code&gt;) forces a clean replacement.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What about the setup script?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Leave it empty.&lt;/strong&gt; You can delete the diagnostics too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;Fresh session, &lt;code&gt;ls -la ~/.claude/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLAUDE.md -&amp;gt; /home/user/agent-base/CLAUDE.md
commands  -&amp;gt; /home/user/agent-base/commands
skills    -&amp;gt; /home/user/agent-base/skills
agents    -&amp;gt; /home/user/agent-base/agents
hooks     -&amp;gt; /home/user/agent-base/hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typing &lt;code&gt;/&lt;/code&gt; shows all the custom slash commands from &lt;code&gt;agent-base&lt;/code&gt; (&lt;code&gt;/capture&lt;/code&gt;, &lt;code&gt;/pdca&lt;/code&gt;, &lt;code&gt;/inv&lt;/code&gt;, ...) and they execute without issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; vars don't reach the setup script&lt;/td&gt;
&lt;td&gt;Move clone into a SessionStart hook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup-script &lt;code&gt;echo&lt;/code&gt; output is truncated&lt;/td&gt;
&lt;td&gt;Redirect to a log file and &lt;code&gt;cat&lt;/code&gt; it later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup script is cached and won't rerun&lt;/td&gt;
&lt;td&gt;Add/edit a throwaway comment to bust the cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New sessions can't start with an empty prompt&lt;/td&gt;
&lt;td&gt;Type anything — but remember it becomes the first instruction to Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ln -sf&lt;/code&gt; doesn't overwrite existing directories&lt;/td&gt;
&lt;td&gt;Back up first, then &lt;code&gt;ln -sfn&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git clone https://x-access-token:${TOKEN}@...&lt;/code&gt; leaks the token into &lt;code&gt;.git/config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pass it via &lt;code&gt;-c http.extraHeader=...&lt;/code&gt; instead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The "setup script can't see &lt;code&gt;.env&lt;/code&gt; vars" behavior is inferable if you read the docs carefully — the &lt;code&gt;gh&lt;/code&gt; CLI example hints at "this is for in-session auto-pickup" — but it's never stated plainly. Easy to misread as "you can use these in the setup script too."&lt;/p&gt;

&lt;p&gt;Hope this saves someone else the afternoon I lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/claude-code-on-the-web" rel="noopener noreferrer"&gt;Use Claude Code on the web — Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks configuration — Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Original Japanese post: &lt;a href="https://zenn.dev/harieshokunin/articles/b1064354319ce2" rel="noopener noreferrer"&gt;https://zenn.dev/harieshokunin/articles/b1064354319ce2&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>anthropic</category>
      <category>claude</category>
      <category>devops</category>
      <category>bash</category>
    </item>
    <item>
      <title>I Built an Image Conversion SaaS on (Almost) $0/Month — Here's the Full Stack</title>
      <dc:creator>quickconv</dc:creator>
      <pubDate>Fri, 17 Apr 2026 12:37:03 +0000</pubDate>
      <link>https://dev.to/cc_quickconv_ff5b94a1d015/i-built-an-image-conversion-saas-on-almost-0month-heres-the-full-stack-4o2i</link>
      <guid>https://dev.to/cc_quickconv_ff5b94a1d015/i-built-an-image-conversion-saas-on-almost-0month-heres-the-full-stack-4o2i</guid>
      <description>&lt;h1&gt;
  
  
  I Built an Image Conversion SaaS on (Almost) $0/Month — Here's the Full Stack
&lt;/h1&gt;

&lt;p&gt;A few months ago I got frustrated trying to convert a HEIC photo on my iPhone into something my client could actually open. The tools I found were either slow, ugly, or locked behind a paywall after one use. So I built &lt;a href="https://quickconv.cc" rel="noopener noreferrer"&gt;QuickConv&lt;/a&gt; — a file conversion service focused on next-generation image formats like WebP, AVIF, and HEIC.&lt;/p&gt;

&lt;p&gt;This post is a full technical breakdown: what I chose, why I chose it, and what I'd do differently. No marketing fluff.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Here's the bird's-eye view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser (Next.js static)
        │  HTTPS
        ▼
Cloudflare Pages  (CDN edge, static HTML/JS)
        │
        │  API calls
        ▼
Cloudflare Workers  (Hono — api.quickconv.cc)
        │               │
        │ R2 presign     │ convert job dispatch
        ▼               ▼
Cloudflare R2     GCP Cloud Run  (Sharp / FFmpeg / Ghostscript)
(file storage)          │
        ▲               │ callback on completion
        └───────────────┘
              │
              ▼
        Cloudflare D1
        (job state, rate limits, users)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design decision: &lt;strong&gt;split sharp-based conversion into a separate container&lt;/strong&gt; rather than running it inside Workers. That single choice drove most of the rest of the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend: Next.js with Static Export
&lt;/h2&gt;

&lt;p&gt;The frontend is a Next.js 15 App Router app. The entire build is configured as a static export:&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;// apps/web/next.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&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="s2"&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;productionBrowserSourceMaps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;output: "export"&lt;/code&gt; means &lt;code&gt;next build&lt;/code&gt; produces a directory of static HTML, JS, and CSS — no Node.js server required. That output gets deployed directly to Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why static export?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare Pages serves static assets from its global CDN at no compute cost&lt;/li&gt;
&lt;li&gt;No cold starts, no server to manage&lt;/li&gt;
&lt;li&gt;Pages has a generous free tier (unlimited requests for static assets)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff: no server-side rendering per request. For a conversion tool this is fine — the page content doesn't change per user. Dynamic data (job status, user account) is fetched client-side from the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internationalization&lt;/strong&gt; is handled by &lt;code&gt;next-intl&lt;/code&gt; with &lt;code&gt;ja&lt;/code&gt; and &lt;code&gt;en&lt;/code&gt; locale support baked into the static export. The locale is resolved from the URL path (&lt;code&gt;/en/&lt;/code&gt;, &lt;code&gt;/ja/&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  API Layer: Hono on Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;The API is a &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; application deployed to Cloudflare Workers at &lt;code&gt;api.quickconv.cc&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/upload    — receive file, store in R2
/api/convert   — create job, dispatch to converter
/api/status    — poll job state from D1
/api/download  — stream converted file from R2
/api/auth      — Google OAuth (JWT cookie)
/api/checkout  — Stripe checkout session
/api/webhook   — Stripe webhook handler
/api/account   — subscription info
/v1/convert    — public API for developers (API key auth)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why Hono instead of Express or Fastify?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Workers compatibility.&lt;/strong&gt; Express and Fastify are built around Node.js APIs (&lt;code&gt;http.IncomingMessage&lt;/code&gt;, &lt;code&gt;Buffer&lt;/code&gt;, etc.) that don't exist in the Workers runtime. Hono is built on the Web Standard APIs (&lt;code&gt;Request&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;, &lt;code&gt;Headers&lt;/code&gt;) that Workers natively support. No polyfills, no shims.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TypeScript ergonomics.&lt;/strong&gt; Hono has excellent type inference for route handlers, middleware, and context variables. The &lt;code&gt;Bindings&lt;/code&gt; and &lt;code&gt;Variables&lt;/code&gt; generics let me type-check R2 bindings, D1 bindings, and middleware-set values at compile time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; Workers have a 1MB (compressed) script size limit. Hono is tiny.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The middleware stack in order: Sentry → CORS → identification → optional auth → cost guard → rate limit.&lt;/p&gt;

&lt;p&gt;The identification middleware generates a stable &lt;code&gt;clientHash&lt;/code&gt; from IP + User-Agent (hashed, no PII stored) to track anonymous usage for rate limiting without requiring login.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conversion Engine: Sharp on GCP Cloud Run
&lt;/h2&gt;

&lt;p&gt;This is where things get interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not run Sharp in Workers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Workers run in V8 isolates — a JavaScript-only sandbox. Sharp is a native Node.js addon built on &lt;code&gt;libvips&lt;/code&gt;. Native binaries cannot run in V8 isolates. You can run WebAssembly in Workers, and there is a &lt;code&gt;sharp&lt;/code&gt; WASM build, but as of writing it doesn't support AVIF encoding and is significantly slower for large images.&lt;/p&gt;

&lt;p&gt;For a service where the core value proposition is "convert HEIC/AVIF fast," that's a non-starter.&lt;/p&gt;

&lt;p&gt;The solution: a separate container on Cloud Run.&lt;/p&gt;

&lt;p&gt;The converter is itself a small Hono app (&lt;code&gt;@hono/node-server&lt;/code&gt;) that runs on Node.js 22 with native Sharp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="c"&gt;# ... build stage ...&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    libvips-dev &lt;span class="se"&gt;\
&lt;/span&gt;    ffmpeg &lt;span class="se"&gt;\
&lt;/span&gt;    ghostscript
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;ffmpeg&lt;/code&gt; and &lt;code&gt;ghostscript&lt;/code&gt; are also installed — this handles video conversions and PDF operations respectively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The conversion flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Worker receives &lt;code&gt;/api/convert&lt;/code&gt; request&lt;/li&gt;
&lt;li&gt;For images/audio: Worker fetches the file from R2, POSTs it to Cloud Run (&lt;code&gt;/convert/direct&lt;/code&gt;), streams back the result&lt;/li&gt;
&lt;li&gt;For videos (which can take minutes): Worker dispatches an async job via &lt;code&gt;c.executionCtx.waitUntil()&lt;/code&gt;, Cloud Run pulls the file from R2 directly, converts, writes back to R2, then POSTs a callback to the Worker to update the D1 job record&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Workers have a 30-second CPU time limit. &lt;code&gt;waitUntil()&lt;/code&gt; extends this for background work, but video conversions can still exceed it. The async R2-based flow for video sidesteps this entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image quality defaults (from the actual code):&lt;/strong&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_QUALITY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;jpg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// mozjpeg&lt;/span&gt;
  &lt;span class="na"&gt;webp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;avif&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// AVIF compresses aggressively; 65 looks good&lt;/span&gt;
  &lt;span class="na"&gt;tiff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&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;AVIF at quality 65 typically produces files 50–70% smaller than JPEG at equivalent perceptual quality. That compression ratio is why I focused on next-gen formats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud Run configuration:&lt;/strong&gt; &lt;code&gt;max-instances=1&lt;/code&gt;. This is intentional cost control during early stages. A single instance handles queue sequentially. As traffic grows this will increase, but starting at 1 means zero idle cost outside the free tier.&lt;/p&gt;




&lt;h2&gt;
  
  
  Storage: Cloudflare R2
&lt;/h2&gt;

&lt;p&gt;All files — uploaded originals and converted outputs — live in R2 (&lt;code&gt;quickconv-files&lt;/code&gt; bucket).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why R2 over S3?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One reason: &lt;strong&gt;zero egress fees&lt;/strong&gt;. S3 charges ~$0.09/GB for data transferred out. For a conversion service, every download is egress. R2 charges $0 for egress to the internet.&lt;/p&gt;

&lt;p&gt;The math:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000 conversions/day × avg 2MB output = 2GB/day egress&lt;/li&gt;
&lt;li&gt;S3: ~$5.40/month just for egress&lt;/li&gt;
&lt;li&gt;R2: $0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Storage cost is $0.015/GB/month after the 10GB free tier. At current scale, I'm in the free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;24-hour auto-delete:&lt;/strong&gt; Files are automatically expired after 24 hours. This is configured as an R2 lifecycle rule, not application-level deletion. It runs even if the API is down.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;FILE_EXPIRY_HOURS = 24&lt;/code&gt; constant in the shared package is purely for setting the &lt;code&gt;expiresAt&lt;/code&gt; field in D1 (for display purposes). The actual deletion is infrastructure-level.&lt;/p&gt;




&lt;h2&gt;
  
  
  Database: Cloudflare D1
&lt;/h2&gt;

&lt;p&gt;D1 is Cloudflare's serverless SQLite. I use it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Job records (status, input/output R2 keys, timestamps)&lt;/li&gt;
&lt;li&gt;Rate limit counters (daily conversions per &lt;code&gt;clientHash&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;User accounts (email, Stripe customer ID, plan)&lt;/li&gt;
&lt;li&gt;Video conversion monthly counters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why D1 and not Postgres/PlanetScale/Turso?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a Workers-native app, D1 is the obvious choice: zero latency to bind, no connection pooling issues (SQLite has no connection limit), and it's free for the first 5M rows/month.&lt;/p&gt;

&lt;p&gt;The schema is managed with Wrangler migrations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler d1 migrations apply quickconv-db &lt;span class="nt"&gt;--remote&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite's single-writer model is fine for this workload. Rate limit increments use &lt;code&gt;INSERT OR REPLACE&lt;/code&gt; patterns that are safe under SQLite's serialized writes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Payments: Stripe
&lt;/h2&gt;

&lt;p&gt;Stripe handles all billing. The checkout flow: Workers creates a Stripe Checkout Session, redirects the user, Stripe POSTs to &lt;code&gt;/api/webhook&lt;/code&gt; on completion, Worker updates the D1 user record with the plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  Developer API
&lt;/h2&gt;

&lt;p&gt;One thing I added recently that I'm excited about: a public &lt;code&gt;/v1/convert&lt;/code&gt; endpoint for developers.&lt;br&gt;
&lt;/p&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 https://api.quickconv.cc/v1/convert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer qc_YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@photo.jpg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"output_format=webp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Developers get an API key from their account dashboard. The key is authenticated via middleware, rate-limited per plan, and the conversion hits the same Cloud Run backend. No separate infrastructure — the same Sharp container serves both the UI and the API.&lt;/p&gt;

&lt;p&gt;This was about 2 days of work on top of the existing backend. The hard part (conversion, storage, rate limiting) was already done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Monitoring: Sentry
&lt;/h2&gt;

&lt;p&gt;Sentry is initialized in all three components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; (&lt;code&gt;@sentry/nextjs&lt;/code&gt;) — captures unhandled errors and route transitions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Workers&lt;/strong&gt; (&lt;code&gt;@sentry/cloudflare&lt;/code&gt;) — captures Worker exceptions with breadcrumbs per conversion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Converter&lt;/strong&gt; (&lt;code&gt;@sentry/node&lt;/code&gt;) — captures conversion failures with file size/format context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The converter adds structured breadcrumbs for every conversion:&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="nf"&gt;addConversionBreadcrumb&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;conversionFormat&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;inputFormat&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-to-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outputFormat&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="na"&gt;durationMs&lt;/span&gt;&lt;span class="p"&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fileSizeInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inputBuffer&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="na"&gt;fileSizeOutput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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;This makes it easy to diagnose which format pairs are slow or failing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&gt;

&lt;p&gt;Monthly costs at current (early) scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers&lt;/td&gt;
&lt;td&gt;$0 (free tier: 100K req/day)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Pages&lt;/td&gt;
&lt;td&gt;$0 (static assets, unlimited)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;$0 (under 10GB free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare D1&lt;/td&gt;
&lt;td&gt;$0 (under 5M rows free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP Cloud Run&lt;/td&gt;
&lt;td&gt;$0 (under ~180K vCPU-seconds free/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain (quickconv.cc)&lt;/td&gt;
&lt;td&gt;~$1/month amortized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;$0 (free tier: 5K errors/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$1/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The GCP free tier for Cloud Run is 180,000 vCPU-seconds per month. A typical image conversion takes ~0.5–2 seconds of CPU. That's roughly 90,000–360,000 conversions before I pay anything on Cloud Run.&lt;/p&gt;

&lt;p&gt;When I exceed free tiers, the marginal cost is still low: Workers Paid plan is $5/month for 10M requests. R2 storage is $0.015/GB. Cloud Run is ~$0.024 per vCPU-hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Don't fight the platform.&lt;/strong&gt; When I first designed the converter, I tried to shoehorn Sharp into a Worker via WASM. It took two days to discover AVIF encoding wasn't supported. Accepting that Workers can't run native binaries and reaching for Cloud Run took an afternoon. Know your runtime's constraints upfront.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Shared types are worth the monorepo overhead.&lt;/strong&gt; The &lt;code&gt;@quickconv/shared&lt;/code&gt; package contains types, format constants, and validation schemas used by both the API Worker and the converter container. Without it, I'd have duplicated ~500 lines and diverged them within a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Static export is underrated.&lt;/strong&gt; The combination of Next.js static export + Cloudflare Pages is genuinely fast — Time to First Byte from edge nodes is under 50ms globally. For content that doesn't change per request, there's no reason to pay for server-side rendering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. R2 egress pricing changes the math entirely.&lt;/strong&gt; If you're building anything where users download files, compare R2 vs S3 egress costs before assuming S3 is the default. For download-heavy workloads, R2 is often 5–10x cheaper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;waitUntil()&lt;/code&gt; is not a queue.&lt;/strong&gt; Workers' &lt;code&gt;executionCtx.waitUntil()&lt;/code&gt; lets you run background work after returning a response, but it still has limits (30-second CPU, subject to Workers runtime constraints). For video conversions that might run 5+ minutes, I moved to a true callback pattern: dispatch to Cloud Run, Cloud Run calls back when done.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Format expansion: SVG → PNG, PDF → image batch&lt;/li&gt;
&lt;li&gt;Quality comparison preview (side-by-side before/after slider)&lt;/li&gt;
&lt;li&gt;More language support beyond ja/en&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;&lt;a href="https://quickconv.cc" rel="noopener noreferrer"&gt;quickconv.cc&lt;/a&gt; — free tier is 10 conversions/day, no account required.&lt;/p&gt;

&lt;p&gt;If you're building something that needs image conversion, the &lt;a href="https://quickconv.cc/en/developers" rel="noopener noreferrer"&gt;developer API&lt;/a&gt; is live. Free tier includes 100 conversions/month.&lt;/p&gt;

&lt;p&gt;The stack is deliberately unsexy: Next.js, Hono, Sharp, SQLite. No Kubernetes, no microservices, no Kafka. It serves the use case, runs on almost nothing, and I can reason about the whole thing in my head. That feels like the right place to start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Next.js 15, Hono 4, Sharp 0.33, Cloudflare Workers/Pages/R2/D1, GCP Cloud Run, Stripe, Sentry.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
