<?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: David Bartalos</title>
    <description>The latest articles on DEV Community by David Bartalos (@dbartalos).</description>
    <link>https://dev.to/dbartalos</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%2F3936644%2F0fa9b784-f271-4f90-b49c-b91333078f03.png</url>
      <title>DEV Community: David Bartalos</title>
      <link>https://dev.to/dbartalos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dbartalos"/>
    <language>en</language>
    <item>
      <title>Static Site, Live Inventory: Two Sources of Truth That Don't Fight Each Other</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Thu, 04 Jun 2026 12:24:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/static-site-live-inventory-two-sources-of-truth-that-dont-fight-each-other-5c0a</link>
      <guid>https://dev.to/dbartalos/static-site-live-inventory-two-sources-of-truth-that-dont-fight-each-other-5c0a</guid>
      <description>&lt;p&gt;The shop sells two things: original watercolour paintings (one of each, ever) and open-edition prints. Originals sell out permanently. Prints don't. The question the architecture has to answer is: how does the site know which originals are still available, and how quickly does it reflect a sale?&lt;/p&gt;

&lt;p&gt;The naive answer is to fetch from Medusa at build time and bake the sold status into the static HTML. That works until a painting sells between deploys — the site shows it as available, someone clicks "Add to cart," Medusa rejects the request, and the experience is broken. Rebuilding on every sale is an option, but it couples the storefront's uptime to Medusa's webhook reliability.&lt;/p&gt;

&lt;p&gt;The naive answer in the other direction is to fetch from Medusa client-side on every page load and render nothing until the data arrives. That's a spinner on a content site. The paintings are the product. Making visitors wait to see them is the wrong trade.&lt;/p&gt;

&lt;p&gt;The actual solution has two layers with clearly defined responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: build-time hint
&lt;/h2&gt;

&lt;p&gt;Every painting in the content collection has an optional &lt;code&gt;sold&lt;/code&gt; field in its frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Autumn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Morning"&lt;/span&gt;
&lt;span class="na"&gt;sold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;sold: true&lt;/code&gt;, the build-time effects are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Catalog feed&lt;/strong&gt;: the original variant is excluded from the Google/Meta product catalog TSV. No point advertising something that can't be bought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD&lt;/strong&gt;: the product structured data uses &lt;code&gt;OutOfStock&lt;/code&gt; for availability. Google doesn't index it as a purchasable product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OG tags&lt;/strong&gt;: &lt;code&gt;product:availability&lt;/code&gt; is set to &lt;code&gt;"oos"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these affect the shop UI. A sold-out painting still has a page, still shows in the gallery, still shows the print options. The &lt;code&gt;sold&lt;/code&gt; flag is a &lt;em&gt;build-time signal to external systems&lt;/em&gt;, not a UI gate.&lt;/p&gt;

&lt;p&gt;The flag is kept in sync automatically. &lt;code&gt;bun run sync&lt;/code&gt; reads &lt;code&gt;stocked_quantity&lt;/code&gt; from Medusa for every product and patches the frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;↕ autumn-morning → sold: true
↕ winter-estuary → removed sold flag (back in stock)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;↕&lt;/code&gt; in the output means a patch was written. Running sync before deploying keeps the catalog and structured data accurate without manual frontmatter edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: runtime hydration
&lt;/h2&gt;

&lt;p&gt;The shop grid is static Astro HTML. Every card renders with &lt;code&gt;data-status="available"&lt;/code&gt; by default — the optimistic assumption. The original price, the "Add to cart" button, the availability badge: all rendered at build time, all assuming the painting is available.&lt;/p&gt;

&lt;p&gt;After the page loads, a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block calls the Medusa store API and patches each card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hydrateAvailability&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;products&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;fetchProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// cached promise, fires once per page&lt;/span&gt;

  &lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;card&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;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;availability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&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;originalAvailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;originalAvailable&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;// optimistic fallback&lt;/span&gt;

    &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalAvailable&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;originalAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.variant-original&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-sold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;hydrateAvailability&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;data-sold&lt;/code&gt; on the original variant row triggers CSS: the price gets a strikethrough, the row fades slightly, the "Add to cart" button disappears. The card stays in the grid — sold originals are visible as FOMO and context, not hidden. Prints on the same painting remain fully purchasable.&lt;/p&gt;

&lt;p&gt;No framework. No Svelte island. No loading state. The grid is visible and interactive immediately; the sold indicators arrive silently a few hundred milliseconds later without shifting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when Medusa is down
&lt;/h2&gt;

&lt;p&gt;The optimistic fallback on line 6 above is intentional. If &lt;code&gt;fetchProducts()&lt;/code&gt; fails — Medusa is restarting, the VPS is briefly unreachable — every card stays &lt;code&gt;data-status="available"&lt;/code&gt;. Visitors can browse. The cart still works. If someone tries to add a sold original, Medusa rejects the cart request and the UI shows the error then.&lt;/p&gt;

&lt;p&gt;The worst case is a few extra "sorry, sold out" cart errors during a brief backend outage. The alternative — blocking the grid on a Medusa response — would mean a broken shop during any downtime. For a low-traffic art shop, optimistic-with-graceful-degradation is the right default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter system
&lt;/h2&gt;

&lt;p&gt;The shop grid has filters: show all / available originals only / prints only / by theme. The filters use &lt;code&gt;data-hidden&lt;/code&gt; (not &lt;code&gt;display:none&lt;/code&gt;) driven by &lt;code&gt;data-status&lt;/code&gt; and &lt;code&gt;data-tags&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyFilters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;card&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;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shouldHide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CSS transitions on &lt;code&gt;data-hidden&lt;/code&gt; give the appearance/disappearance a fade rather than a jump. The "available originals" filter count updates after &lt;code&gt;hydrateAvailability()&lt;/code&gt; completes — it reads the live &lt;code&gt;data-status&lt;/code&gt; values, not the build-time assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two layers instead of one
&lt;/h2&gt;

&lt;p&gt;Build-time data is fast and free — it's just frontmatter. Runtime data is live and accurate. The split is about matching the right data source to the right consumer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;External systems (Google, Meta) see the build-time state. They cache it. A slightly-stale catalog entry means a sold original might briefly appear in Google Shopping — a bad click, but not a broken purchase (Medusa rejects the cart). Running &lt;code&gt;bun run sync&lt;/code&gt; before each deploy keeps the gap small.&lt;/li&gt;
&lt;li&gt;The shop UI sees the runtime state. A "available" badge on an actually-sold painting is a much worse experience — someone browses, adds to cart, gets rejected. That one must be live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;bun run sync&lt;/code&gt; closes the gap before each deploy, so the build-time state is never more than one deploy behind. In practice, originals sell slowly enough that the gap is rarely more than a few hours.&lt;/p&gt;

&lt;p&gt;If you're applying this on a different stack, the principle generalises: optimistic static HTML + silent runtime patch + accept that the worst case is a cart error. The trick is being honest about which data source serves which consumer, and not making the visitor wait for the slow one.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern doesn't fit
&lt;/h2&gt;

&lt;p&gt;Optimistic-with-graceful-degradation is the right default for a low-traffic shop where originals sell on the order of days. It's the wrong default for a flash sale, a sneaker drop, or anything where stock depletes in seconds and a misleading "available" badge would mean hundreds of cart errors a minute. At that point the spinner stops being a UX failure and starts being an honest signal — "we're checking, because the answer changes faster than we can ship HTML."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;That's the full series. The stack behind &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;: Astro 6 + Svelte on Cloudflare Pages, Medusa v2 on a €3.29/mo Hetzner VPS, and a handful of patterns that took longer to figure out than they should have.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>medusa</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Transactional Email in Medusa v2 Without the Notification Module</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:18:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/transactional-email-in-medusa-v2-without-the-notification-module-4gen</link>
      <guid>https://dev.to/dbartalos/transactional-email-in-medusa-v2-without-the-notification-module-4gen</guid>
      <description>&lt;p&gt;Medusa v2 has a notification module. It's designed exactly for transactional email — you register a provider, configure templates, and the module fires on order events. There's one problem: there's no official Resend provider.&lt;/p&gt;

&lt;p&gt;The community providers that exist are hit-and-miss on maintenance. The official SendGrid provider exists, but its setup felt heavier than the problem warranted. Resend, on the other hand, seemed easy enough to implement directly — clean API, good TypeScript types, and 3,000 emails a month free and a dollar per thousand beyond that.&lt;/p&gt;

&lt;p&gt;This is what I did instead: skip the notification module entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;Medusa's event system still works without the notification module. Any subscriber can listen to &lt;code&gt;order.placed&lt;/code&gt; and do whatever it wants. So the setup is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;code&gt;order.placed&lt;/code&gt; subscriber that calls the Resend SDK directly.&lt;/li&gt;
&lt;li&gt;The email template inlined in the subscriber file.&lt;/li&gt;
&lt;li&gt;A dev-redirect pattern so order emails in staging don't land in real inboxes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No notification module configuration. No provider abstraction. Just a function that runs when an order is placed and sends an email.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ESM trap: why the template lives inline
&lt;/h2&gt;

&lt;p&gt;The natural instinct is to put the HTML template in a separate file and import it:&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;// ❌ This fails at runtime&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orderConfirmationTemplate&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="s1"&gt;../templates/order-confirmation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Medusa's API uses &lt;code&gt;"module": "Node16"&lt;/code&gt;. Cross-file imports for subscriber dependencies work in dev and fail in production with &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt;. I didn't fully diagnose which resolver did what — inlining everything sidesteps the question entirely.&lt;/p&gt;

&lt;p&gt;The fix is to keep subscriber files completely self-contained. Everything the subscriber needs — template, helpers, types — lives in the same file.&lt;/p&gt;

&lt;p&gt;The same rule applies to &lt;code&gt;import type&lt;/code&gt;. With &lt;code&gt;"module": "Node16"&lt;/code&gt;, plain &lt;code&gt;import { SomeType }&lt;/code&gt; for TypeScript types isn't guaranteed to be erased from the compiled output. Always use &lt;code&gt;import type { ... }&lt;/code&gt; for type-only imports in subscriber files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subscriber
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api/src/subscribers/order-placed.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SubscriberArgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SubscriberConfig&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="s1"&gt;@medusajs/framework&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Modules&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="s1"&gt;@medusajs/framework/utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Resend&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="s1"&gt;resend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;orderPlaced&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SubscriberArgs&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;orderModule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Modules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ORDER&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;order&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;orderModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieveOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toAddress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isDev&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_DEV_REDIRECT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nadia Poe &amp;lt;hello@nadiapoe.co.uk&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Order confirmed — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display_id&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;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildOrderEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SubscriberConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order.placed&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildOrderEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&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="c1"&gt;// template inlined here — plain HTML string, no JSX, no template engine&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!DOCTYPE html&amp;gt;...`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;EMAIL_DEV_REDIRECT&lt;/code&gt; pattern is worth keeping. In development, every order confirmation — regardless of who placed the order — goes to a &lt;code&gt;+dev&lt;/code&gt; alias on your own email address. You see the real email, the real order data, without polluting a customer inbox. In production, &lt;code&gt;toAddress&lt;/code&gt; is the customer's email. The guard is a single ternary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contact form: no SDK needed
&lt;/h2&gt;

&lt;p&gt;The storefront has a contact form that also sends via Resend. For a one-off form POST, installing the Resend SDK client-side isn't worth it — the API is just an HTTP endpoint:&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;// client/src/pages/api/contact.ts&lt;/span&gt;
&lt;span class="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare:workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="o"&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;request&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="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="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.resend.com/emails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_CONTACT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nadiapoe.co.uk &amp;lt;hello@nadiapoe.co.uk&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&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;EMAIL_CONTACT_TO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reply_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Message from &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&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;env&lt;/code&gt; comes from &lt;code&gt;cloudflare:workers&lt;/code&gt; — the live runtime binding, not &lt;code&gt;import.meta.env&lt;/code&gt;. The &lt;code&gt;reply_to&lt;/code&gt; is set to the sender's address so replying in Gmail works naturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four API keys, not one
&lt;/h2&gt;

&lt;p&gt;One API key for everything is convenient until you need to rotate it, audit usage, or debug which part of the system sent a bad email. The setup uses four keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;medusa-notifications-prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;order subscriber&lt;/td&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;medusa-notifications-dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;order subscriber&lt;/td&gt;
&lt;td&gt;staging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storefront-contact-prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;contact form&lt;/td&gt;
&lt;td&gt;production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storefront-contact-dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;contact form&lt;/td&gt;
&lt;td&gt;staging&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Resend shows per-key send history. When something goes wrong you can see exactly which key sent what, without digging through logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS: the boring bit that breaks everything if you skip it
&lt;/h2&gt;

&lt;p&gt;Resend's Cloudflare integration auto-configures DKIM and SPF. The setup that works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt;: &lt;code&gt;v=spf1 include:_spf.resend.com ~all&lt;/code&gt; on the root domain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt;: Resend generates the records; Cloudflare auto-applies them via the integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt;: &lt;code&gt;v=DMARC1; p=reject; rua=mailto:hello@nadiapoe.co.uk&lt;/code&gt; — &lt;code&gt;p=reject&lt;/code&gt; means unauthenticated mail claiming to be from &lt;code&gt;nadiapoe.co.uk&lt;/code&gt; gets dropped, not delivered&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MX + inbound&lt;/strong&gt;: Cloudflare Email Routing with a catch-all rule forwarding to Gmail. &lt;code&gt;hello@nadiapoe.co.uk&lt;/code&gt; works as a real inbox without running a mail server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Resend SMTP credentials (&lt;code&gt;smtp.resend.com:587&lt;/code&gt;, username &lt;code&gt;resend&lt;/code&gt;, password = the API key) let Gmail send &lt;em&gt;as&lt;/em&gt; &lt;code&gt;hello@nadiapoe.co.uk&lt;/code&gt; via "Send mail as" — so replies from the shop's inbox come from the right address.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;The notification module's abstraction is useful if you ever want to swap providers or add multiple notification channels (email + SMS + push). Bypassing it means that flexibility lives in your subscriber code instead. For a shop that will always send via Resend, that's a fine trade. If the requirements change, the migration is straightforward: add the official provider when it ships, move the template, delete the subscriber.&lt;/p&gt;

&lt;p&gt;Live shop: &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>medusa</category>
      <category>email</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Three Cloudflare Patterns Earned the Hard Way</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Thu, 28 May 2026 12:13:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/three-cloudflare-patterns-earned-the-hard-way-1pcc</link>
      <guid>https://dev.to/dbartalos/three-cloudflare-patterns-earned-the-hard-way-1pcc</guid>
      <description>&lt;p&gt;Every Cloudflare product is well-documented in isolation. The interesting bugs are always at the seams between two products — the edge injecting scripts into HTML that already has a CSP, the WAF inspecting requests for media served from R2, Vite substituting variables at build time that don't exist yet on Pages. These three patterns are from running &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt; on Astro 6 + Pages, with R2 hosting the artwork media.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. CSP nonces via middleware, not build-time hashes
&lt;/h2&gt;

&lt;p&gt;The textbook way to do a strict CSP on a static site is build-time hashing: scan every inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, hash it, list the hashes in the CSP header. Cloudflare even has a build hook to do it for you.&lt;/p&gt;

&lt;p&gt;For an art site, Bot Fight Mode is non-negotiable — automated scrapers harvesting painting images are a real concern, and Cloudflare's challenge platform is the cheapest layer of protection available. But it breaks the moment you enable it. Cloudflare's edge injects the challenge widget at request time with a rotating token. The hash changes per request. Your CSP rejects it. Half your visitors get a broken challenge widget because the static hash you baked in this morning no longer matches.&lt;/p&gt;

&lt;p&gt;The fix is a per-request nonce. Astro 6's middleware runs as a Worker; Workers have &lt;code&gt;HTMLRewriter&lt;/code&gt;. From &lt;code&gt;client/src/middleware.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMiddleware&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;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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;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;next&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;HTMLRewriter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonceBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;nonceBytes&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;rewritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HTMLRewriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&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;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nonce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Security-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildCsp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="c1"&gt;// private cache — never share a nonce between users&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private, max-age=1500, stale-while-revalidate=7200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&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;Two non-obvious bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare reads the nonce from your CSP header and stamps its own injected scripts with it.&lt;/strong&gt; Undocumented but stable. This is the only reason the pattern works at all — without it, the challenge widget would still get blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Cache-Control: private&lt;/code&gt; is load-bearing.&lt;/strong&gt; A shared cache that served one user's nonced HTML to another would only break the cached client (their nonce doesn't match the newly injected scripts), but it's still a bug. &lt;code&gt;private&lt;/code&gt; keeps Cloudflare's edge from doing this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Server secrets via &lt;code&gt;cloudflare:workers&lt;/code&gt;, not &lt;code&gt;import.meta.env&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Astro has two environments: build-time and runtime. Vite resolves &lt;code&gt;import.meta.env.SOMETHING&lt;/code&gt; at build time by string substitution. If the value isn't in &lt;code&gt;.env&lt;/code&gt; at build time — and Cloudflare Pages doesn't expose dashboard-configured secrets during the build — Vite bakes in the literal &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worse: it does this silently. No warning, no error. The contact form deploys, the Resend SDK call fails with "API key undefined," and you spend an hour checking the Cloudflare dashboard before realising the value never made it into the bundle.&lt;/p&gt;

&lt;p&gt;Astro 6 has two ways to read server-side env at runtime. They are &lt;em&gt;not&lt;/em&gt; equivalent:&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;// ❌ Broken in Astro 6 — Astro.locals.runtime.env was removed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&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;RESEND_API_KEY&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Works&lt;/span&gt;
&lt;span class="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare:workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&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;RESEND_API_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cloudflare:workers&lt;/code&gt; module is provided by the Workers runtime. &lt;code&gt;env&lt;/code&gt; is the live binding object — the same one your &lt;code&gt;wrangler.toml&lt;/code&gt; and Cloudflare dashboard configure. No build-time substitution; nothing is baked in.&lt;/p&gt;

&lt;p&gt;Client-side code (anything in a Svelte component or a non-SSR Astro page) keeps using &lt;code&gt;import.meta.env.PUBLIC_*&lt;/code&gt;. Those are baked at build time on purpose — they're public.&lt;/p&gt;

&lt;p&gt;One gotcha: bindings may be absent in local dev without a full Pages emulation setup. Wrap access in try/catch when the value is optional:&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;function&lt;/span&gt; &lt;span class="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;h2&gt;
  
  
  3. R2 hotlink protection with an inverted WAF rule
&lt;/h2&gt;

&lt;p&gt;Print fulfillment runs through Prodigi — a print-on-demand provider that produces and ships prints worldwide. When an order comes in, Medusa generates a time-gated presigned R2 URL pointing to the high-resolution print master and hands it to Prodigi. Prodigi fetches the file, prints it, ships it. The URL expires. Nobody else ever needs access to that file.&lt;/p&gt;

&lt;p&gt;The high-res masters live in R2. Keeping them protected means ensuring those presigned URLs can only be opened by the intended recipient in the intended window — not scraped, not re-shared, not embedded on another site. The WAF rule is the layer that enforces origin intent on the media domain.&lt;/p&gt;

&lt;p&gt;The naive WAF rule is "block if the &lt;code&gt;Referer&lt;/code&gt; header doesn't match &lt;code&gt;nadiapoe.co.uk&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;It works until you try to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open an image URL directly in a browser tab&lt;/strong&gt; — no Referer, blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load a &lt;code&gt;&amp;lt;video preload="metadata"&amp;gt;&lt;/code&gt; tag&lt;/strong&gt; — some browsers send no Referer on media requests, blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on a &lt;code&gt;*.pages.dev&lt;/code&gt; preview deployment&lt;/strong&gt; — Referer is &lt;code&gt;&amp;lt;branch&amp;gt;.nadiapoe-co-uk.pages.dev&lt;/code&gt;, blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is to invert the logic: block &lt;em&gt;only when&lt;/em&gt; a Referer exists &lt;em&gt;and&lt;/em&gt; isn't in the allowlist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(http.host eq "media.nadiapoe.co.uk")
and (len(http.referer) &amp;gt; 0)
and not (http.referer contains "nadiapoe.co.uk")
and not (http.referer contains "localhost")
and not (http.referer contains ".pages.dev")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty Referers pass — direct nav, preload requests, RSS readers. Preview deployments pass via the &lt;code&gt;.pages.dev&lt;/code&gt; wildcard. Real hotlinks from other sites get a 403.&lt;/p&gt;

&lt;p&gt;If you script your WAF rules via the Cloudflare API (which you should, for reproducibility), two quirks not mentioned in the error messages: a &lt;code&gt;PUT&lt;/code&gt; to the rulesets endpoint must &lt;em&gt;not&lt;/em&gt; include &lt;code&gt;kind&lt;/code&gt; or &lt;code&gt;phase&lt;/code&gt; (they're implicit from the URL), and rate-limit &lt;code&gt;characteristics&lt;/code&gt; on the free plan must include &lt;code&gt;cf.colo.id&lt;/code&gt; because counts are per-colo, not global.&lt;/p&gt;

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

&lt;p&gt;The seams are where you learn things. Check the network tab when behaviour is wrong — Cloudflare adds its own response headers that tell you which rule fired, which challenge ran, which WAF block triggered. The answers are usually there; they're just not in the docs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt; runs on all three patterns in production.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>astro</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Medusa v2 in Production: Three Bugs That Each Ate a Weekend</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Tue, 26 May 2026 12:13:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/medusa-v2-in-production-three-bugs-that-each-ate-a-weekend-4e67</link>
      <guid>https://dev.to/dbartalos/medusa-v2-in-production-three-bugs-that-each-ate-a-weekend-4e67</guid>
      <description>&lt;p&gt;Production bugs don't care that your infrastructure costs €3.29/mo.&lt;/p&gt;

&lt;p&gt;Medusa v2 is genuinely good — the headless model, the workflow engine, the v2 admin API are all a step up from v1. But the docs surface 80% of what you'll hit, and the remaining 20% is where weekends go. These are three bugs I hit running &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;'s shop on Medusa v2. All three had simple fixes. None of the fixes were obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The &lt;code&gt;Date.parse&lt;/code&gt; shipping rule trap
&lt;/h2&gt;

&lt;p&gt;The shop has two delivery options — a small incentive to encourage slightly larger print orders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard delivery&lt;/strong&gt; — £4.50, always available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free delivery&lt;/strong&gt; — £0, available when &lt;code&gt;cart_subtotal &amp;gt;= 60&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A £100 cart. Free Delivery disappears. Standard gets auto-selected. Refresh, clear cart, retry — same result. Stranger: a £10 test cart shows Free Delivery correctly. The rule is set up right. The data in the database looks right. Something in the middle is broken.&lt;/p&gt;

&lt;p&gt;That something is buried in &lt;code&gt;@medusajs/fulfillment/dist/utils/index.js&lt;/code&gt;. The comparator for &lt;code&gt;gte&lt;/code&gt; / &lt;code&gt;lt&lt;/code&gt; rules does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&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="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// date branch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;// numeric branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks fine until you remember what &lt;code&gt;Date.parse&lt;/code&gt; does to bare integer strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// → -315619200000     (year 1960 — 2-digit year expansion)&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// → -59011459125000  (year 100 AD)&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → NaN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A £100 cart with &lt;code&gt;cart_subtotal = "100"&lt;/code&gt; and a rule value of &lt;code&gt;"60"&lt;/code&gt; evaluates as &lt;code&gt;Date(100 AD) &amp;lt; Date(1960)&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;. The "show this option when subtotal is &lt;em&gt;below&lt;/em&gt; £60" condition fires on a cart worth nearly twice the threshold. Free Delivery gets stripped.&lt;/p&gt;

&lt;p&gt;The fix is one &lt;code&gt;toFixed(2)&lt;/code&gt;. From &lt;code&gt;api/src/workflows/shipping-options-context.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;listShippingOptionsForCartWorkflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;listShippingOptionsForCartWithPricingWorkflow&lt;/span&gt;&lt;span class="p"&gt;,&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;@medusajs/medusa/core-flows&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StepResponse&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;@medusajs/framework/workflows-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;injectCartSubtotal&lt;/span&gt; &lt;span class="o"&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;cart&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StepResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cart_subtotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;item_total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;listShippingOptionsForCartWorkflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setShippingOptionsContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;injectCartSubtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;listShippingOptionsForCartWithPricingWorkflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setShippingOptionsContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;injectCartSubtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule values stored in the database have to match the same shape — &lt;code&gt;"60.00"&lt;/code&gt;, not &lt;code&gt;"60"&lt;/code&gt;. Once both sides are decimals, &lt;code&gt;Date.parse&lt;/code&gt; returns &lt;code&gt;NaN&lt;/code&gt; and the evaluator falls through to the numeric branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: There are &lt;em&gt;two&lt;/em&gt; shipping workflows, and you have to hook both
&lt;/h2&gt;

&lt;p&gt;Look at the snippet above again. There are two workflow hooks, not one. That's not an accident.&lt;/p&gt;

&lt;p&gt;By default Medusa v2's shipping rule evaluator only sees &lt;code&gt;is_return&lt;/code&gt; and &lt;code&gt;enabled_in_store&lt;/code&gt;. Anything cart-financial — &lt;code&gt;cart_subtotal&lt;/code&gt;, &lt;code&gt;item_count&lt;/code&gt;, currency, region — has to be injected via the &lt;code&gt;setShippingOptionsContext&lt;/code&gt; hook. That part is documented.&lt;/p&gt;

&lt;p&gt;What isn't documented: there are &lt;strong&gt;two&lt;/strong&gt; workflows that evaluate shipping options.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;listShippingOptionsForCartWorkflow&lt;/code&gt; runs when the storefront fetches options to display.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;listShippingOptionsForCartWithPricingWorkflow&lt;/code&gt; runs &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;addShippingMethodToCartWorkflow&lt;/code&gt; when a customer actually selects an option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hook only the first and the storefront shows Free Delivery correctly. The customer selects it, gets a 400 back — because the second workflow has no &lt;code&gt;cart_subtotal&lt;/code&gt; in its context, the rule fails, and Medusa rejects the assignment as "option not available for this cart." The response body is generic. The server logs are silent.&lt;/p&gt;

&lt;p&gt;Cost: one Friday evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: The workaround that aged badly
&lt;/h2&gt;

&lt;p&gt;Some workarounds need an expiry date. This one didn't have one.&lt;/p&gt;

&lt;p&gt;Early Medusa v2 had two related bugs — &lt;a href="https://github.com/medusajs/medusa/issues/11766" rel="noopener noreferrer"&gt;#11766&lt;/a&gt; and &lt;a href="https://github.com/medusajs/medusa/issues/13301" rel="noopener noreferrer"&gt;#13301&lt;/a&gt;. Stripe charged the card, webhooks fired, but &lt;code&gt;order.paid_total&lt;/code&gt; stayed at 0 and the payment status never advanced past &lt;code&gt;pending&lt;/code&gt;. Every order had to be manually marked paid in the admin — which also meant no order confirmation email fired, since the email subscriber was gated on a completed order. Customers paid, heard nothing, and had to be chased manually.&lt;/p&gt;

&lt;p&gt;I patched it with a subscriber on &lt;code&gt;order.placed&lt;/code&gt; that called &lt;code&gt;capturePaymentWorkflow&lt;/code&gt; directly:&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;// api/src/subscribers/order-capture-payment.ts  (DELETED in 2026-05)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;orderCapturePayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...fetch payment collection, mark fully captured, refresh order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. Shipped.&lt;/p&gt;

&lt;p&gt;Then Medusa v2.11.1 landed and fixed both upstream bugs. The subscriber kept running — double-firing the capture (harmless, since the payment was already captured) but also re-emitting &lt;code&gt;order.placed&lt;/code&gt;. That re-emission triggered the email subscriber a second time. Every customer started getting two identical confirmation emails.&lt;/p&gt;

&lt;p&gt;I didn't notice until v2.14.2, months later, when an unrelated change made the second &lt;code&gt;order.placed&lt;/code&gt; event log a warning. The fix was a one-line deletion. The lesson cost more than the bug did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every workaround for a third-party bug needs three things:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The upstream issue URL in a comment — so future-you knows why it exists.&lt;/li&gt;
&lt;li&gt;A version check or feature flag to disable it — so it can be switched off without deleting it immediately.&lt;/li&gt;
&lt;li&gt;A note to revisit on the next major upgrade of that dependency.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had #1. I didn't have #2 or #3. The duplicate emails probably did 30 minutes of brand damage before I caught it. The fix was free. The discipline to set expiry dates on workarounds isn't instinctive, but it's cheap.&lt;/p&gt;

&lt;p&gt;One note if you're starting a Medusa v2 project: the v1 docs URL still resolves and Google ranks both. Make sure the URL doesn't have &lt;code&gt;/v1/&lt;/code&gt; in it before you copy a snippet — the APIs changed significantly between versions.&lt;/p&gt;

&lt;p&gt;Live shop: &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>medusa</category>
      <category>ecommerce</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Hosting Rejection Tour: Render, AWS EC2, Oracle, and How I Ended Up on a €3.29/mo VPS</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Tue, 19 May 2026 12:17:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/the-hosting-rejection-tour-render-aws-ec2-oracle-and-how-i-ended-up-on-a-eu329mo-vps-lp2</link>
      <guid>https://dev.to/dbartalos/the-hosting-rejection-tour-render-aws-ec2-oracle-and-how-i-ended-up-on-a-eu329mo-vps-lp2</guid>
      <description>&lt;p&gt;Before the stack worked, three hosting options failed. Each one looked perfect on paper. Each one had a specific, concrete reason it couldn't work. This is the rejection tour.&lt;/p&gt;

&lt;p&gt;The constraint: Medusa v2 is stateful. It needs PostgreSQL, Redis for BullMQ, and enough RAM to run the event bus and workflow engine without getting killed. That rules out serverless. (Medusa can technically start without Redis — it falls back to an in-process event bus — but that means losing job queuing, workflow retries, and reliable event delivery. Fine for a quick local demo; not something you want processing real orders.) It needs persistent storage, a real process manager, and a consistent IP for outbound webhook validation. Every "just deploy it for free" option assumes your workload is stateless. This one isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: Render
&lt;/h2&gt;

&lt;p&gt;Render's free tier looked fine for a low-traffic shop. 512MB RAM, easy deploys from GitHub — cold starts on the free tier, always-on on the paid plan. The plan was to pair it with Neon for PostgreSQL (generous free tier, serverless scaling) and Upstash for Redis (free tier covers BullMQ at low volume). Three managed services, total cost: $0.&lt;/p&gt;

&lt;p&gt;I got Medusa running, ran the setup scripts, and then installed the Prodigi print fulfillment plugin — which loads product mappings and prefetches shipping zones at startup.&lt;/p&gt;

&lt;p&gt;OOM kill on boot. 512MB wasn't enough.&lt;/p&gt;

&lt;p&gt;The obvious fix is to upgrade the Render service. Their starter plan is $7/mo. That's just the API — Neon and Upstash stay free at low volume, but now I'm at $7/mo for a single service with no room to grow, and I still hadn't solved staging. Render's starter plan is $7/mo &lt;em&gt;per service&lt;/em&gt;. Two environments means two API instances: $14/mo before touching the databases.&lt;/p&gt;

&lt;p&gt;I could have stripped the fulfillment plugin to fit inside 512MB. But without it there are no print orders — Prodigi integration is how open-edition prints get produced and shipped. Dropping it to hit a RAM limit means the shop sells originals only, which wasn't the brief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: AWS EC2
&lt;/h2&gt;

&lt;p&gt;After Render, AWS EC2 looked like the right move. A &lt;code&gt;t4g.micro&lt;/code&gt; in &lt;code&gt;eu-west-2&lt;/code&gt; (London): 1 GiB RAM, 2 vCPU ARM64 Graviton2, always-on, full SSH access, swap configurable — all within the AWS free tier for the first 12 months. Double the memory of Render's paid plan, at no cost. I built out the full setup: CloudFormation stack, Nginx reverse proxy, Let's Encrypt TLS, systemd service, GitHub Actions CD, health check cron.&lt;/p&gt;

&lt;p&gt;It ran. There were a couple of ARM64 quirks — a &lt;code&gt;ts-node&lt;/code&gt; source-map crash under Bun that needed &lt;code&gt;TS_NODE_SKIP_SOURCE_MAP_SUPPORT=1&lt;/code&gt;, and Node.js auto-limiting the V8 heap to ~512MB on a 1 GiB instance, fixed with &lt;code&gt;NODE_OPTIONS=--max-old-space-size=1024&lt;/code&gt;. But Medusa came up, Prodigi loaded, the health check passed.&lt;/p&gt;

&lt;p&gt;Then I ran a checkout flow under load. OOM kill.&lt;/p&gt;

&lt;p&gt;The 1 GiB ceiling wasn't enough headroom when Prodigi, Medusa, and an active checkout were all competing for memory at once. And the infrastructure complexity — CloudFormation, Nginx config, cert renewal timers, CloudWatch, a manual cutover checklist — was a real ongoing cost for a solo project. After the free year, the bill would be ~$7.90/mo anyway. At that price you can just buy a VPS that has the memory you need and none of the ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 3: Oracle Always Free
&lt;/h2&gt;

&lt;p&gt;Oracle's Always Free tier is, on paper, absurd. The A1 Flex shape gives you up to 4 OCPU and 24 GB RAM at no cost, permanently. ARM64, which is what Hetzner's cheapest VPS uses anyway. UK London region available. I signed up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The selected shape is not available in this Availability Domain. Please try a different Availability Domain or shape."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I tried every Availability Domain in UK London. Same message. I tried Frankfurt. Same message. I tried Amsterdam. Same.&lt;/p&gt;

&lt;p&gt;This isn't a new problem. The Oracle community forum has threads going back two years with hundreds of people reporting A1 capacity unavailable in every European region. Oracle adds capacity occasionally, and it disappears within hours as people snap it up. The forum advice is to script retries and keep trying. I tried that for a few weeks.&lt;/p&gt;

&lt;p&gt;The Always Free tier only works if you can actually provision the instance. If capacity is gone, it's gone indefinitely. For a project you want to actually ship, "keep retrying and hope" isn't a deployment strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked: Hetzner CAX11
&lt;/h2&gt;

&lt;p&gt;Hetzner's CAX11 is €3.29/mo. ARM64, 2 vCPU, 4 GB RAM, 40 GB NVMe SSD, 20 TB/month egress included. I signed up, got the server provisioned in about 30 seconds, and had Medusa running the same afternoon.&lt;/p&gt;

&lt;p&gt;The setup that's been stable since:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two systemd services, one box.&lt;/strong&gt; Dev and production run as separate Medusa instances — different ports, different &lt;code&gt;.env&lt;/code&gt; files, different PostgreSQL databases. Caddy sits in front and routes &lt;code&gt;api-dev.nadiapoe.co.uk&lt;/code&gt; and &lt;code&gt;api.nadiapoe.co.uk&lt;/code&gt; to the right one. Each service restarts automatically on failure. No Docker, no orchestration — just two &lt;code&gt;medusa.service&lt;/code&gt; unit files and a Caddy config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploys via GitHub Actions.&lt;/strong&gt; On push to &lt;code&gt;main&lt;/code&gt;, a workflow SSHes in, pulls the latest code, runs &lt;code&gt;bun install&lt;/code&gt; and &lt;code&gt;bun run build&lt;/code&gt;, and restarts the appropriate service. Under 60 seconds end to end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups to Cloudflare R2.&lt;/strong&gt; A nightly cron runs &lt;code&gt;pg_dump&lt;/code&gt; and uploads the compressed archive to a dedicated R2 bucket (&lt;code&gt;nadiapoe-backups&lt;/code&gt;). R2 is already in the stack for media — adding a backup bucket costs nothing extra. Retention is 7 daily dumps on-server, 30 in R2. If the VPS burns down, restoring is &lt;code&gt;pg_restore&lt;/code&gt; and a &lt;code&gt;git pull&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The total monthly cost: €3.29. Both environments. All services. Backups included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;"Free" hosting for stateful backend workloads usually means one of two things: a RAM ceiling that kills anything real, or a capacity queue where "available" means "available when someone else cancels." For a stateless frontend or a simple API, free tiers are great — Cloudflare Pages handles the storefront for nothing. But the moment you have a process that needs to stay alive, own persistent state, and run at startup, a cheap paid VPS is more reliable than a free tier with asterisks.&lt;/p&gt;

&lt;p&gt;Hetzner CAX11 at €3.29/mo is less than most people spend on a coffee a month. It's not free. It's better than free.&lt;/p&gt;

&lt;p&gt;One genuine acknowledgement before moving on: the free tiers from Cloudflare, Resend, Neon, and Upstash make it possible to build and prove a business before it earns a penny. They lower the bar for anyone who wants to try something without betting money on it first. That matters, and it's worth saying.&lt;/p&gt;

&lt;p&gt;Live shop: &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>medusa</category>
      <category>selfhosted</category>
      <category>hetzner</category>
    </item>
    <item>
      <title>Blazingly Fast Ecommerce Stack for Less Than a Coffee a Month — No Marketplace, No Platform Cut</title>
      <dc:creator>David Bartalos</dc:creator>
      <pubDate>Mon, 18 May 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/dbartalos/blazingly-fast-ecommerce-stack-for-less-than-a-coffee-a-month-no-marketplace-no-platform-cut-59dn</link>
      <guid>https://dev.to/dbartalos/blazingly-fast-ecommerce-stack-for-less-than-a-coffee-a-month-no-marketplace-no-platform-cut-59dn</guid>
      <description>&lt;p&gt;If you've ever looked at a marketplace's fee page and felt your eye twitch, this post is for you.&lt;/p&gt;

&lt;p&gt;The major selling platforms take their cut from every angle — transaction fees, listing fees, monthly subscriptions, payment processing. The percentages vary but the direction doesn't: a meaningful slice of every sale goes to infrastructure you don't own or control. And that's before the visibility problem: on a marketplace of millions of listings, the algorithm decides whether your work gets seen at all.&lt;/p&gt;

&lt;p&gt;My girlfriend had been listing her work on one of the big marketplaces for a while — barely any traffic, zero sales. The fees were almost beside the point. I saw the disappointment and floated the idea: her own site, her own corner of the internet — and I'd build it.&lt;/p&gt;

&lt;p&gt;I'm a software engineer. I like a challenge. So I set out to give it a shot. The result runs at &lt;strong&gt;€3.29/mo&lt;/strong&gt; for all backend infrastructure. The site is live at &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt;. Here's the stack and the decisions behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Artwork loads instantly.&lt;/strong&gt; Watercolours are the product. A 2-second LCP would send visitors away before they saw a painting. No image-CDN that stamps a watermark on the hero image, no spinner while the page wakes a cold serverless function.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No third-party watermarks.&lt;/strong&gt; Image-CDN convenience — Cloudinary, imgix — isn't worth a logo in the corner of the hero. This is an artist's portfolio. The paintings deserve respect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real commerce, not a payment link.&lt;/strong&gt; Multi-currency (GBP / EUR / USD / AUD), international fulfillment for prints, originals shipped from the UK. A Stripe payment link in the Instagram bio wasn't going to cut it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No tracking, no cookie banner.&lt;/strong&gt; The site doesn't follow visitors. No analytics cookies, no third-party pixels, no consent popup to dismiss before you can see a painting. Purchase data goes only as far as it needs to: card details to Stripe, a shipping address — originals are shipped by us from the UK, prints via a print-on-demand provider for international orders. Nothing retained beyond what's needed to get the order out the door.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Indie budget.&lt;/strong&gt; One artist, no team, no investor. Anything more than ~£10/month total infrastructure is a recurring tax on the creative work. That ceiling shaped every hosting decision in the stack.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack, piece by piece
&lt;/h2&gt;

&lt;p&gt;The infrastructure splits cleanly across two providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare (free tier)
&lt;/h3&gt;

&lt;p&gt;Everything the visitor touches runs on Cloudflare. CDN and WAF sit in front of everything — Bot Fight Mode, rate limiting, R2 hotlink protection. Behind them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; hosts the Astro 6 storefront. Static by default, per-route SSR where needed (checkout callbacks, contact form, structured data feeds). The painting pages are pure HTML — the entire collection ships zero JS until the cart is opened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare R2&lt;/strong&gt; stores all images and process videos. Zero egress fees, served from a custom domain (&lt;code&gt;media.nadiapoe.co.uk&lt;/code&gt;). Videos are pre-encoded to four variants locally with ffmpeg (720p desktop, 480p mobile, JPEG poster, 64×64 thumb) and uploaded via wrangler. No URL transforms, no image-service quotas to exhaust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare D1&lt;/strong&gt; is the edge SQL database — one table, one purpose: like counts. The like button stores your choice in localStorage and increments a counter in D1. No cookies, no tracking, no consent popup. You see the count; nothing sees you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hetzner VPS — €3.29/mo
&lt;/h3&gt;

&lt;p&gt;Everything commerce-related runs on a single Hetzner CAX11: 2 vCPU ARM64, 4 GB RAM, 40 GB NVMe, 20 TB/month egress. On it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Medusa v2&lt;/strong&gt; — the commerce backend. Dev and prod as separate systemd services, both reverse-proxied by Caddy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — orders, products, customers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; — BullMQ event bus and workflow engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Render, AWS, and Oracle Always Free each failed for a different specific reason. Hetzner just works.&lt;/p&gt;

&lt;p&gt;One gap in Medusa v2 worth knowing: there's no official Resend provider. Order confirmation emails skip the notification module entirely and call the Resend SDK directly from an &lt;code&gt;order.placed&lt;/code&gt; subscriber.&lt;/p&gt;

&lt;h3&gt;
  
  
  Third-party services
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Stripe&lt;/strong&gt; handles payments across four currencies (GBP / EUR / USD / AUD). Cloudflare injects &lt;code&gt;cf.country&lt;/code&gt; into a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag at the edge; the storefront reads it to pick the matching Medusa region and present prices in the local currency. User override persists in localStorage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resend&lt;/strong&gt; handles transactional email — 3,000 emails/month free, then $1/1,000.&lt;/p&gt;

&lt;h3&gt;
  
  
  The interactive layer
&lt;/h3&gt;

&lt;p&gt;Svelte islands handle the cart drawer, quantity controls, painting gallery, and region selector. Nano-stores (&lt;code&gt;cartStore&lt;/code&gt;, &lt;code&gt;cartUpdating&lt;/code&gt;, &lt;code&gt;regionStore&lt;/code&gt;) keep islands in sync without a framework router.&lt;/p&gt;

&lt;p&gt;The shop grid itself is static Astro HTML — no Svelte involved. The paintings are the product; a visitor should see the full collection immediately, not wait on an inventory check before anything renders. So every card defaults to "available," then a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag calls the Medusa API in the background and patches &lt;code&gt;data-status&lt;/code&gt; on each card to flip sold originals to a faded state. No layout shift, no spinner, no framework overhead for a read-only grid.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One Astro 6 gotcha that cost an afternoon:&lt;/em&gt; server-side secrets must come from &lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt;. The old &lt;code&gt;Astro.locals.runtime.env&lt;/code&gt; was removed and &lt;code&gt;import.meta.env&lt;/code&gt; silently bakes &lt;code&gt;undefined&lt;/code&gt; for server-only vars. No build error, no runtime warning — just missing data in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually costs
&lt;/h2&gt;

&lt;p&gt;Backend hosting: &lt;strong&gt;€3.29/mo&lt;/strong&gt; — both staging and production Medusa instances, PostgreSQL, Redis, Caddy, automated nightly backups to Cloudflare R2. Frontend, CDN, edge SQL, object storage, WAF, analytics: free tier. The metered costs are Stripe (1.5–2.9% per transaction — unavoidable regardless of platform, but at least there's no &lt;em&gt;extra&lt;/em&gt; platform cut on top) and Resend (3,000 emails/month free, then $1/1,000).&lt;/p&gt;

&lt;p&gt;Compare that to a typical marketplace taking 6–7% on a £150 original watercolour — that's £9–10 per sale, forever, to infrastructure you don't own. At even modest volume the self-hosted setup pays for itself inside the first month.&lt;/p&gt;

&lt;p&gt;Will this survive a traffic spike? Honestly, no idea — this shop has never been Slashdotted, and a 2 vCPU ARM box with 4 GB RAM is not going to win any load test. But if it ever buckles under the weight of people trying to buy original watercolours, upgrading the VPS will be the easiest problem on the list that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  One snippet worth stealing
&lt;/h2&gt;

&lt;p&gt;This pattern only works because the HTML is served through a Cloudflare Worker — but if you're already on Cloudflare Pages, you have that for free.&lt;/p&gt;

&lt;p&gt;The full CSP is set per-request with a fresh nonce, injected into every &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag at the edge. No build-time hash dance, no list of inline script hashes to maintain. From &lt;code&gt;client/src/middleware.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMiddleware&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;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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;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;next&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;HTMLRewriter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonceBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;nonceBytes&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;rewritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HTMLRewriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&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;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nonce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Security-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildCsp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&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;Cloudflare's own bot-fight challenge scripts get whitelisted automatically — they read the nonce from the CSP header and stamp themselves with it. On a static-only setup you're stuck with hashes, and those break the moment Cloudflare rotates a challenge token. The Worker approach sidesteps that entirely.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://nadiapoe.co.uk" rel="noopener noreferrer"&gt;nadiapoe.co.uk&lt;/a&gt; if you want to see the result.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>svelte</category>
      <category>cloudflare</category>
      <category>medusa</category>
    </item>
  </channel>
</rss>
