<?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>Forem: freedivingbase</title>
    <description>The latest articles on Forem by freedivingbase (@freedivingbase).</description>
    <link>https://forem.com/freedivingbase</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%2F3912422%2Ff5e7b188-cf3e-4813-9650-91d03eb9438d.png</url>
      <title>Forem: freedivingbase</title>
      <link>https://forem.com/freedivingbase</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/freedivingbase"/>
    <language>en</language>
    <item>
      <title>How I built freedivingbase on Cloudflare Workers, D1, and Astro</title>
      <dc:creator>freedivingbase</dc:creator>
      <pubDate>Mon, 04 May 2026 16:27:13 +0000</pubDate>
      <link>https://forem.com/freedivingbase/how-i-built-freedivingbase-on-cloudflare-workers-d1-and-astro-2dgg</link>
      <guid>https://forem.com/freedivingbase/how-i-built-freedivingbase-on-cloudflare-workers-d1-and-astro-2dgg</guid>
      <description>&lt;p&gt;I recently launched &lt;a href="https://freedivingbase.com" rel="noopener noreferrer"&gt;freedivingbase&lt;/a&gt;, a directory of freediving destinations and schools. The whole stack runs on Cloudflare's free tier, with single-digit-millisecond response times globally. Here's what the build looks like.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro SSR&lt;/strong&gt; (&lt;code&gt;output: 'server'&lt;/code&gt;) for the framework&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; as the runtime&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare D1&lt;/strong&gt; for the database (SQLite at the edge)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare R2&lt;/strong&gt; for image storage, with Image Resizing for responsive delivery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;caches.default&lt;/code&gt;&lt;/strong&gt; for edge caching of public GETs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arctic&lt;/strong&gt; for Google OAuth (admin dashboard)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this stack over Vercel/Supabase/etc.
&lt;/h2&gt;

&lt;p&gt;I wanted three things: cheap, fast everywhere, and minimal infra to think about. Cloudflare's free tier covers Workers (100k requests/day), D1 (5M reads/day), R2 (10GB storage), and Image Resizing all without leaving the same dashboard. For a content site that's mostly reads, that's more than enough.&lt;/p&gt;

&lt;p&gt;The other big draw is that there's no concept of "cold starts" the way there is on Lambda. Workers run V8 isolates, not containers. SSR responses come back in 50ms even on the free tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  D1: normalized schema at the edge
&lt;/h2&gt;

&lt;p&gt;D1 is just SQLite with a network layer, but writing it that way actually matters. The schema is fully normalized: countries, destinations, schools, certifications, etc. all in separate tables with foreign keys. No JSON columns, no document store patterns. The whole app uses about 12 tables.&lt;/p&gt;

&lt;p&gt;Queries from a Worker:&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;getDestinationBySlug&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;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;env&lt;/span&gt;&lt;span class="p"&gt;.&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM destinations WHERE slug = ? LIMIT 1&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="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&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;return&lt;/span&gt; &lt;span class="nx"&gt;result&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;D1 also supports prepared statements and batched queries, which I use heavily for the destination detail pages (one batch fetches the destination plus all its schools, conditions, and certifications).&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge caching pattern
&lt;/h2&gt;

&lt;p&gt;The trick that makes this site feel instant is using &lt;code&gt;caches.default&lt;/code&gt; directly inside Astro middleware. Public GET requests check the cache first; only cache misses hit D1.&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;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&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;cached&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&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;response&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;renderPage&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is invalidation. When an admin edits a school or destination, I purge the affected URLs:&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;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;cache&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="s2"&gt;`https://freedivingbase.com/schools/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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;cache&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="s2"&gt;`https://freedivingbase.com/schools/`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;cache&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="s2"&gt;`https://freedivingbase.com/`&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 avoids the classic stale-cache problem without needing a separate Redis or KV layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images
&lt;/h2&gt;

&lt;p&gt;Original WebP files live in R2. Cloudflare Image Resizing handles every variant on the fly via &lt;code&gt;/cdn-cgi/image/&lt;/code&gt; URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/cdn-cgi/image/width=640,quality=75,format=auto/&amp;lt;r2-url&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;format=auto&lt;/code&gt; negotiates AVIF for browsers that support it, WebP otherwise. &lt;code&gt;srcset&lt;/code&gt; widths are &lt;code&gt;[400, 640]&lt;/code&gt; for cards and &lt;code&gt;[640, 1024, 1440, 1920]&lt;/code&gt; for hero images. No build step, no image pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth
&lt;/h2&gt;

&lt;p&gt;The admin dashboard uses Google OAuth via &lt;a href="https://arctic.js.org/" rel="noopener noreferrer"&gt;Arctic&lt;/a&gt;, which is by far the cleanest OAuth library I've used in TypeScript. Sessions are HTTP-only cookies; admin role is stored on the user record in D1. About 80 lines total for login + logout + session middleware.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;A few things, in no order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro middleware on Workers&lt;/strong&gt; is genuinely composable. You can layer auth, caching, and logging without it feeling like Express.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1's local-dev story&lt;/strong&gt; has gotten really good. &lt;code&gt;wrangler dev&lt;/code&gt; runs against a local SQLite file that mirrors the schema, and &lt;code&gt;getPlatformProxy()&lt;/code&gt; lets vitest tests hit a real D1 instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Resizing's &lt;code&gt;/cdn-cgi/image/&lt;/code&gt; syntax&lt;/strong&gt; is undersold in Cloudflare's docs. It's basically the killer feature for content sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle size on Workers&lt;/strong&gt; is real (1MB compressed limit). I had to switch out a fuzzy-search library to stay under it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you're building a content-heavy side project and don't want to think about infrastructure, this stack genuinely delivers. The whole site is at &lt;a href="https://freedivingbase.com" rel="noopener noreferrer"&gt;freedivingbase.com&lt;/a&gt; if you want to poke around.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>astro</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
