<?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: Anton Makasin</title>
    <description>The latest articles on DEV Community by Anton Makasin (@towami).</description>
    <link>https://dev.to/towami</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4052077%2Fe90be03e-a042-4073-8bbf-314c833203bb.jpg</url>
      <title>DEV Community: Anton Makasin</title>
      <link>https://dev.to/towami</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/towami"/>
    <language>en</language>
    <item>
      <title>Mobile-First SCSS Architecture with Dart Sass in Docker</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Sun, 08 Nov 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/towami/mobile-first-scss-architecture-with-dart-sass-in-docker-2bhk</link>
      <guid>https://dev.to/towami/mobile-first-scss-architecture-with-dart-sass-in-docker-2bhk</guid>
      <description>&lt;p&gt;Our SCSS is split into three folders and compiled by tools inside Docker, so developers do not need Sass installed on the host. The source and generated files still live on a bind-mounted project directory. Here's the setup and why it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Directory Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assets/scss/site/
├── common/          ← variables, breakpoints, shared partials
├── mobile/          ← base styles and small-screen adjustments
│   ├── _catalog_list.scss
│   ├── _catalog_item.scss
│   ├── _account.scss
│   └── _main.scss
└── desktop/         ← wider-screen overrides
    ├── _catalog_list.scss
    ├── _catalog_item.scss
    └── _main.scss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mobile folder contains the base appearance. It still has a few targeted media and accessibility queries; “mobile-first” does not have to mean “no media queries.”&lt;/p&gt;

&lt;p&gt;The desktop folder primarily contains wider-screen overrides. If a component does not change at the desktop breakpoint, it usually needs no matching desktop partial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;The separation makes the cascade and ownership clear: base rules live in one place, and desktop files describe the delta. We compile separate outputs and apply the desktop stylesheet conditionally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/storage/static/css/site/main_catalog_list_mobile.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt;
      &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/storage/static/css/site/main_catalog_list_desktop.css"&lt;/span&gt;
      &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"(min-width: 641px) and (min-height: 500px)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;media&lt;/code&gt; attribute controls when the desktop CSS applies. Browsers may fetch a nonmatching stylesheet at lower priority, but it does &lt;strong&gt;not&lt;/strong&gt; guarantee that the file will not be downloaded. Verify transfer behavior with browser tooling instead of treating file separation as an automatic bandwidth saving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Inside Docker
&lt;/h2&gt;

&lt;p&gt;We never run &lt;code&gt;sass&lt;/code&gt; on the host machine. The Docker container has Dart Sass installed:&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="c1"&gt;# docker-compose.yml (simplified)&lt;/span&gt;
&lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.docker/site/site.dockerfile&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;make"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Makefile"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dev"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./src:/app&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Makefile:/Makefile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The development target builds assets once, then runs Air and Sass concurrently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;dev&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;build-css-dev build-js-dev&lt;/span&gt;
    &lt;span class="p"&gt;$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; 2 run-air watch-css

&lt;span class="nl"&gt;run-air&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;SRC_DIR&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; air &lt;span class="nt"&gt;-c&lt;/span&gt; .air.site.toml

&lt;span class="nl"&gt;watch-css&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    sass &lt;span class="nt"&gt;--watch&lt;/span&gt; &lt;span class="nt"&gt;--poll&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;SCSS_INPUTS&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;expanded &lt;span class="nt"&gt;--no-source-map&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use &lt;code&gt;--poll&lt;/code&gt; because filesystem event delivery from bind mounts varies across host and Docker combinations. Polling is not universally required, so test both modes in your own environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compiled Output
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;storage/static/css/site/
├── main_catalog_list_mobile.css
├── main_catalog_list_desktop.css
├── main_catalog_item_mobile.css
├── main_catalog_item_desktop.css
├── main_account_mobile.css
├── main_account_desktop.css
├── bundle_mobile.css
└── bundle_desktop.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our static web server serves the compiled files directly, without routing them through the Go application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotcha: Air Doesn't Watch SCSS
&lt;/h2&gt;

&lt;p&gt;Air's config explicitly excludes &lt;code&gt;assets/&lt;/code&gt; and &lt;code&gt;storage/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;exclude_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tmp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentional. SCSS changes are handled by &lt;code&gt;sass --watch&lt;/code&gt;, which runs in parallel. If Air watched SCSS files too, it would rebuild the Go binary on every CSS edit — pointless and slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JavaScript has no watcher in this setup.&lt;/strong&gt; After editing an entry file, we run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; site make &lt;span class="nt"&gt;-f&lt;/span&gt; /Makefile build-js-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a deliberate project trade-off, not a requirement of Docker or Sass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Change
&lt;/h2&gt;

&lt;p&gt;If starting over, we would evaluate native CSS plus &lt;a href="https://lightningcss.dev/" rel="noopener noreferrer"&gt;Lightning CSS&lt;/a&gt;, or keep Sass and add a separate post-processing step. Lightning CSS can transform, prefix, bundle, and minify CSS, but it is not a drop-in compiler for SCSS syntax.&lt;/p&gt;

&lt;p&gt;Dart Sass remains a good fit for the current partials, and keeping the toolchain in Docker gives every developer the same compiler version.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>sass</category>
      <category>webdev</category>
      <category>docker</category>
    </item>
    <item>
      <title>HTMX + Alpine.js: Building a SPA Feel Without a Client-Rendered App</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:30:00 +0000</pubDate>
      <link>https://dev.to/towami/htmx-alpinejs-building-a-spa-feel-without-a-client-rendered-app-4cjf</link>
      <guid>https://dev.to/towami/htmx-alpinejs-building-a-spa-feel-without-a-client-rendered-app-4cjf</guid>
      <description>&lt;p&gt;Our marketplace uses no React, Vue, or Svelte application. Most of the interactive UI is HTMX and Alpine.js layered on server-rendered HTML. Alpine is a JavaScript framework, but it does not turn the site into a client-rendered SPA.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Principle
&lt;/h2&gt;

&lt;p&gt;Every navigable page URL can return a complete page rendered with Go and Templ. HTMX progressively enhances navigation by replacing fragments. Alpine.js handles local UI state such as dropdowns and modals.&lt;/p&gt;

&lt;p&gt;There is no virtual DOM or hydration step. We still copy or minify our JavaScript for deployment, and an isolated rich-text editor has its own bundle; HTMX and Alpine do not require a framework-specific compile pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTMX Navigation: Catalog Example
&lt;/h2&gt;

&lt;p&gt;The catalog keeps an ordinary GET form. Global &lt;code&gt;hx-boost&lt;/code&gt; progressively enhances links and forms, while the inherited target replaces the page's main content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;body&lt;/span&gt; &lt;span class="na"&gt;hx-boost=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
      &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#main-content"&lt;/span&gt;
      &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"innerHTML transition:true show:window:top"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"main-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"get"&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/en/vehicles/cars/for-sale"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;select&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"brand"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;All brands&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"toyota"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Toyota&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/select&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Apply&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Server-rendered catalog cards --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without JavaScript, the form performs normal navigation. With HTMX, &lt;code&gt;hx-boost&lt;/code&gt; sends an &lt;code&gt;HX-Request&lt;/code&gt; header and swaps &lt;code&gt;#main-content&lt;/code&gt;. The same URL remains directly navigable and crawlable.&lt;/p&gt;

&lt;p&gt;The form does not hardcode &lt;code&gt;hx-push-url&lt;/code&gt;. When query facets can be represented by a canonical path, the server returns the HTMX response with &lt;code&gt;HX-Push-Url&lt;/code&gt;; a normal request receives the corresponding redirect. This keeps URL normalization at the HTTP boundary instead of duplicating it in browser code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpine.js: Client-Side State
&lt;/h2&gt;

&lt;p&gt;Alpine handles interactions that do not need a server round trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Mobile menu toggle --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ open: false }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"open = !open"&lt;/span&gt;
            &lt;span class="na"&gt;:aria-expanded=&lt;/span&gt;&lt;span class="s"&gt;"open.toString()"&lt;/span&gt;
            &lt;span class="na"&gt;aria-controls=&lt;/span&gt;&lt;span class="s"&gt;"mobile-menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        Menu
    &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"mobile-menu"&lt;/span&gt; &lt;span class="na"&gt;x-show=&lt;/span&gt;&lt;span class="s"&gt;"open"&lt;/span&gt; &lt;span class="na"&gt;x-transition&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- menu items --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our Alpine components are registered from a plain JavaScript file. That is a project choice, not an Alpine requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notifications via HTMX Triggers
&lt;/h2&gt;

&lt;p&gt;When a server action completes, it can send a small UI event through an HTMX response header. The public and account UI uses &lt;code&gt;show-flash&lt;/code&gt; (the admin UI has an analogous &lt;code&gt;show-toast&lt;/code&gt; event):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;SendSiteFlash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isError&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"show-flash"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;isError&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="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HX-Trigger"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ToHeaderASCII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;On the client, Alpine listens for the event and updates an accessible status element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ msg: '', err: false, show: false }"&lt;/span&gt;
     &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;show-flash.window=&lt;/span&gt;&lt;span class="s"&gt;"msg = $event.detail.message; err = !!$event.detail.error; show = true"&lt;/span&gt;
     &lt;span class="na"&gt;x-show=&lt;/span&gt;&lt;span class="s"&gt;"show"&lt;/span&gt;
     &lt;span class="na"&gt;:role=&lt;/span&gt;&lt;span class="s"&gt;"err ? 'alert' : 'status'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;x-text=&lt;/span&gt;&lt;span class="s"&gt;"msg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JSON encoding protects the header's structure, while ASCII conversion keeps the HTTP header transport-safe. Neither step makes sensitive provider errors suitable for users, so map those to controlled messages first and keep header payloads small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Stack?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Progressive enhancement.&lt;/strong&gt; Important content and links exist in server-rendered HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A smaller client runtime.&lt;/strong&gt; We ship only the libraries and custom behavior the page needs. The actual payload should be measured, not assumed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A direct mental model.&lt;/strong&gt; The server returns HTML, HTMX swaps it, and Alpine owns small pockets of local state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Complex client-side interactions can still justify a focused library. We use a rich-text editor as an isolated module rather than making the entire site a client application.&lt;/p&gt;

&lt;p&gt;One boundary matters: Alpine validation is only a usability enhancement. Authorization and input validation still belong on the server.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>htmx</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>alpinejs</category>
    </item>
    <item>
      <title>Why We Chose Templ Over html/template (and What We Learned)</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/towami/why-we-chose-templ-over-htmltemplate-and-what-we-learned-4pp7</link>
      <guid>https://dev.to/towami/why-we-chose-templ-over-htmltemplate-and-what-we-learned-4pp7</guid>
      <description>&lt;p&gt;Go's standard &lt;a href="https://pkg.go.dev/html/template" rel="noopener noreferrer"&gt;&lt;code&gt;html/template&lt;/code&gt;&lt;/a&gt; works well. It is in the standard library, is well documented, and provides contextual escaping. We used it before switching to &lt;a href="https://templ.guide" rel="noopener noreferrer"&gt;Templ&lt;/a&gt;. Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With html/template
&lt;/h2&gt;

&lt;p&gt;As the codebase grew, we hit recurring issues:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Template mistakes surface at runtime.&lt;/strong&gt; Write &lt;code&gt;{{.Titl}}&lt;/code&gt; instead of &lt;code&gt;{{.Title}}&lt;/code&gt; and template execution returns an error. It is not normally a panic, but it is still later feedback than a compiler error.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Weaker editor support.&lt;/strong&gt; Support varies by editor and plugin, but field completion, navigation, and refactoring are less reliable across the boundary between Go and template text.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Runtime data contracts.&lt;/strong&gt; The template expects particular fields and methods, but the Go compiler does not type-check those references. Rename a field and tests—or a request that executes the template—must catch it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Templ Gives Us
&lt;/h2&gt;

&lt;p&gt;Templ files use their own Go-like syntax. The generator turns them into Go source, so component parameters and expressions are type-checked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// templates/card.templ&lt;/span&gt;
&lt;span class="n"&gt;templ&lt;/span&gt; &lt;span class="n"&gt;CatalogCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="n"&gt;CatalogCardView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt; &lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;h3&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;h3&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;FormatPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="o"&gt;&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;If &lt;code&gt;CatalogCardView&lt;/code&gt; has no &lt;code&gt;Title&lt;/code&gt; field, generation or compilation fails. If &lt;code&gt;FormatPrice&lt;/code&gt; changes its signature, compilation fails. Templ's editor tooling also provides syntax support and autocomplete; the file itself is not literally ordinary Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Generation Step
&lt;/h2&gt;

&lt;p&gt;Templ generates &lt;code&gt;*_templ.go&lt;/code&gt; files from &lt;code&gt;.templ&lt;/code&gt; files. This extra step is the main friction point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;templ generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never edit generated files manually: the next generation will overwrite them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Container Deduplication
&lt;/h2&gt;

&lt;p&gt;Several development services share the same bind-mounted templates. Letting each process generate the same output is redundant and creates concurrent writers. Our pre-build hook resolves the repository root, checks sources under &lt;code&gt;internal/&lt;/code&gt; and &lt;code&gt;cmd/&lt;/code&gt;, and checks for missing generated outputs. It performs the check both before and after acquiring one shared lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eu&lt;/span&gt;

&lt;span class="nv"&gt;ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;CDPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/.."&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;STAMP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/.dev"&lt;/span&gt;
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/templ.stamp"&lt;/span&gt;
&lt;span class="nv"&gt;LOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/templ.lock"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

needs_generate&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

  &lt;span class="nv"&gt;newer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/internal"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/cmd"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.templ'&lt;/span&gt; &lt;span class="nt"&gt;-newer&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; &lt;span class="nt"&gt;-quit&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$newer&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

  &lt;span class="nv"&gt;missing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/internal"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/cmd"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.templ'&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; 2&amp;gt;/dev/null |
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; src&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      &lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; .templ&lt;span class="si"&gt;)&lt;/span&gt;_templ.go
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo yes
        break
      &lt;/span&gt;&lt;span class="k"&gt;fi
    done&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$missing&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

needs_generate &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="nb"&gt;exec &lt;/span&gt;9&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
flock &lt;span class="nt"&gt;-w&lt;/span&gt; 180 9
needs_generate &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
templ generate
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second check matters: another container may have generated the files while this process waited. Missing-output detection also recovers from a deleted &lt;code&gt;*_templ.go&lt;/code&gt; even when no source timestamp changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Automate the generation step.&lt;/strong&gt; Treat it like protobuf code generation: run it consistently and never hand-edit the output. Whether generated files belong in version control is a team decision; we commit them so builds do not depend on generation happening implicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templ + HTMX is a natural pair.&lt;/strong&gt; Templ components return HTML fragments. HTMX swaps HTML fragments. No JSON serialization layer needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;templ&lt;/span&gt; &lt;span class="n"&gt;SearchResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cards&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;CatalogCardView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;cards&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;CatalogCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&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;The handler renders the component and returns it as an HTMX response. The browser swaps the fragment, with no client-side rendering layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compile-time feedback scales well.&lt;/strong&gt; A renamed field now fails generation or compilation instead of waiting for the affected template path to execute.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>templates</category>
      <category>webdev</category>
      <category>htmx</category>
    </item>
    <item>
      <title>Targeting 100/100 PageSpeed: Our Critical CSS Inlining Strategy</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Thu, 22 Oct 2026 19:32:00 +0000</pubDate>
      <link>https://dev.to/towami/targeting-100100-pagespeed-our-critical-css-inlining-strategy-32lp</link>
      <guid>https://dev.to/towami/targeting-100100-pagespeed-our-critical-css-inlining-strategy-32lp</guid>
      <description>&lt;p&gt;Our stretch target is a 100 PageSpeed score on merchant storefronts. A score is not a guarantee—it varies by page, data, device, network, and Lighthouse version—but the target forces us to keep the render path small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With One Big CSS File
&lt;/h2&gt;

&lt;p&gt;A render-blocking &lt;code&gt;styles.css&lt;/code&gt; can delay the first paint while the browser downloads, parses, and applies the rules needed for the page. For a marketplace with catalog pages, account dashboards, rich text editors, and storefronts, one shared file can grow quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Three-Tier CSS Strategy
&lt;/h2&gt;

&lt;p&gt;We split CSS into three categories based on when it's needed:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Inlined Route CSS
&lt;/h3&gt;

&lt;p&gt;The shared layout inlines its base styles, and public routes inline a small route-specific stylesheet in the server-rendered response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@templ.Raw("&amp;lt;style&amp;gt;" + assets.GetMainCSS("catalog_list_mobile.css") + "&amp;lt;/style&amp;gt;")
@templ.Raw(helper.DesktopStyle(
    assets.GetMainCSS("catalog_list_desktop.css"),
))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes an external request from the initial rendering path. These files are route styles, not automatically generated "above-the-fold" subsets, so we still inspect their size and contents.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. File-Linked CSS (account pages)
&lt;/h3&gt;

&lt;p&gt;Account pages change frequently and benefit less from duplicating CSS in every HTML response, so they use cacheable stylesheets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/storage/static/css/site/main_account_mobile.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Lazy-Loaded Bundles (everything else)
&lt;/h3&gt;

&lt;p&gt;Non-critical shared CSS — such as the footer, lightbox, and secondary catalog UI — loads asynchronously. Editor styles currently live in the linked account stylesheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/storage/static/css/site/bundle_mobile.css"&lt;/span&gt;
      &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"print"&lt;/span&gt; &lt;span class="na"&gt;data-media=&lt;/span&gt;&lt;span class="s"&gt;"all"&lt;/span&gt;
      &lt;span class="na"&gt;onload=&lt;/span&gt;&lt;span class="s"&gt;"this.media=this.dataset.media;this.onload=null"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;noscript&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/storage/static/css/site/bundle_mobile.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/noscript&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;media="print"&lt;/code&gt; pattern keeps the stylesheet from blocking initial screen rendering; once loaded, the handler changes its target media to &lt;code&gt;all&lt;/code&gt;. The &lt;code&gt;noscript&lt;/code&gt; fallback matters, and content must remain usable before the bundle arrives. This technique is suitable only for genuinely non-critical CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile-First File Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scss/
├── mobile/       ← base styles and small-screen rules
├── desktop/      ← desktop overrides
└── common/       ← shared variables, mixins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mobile CSS is the default. A helper wraps compiled desktop CSS in the project's desktop media query before inlining it. The deferred desktop bundle also has a media target. This prevents desktop rules from applying on small screens, but it does &lt;strong&gt;not&lt;/strong&gt; guarantee that mobile browsers never download those bytes.&lt;/p&gt;

&lt;p&gt;Each page section compiles to separate files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compiled-css/
├── main_catalog_list_mobile.css     ← inlined on catalog
├── main_catalog_list_desktop.css    ← inlined on catalog (desktop media)
├── main_account_mobile.css     ← linked on /account
├── main_account_desktop.css
├── bundle_mobile.css           ← lazy loaded
└── bundle_desktop.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Templ Integration
&lt;/h2&gt;

&lt;p&gt;Our Go templates read the compiled CSS files while rendering. In development, that happens on every render for live reload; in production, the first successful read is cached in memory. Simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;GetMainCSS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;production&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;loadOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&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="n"&gt;readCompiledCSS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&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;In the Templ layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@templ.Raw("&amp;lt;style&amp;gt;" + assets.GetMainCSS("catalog_list_mobile.css") + "&amp;lt;/style&amp;gt;")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;templ.Raw&lt;/code&gt; is safe here only because the input is trusted build output. Never pass merchant or user-controlled CSS through this path without a deliberate sanitization and isolation model.&lt;/p&gt;

&lt;p&gt;There is no client-side CSS-in-JS. The essential CSS arrives with the HTML, while shared non-critical styles can still be cached as external files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;This design improves the mechanics that affect first render, but we do not treat an unrecorded Lighthouse run as evidence. We compare before/after builds with the same page data and throttling, track stylesheet and HTML byte sizes, and use field Core Web Vitals when enough traffic is available.&lt;/p&gt;

&lt;p&gt;Inlining also has costs: repeated HTML bytes cannot use the browser's CSS cache, a large inline block can delay HTML transfer, and many entry points complicate builds. We monitor inline stylesheet and HTML sizes, move non-critical rules to external files, and re-measure as pages evolve. Defining an enforceable size budget is a useful next step.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>performance</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>Background Jobs in Go with Asynq: Emails, Notifications, and Resilience</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Thu, 15 Oct 2026 17:58:00 +0000</pubDate>
      <link>https://dev.to/towami/background-jobs-in-go-with-asynq-emails-notifications-and-resilience-4a17</link>
      <guid>https://dev.to/towami/background-jobs-in-go-with-asynq-emails-notifications-and-resilience-4a17</guid>
      <description>&lt;p&gt;When a customer places an order on a merchant's store, the checkout path does &lt;strong&gt;not&lt;/strong&gt; call an SMTP server. After saving the order, it asks a mailer interface to enqueue a task in Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Separate the Worker
&lt;/h2&gt;

&lt;p&gt;Our &lt;code&gt;worker&lt;/code&gt; binary is a dedicated process that pulls tasks from Redis queues using &lt;a href="https://github.com/hibiken/asynq" rel="noopener noreferrer"&gt;Asynq&lt;/a&gt;. It runs independently from the three HTTP binaries (&lt;code&gt;site&lt;/code&gt;, &lt;code&gt;shops&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The reasoning is simple: SMTP latency is variable, and transient failures need retries. Neither concern should block the HTTP response. The worker can retry independently if the mail server is temporarily unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task Architecture
&lt;/h2&gt;

&lt;p&gt;Email delivery uses one generic task type and a template discriminator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TypeEmailDelivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"email:deliver"&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EmailPayload&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;To&lt;/span&gt;       &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"to"`&lt;/span&gt;
    &lt;span class="n"&gt;Lang&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"lang"`&lt;/span&gt;
    &lt;span class="n"&gt;Template&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"template"`&lt;/span&gt;
    &lt;span class="n"&gt;Data&lt;/span&gt;     &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="s"&gt;`json:"data"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The producer marshals the payload and checks both operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EmailPayload&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Lang&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Template&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"new_shop_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"OrderID"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;asynq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypeEmailDelivery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnqueueContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker handles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;EmailHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ProcessTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;asynq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="n"&gt;EmailPayload&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"decode email payload: %v: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asynq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SkipRetry&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="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&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;Malformed JSON is permanent, so &lt;code&gt;SkipRetry&lt;/code&gt; is appropriate. SMTP/network errors are returned normally and remain retryable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lives in the Worker
&lt;/h2&gt;

&lt;p&gt;Beyond order emails, the worker handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email delivery&lt;/strong&gt; — sign-up codes, password resets, support mail, orders, and inquiries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram notifications&lt;/strong&gt; — queued reports and alerts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketplace imports&lt;/strong&gt; — longer, low-priority ingestion tasks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each task type is registered with its own handler. The worker also uses weighted &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, and &lt;code&gt;low&lt;/code&gt; queues. Those weights bias which queued task is selected next; they do not reserve workers, so long-running imports can still occupy the whole pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dev Gotcha
&lt;/h2&gt;

&lt;p&gt;During local development, a queued email will not be sent unless both Redis and the worker are running. Use the project's documented service and log commands rather than copying production hostnames or credentials into an article.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; worker
docker compose logs worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the worker is down while Redis remains available, pending tasks can be processed after the worker restarts. That is not the same as guaranteed durability: data loss still depends on Redis persistence, replication, eviction policy, and backup configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry Policy
&lt;/h2&gt;

&lt;p&gt;Asynq retries failed tasks according to its retry policy. Short notification tasks use the standard policy, while imports run in a lower-priority queue with bounded retries and execution time. After retries are exhausted, Asynq archives the task. Monitor queue latency, retry counts, and archived tasks with tooling such as &lt;a href="https://github.com/hibiken/asynqmon" rel="noopener noreferrer"&gt;Asynqmon&lt;/a&gt; or your own metrics.&lt;/p&gt;

&lt;p&gt;Asynq provides &lt;strong&gt;at-least-once&lt;/strong&gt; execution, not exactly-once execution. A worker can complete a side effect and fail before acknowledging the task, so handlers must be idempotent. For email, a provider idempotency key or a database send ledger can prevent unwanted duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just goroutines?
&lt;/h2&gt;

&lt;p&gt;A bare &lt;code&gt;go func() { sendEmail() }()&lt;/code&gt; has three structural problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lost tasks on restart.&lt;/strong&gt; A deploy kills the process and all in-flight goroutines die.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retry.&lt;/strong&gt; If SMTP is down for 5 minutes, all emails from that window are lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No visibility.&lt;/strong&gt; A bare goroutine provides no queue, failure, or processing state to inspect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Redis + Asynq gives us durable-enough queuing, retries, scheduling, and observability with little application code. The trade-offs are another stateful dependency and at-least-once semantics.&lt;/p&gt;

&lt;p&gt;One final boundary matters: a database commit and a queue publication are separate operations unless the design explicitly bridges them. For business-critical delivery, use a transactional outbox in the same database transaction and publish it asynchronously.&lt;/p&gt;

&lt;p&gt;Task payloads may contain email addresses or order details. Keep Redis on a private network with authentication and encryption as appropriate, minimize payloads and retention, and never log full payloads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://cr.towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>redis</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>Our Layered REST Architecture in Go: Why GET Must Not Mutate Application State</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Fri, 09 Oct 2026 23:50:00 +0000</pubDate>
      <link>https://dev.to/towami/our-layered-rest-architecture-in-go-why-get-must-not-mutate-application-state-1839</link>
      <guid>https://dev.to/towami/our-layered-rest-architecture-in-go-why-get-must-not-mutate-application-state-1839</guid>
      <description>&lt;p&gt;We have a hard rule in our codebase: &lt;strong&gt;GET handlers never mutate application state.&lt;/strong&gt; Incidental server-side effects such as access logging are compatible with HTTP's definition of a safe method, but a GET must not create, update, or delete an application resource. This matters even more when TLS 1.3 early data is enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 0-RTT Replay Problem
&lt;/h2&gt;

&lt;p&gt;We use Cloudflare with &lt;a href="https://developers.cloudflare.com/speed/optimization/protocol/0-rtt-connection-resumption/" rel="noopener noreferrer"&gt;0-RTT Connection Resumption&lt;/a&gt; enabled. TLS 1.3 early data can reduce latency for a resumed connection, but early data does not have inherent replay protection. An attacker may capture and replay it.&lt;/p&gt;

&lt;p&gt;Cloudflare supports 0-RTT for GET, HEAD, and OPTIONS requests and marks them with &lt;code&gt;Early-Data: 1&lt;/code&gt;. That safe-method restriction reduces the surface; it does not make a side-effecting GET safe. A GET that increments a counter, creates a record, or marks a notification as read may run more than once.&lt;/p&gt;

&lt;p&gt;The broader rule comes from &lt;a href="https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1" rel="noopener noreferrer"&gt;RFC 9110's safe-method semantics&lt;/a&gt;: clients, crawlers, caches, and prefetchers may invoke GET without expecting state changes. 0-RTT is one reason to respect that contract, not the only reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Layered Approach
&lt;/h2&gt;

&lt;p&gt;Layering and REST are separate ideas; this is the dependency flow we use for HTTP requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;router → middleware → handler → service → repository → model/DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer has a clear role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Router&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;URL → handler mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Middleware&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auth, country, locale, CSRF, rate limiting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Handler&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Parse request, call service, return response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Business logic, validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Repository&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQL/GORM queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database entity definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By convention, handlers do not import repositories directly and services do not construct SQL. Go does not enforce these boundaries automatically, so import checks, tests, and review are still useful. The payoff is that storage details can change without rewriting HTTP concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Enforcement
&lt;/h2&gt;

&lt;p&gt;GET handlers depend on query-oriented service interfaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CatalogQueries&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CatalogFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;CatalogCard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CatalogHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;CatalogFilter&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ShouldBindQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatusJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"invalid filters"&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="n"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cards&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;Publishing, editing, and deleting use POST, PUT, PATCH, or DELETE. Cloudflare does not accept those methods as 0-RTT early data, but the method alone does not prevent duplicates: clients and proxies can retry requests, and users can double-submit. Important mutations still need transactions, uniqueness constraints, or idempotency keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  DTO Separation
&lt;/h2&gt;

&lt;p&gt;We split transport types by application context. A card view should expose only what the page needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CatalogCard&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;        &lt;span class="kt"&gt;uint&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Slug&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Price&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Currency&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Image&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository also uses an explicit projection for list queries instead of loading large text and JSONB columns. This reduces transferred and decoded data, although the actual gain must be measured against the real query and dataset rather than assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Architecture Helps Prevent
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accidental side effects on safe routes&lt;/strong&gt; — GET stays safe under replay, crawling, and prefetching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered business rules&lt;/strong&gt; — mutations have one service-level entry point&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-fetching&lt;/strong&gt; — explicit repository projections make selected columns reviewable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transport/storage coupling&lt;/strong&gt; — HTTP types do not have to mirror database models&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is more boilerplate than a flat handler-does-everything approach. The rule is worth it because it makes behavior easier to test and reason about. It is not, by itself, complete replay protection: mutation endpoints still require their own idempotency and authorization controls.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>rest</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Serving Tenant Storefronts on Custom Domains with Go + Cloudflare for SaaS</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/towami/serving-tenant-storefronts-on-custom-domains-with-go-cloudflare-for-saas-539p</link>
      <guid>https://dev.to/towami/serving-tenant-storefronts-on-custom-domains-with-go-cloudflare-for-saas-539p</guid>
      <description>&lt;p&gt;A merchant storefront can live on a platform subdomain (&lt;code&gt;mystore.platform.example&lt;/code&gt;) or a custom domain (&lt;code&gt;www.store.example&lt;/code&gt;). Both options are served by the same Go application. Here is how we resolve which shop to render.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shop Host Middleware
&lt;/h2&gt;

&lt;p&gt;When a request hits the &lt;code&gt;shops&lt;/code&gt; binary, a middleware extracts the &lt;code&gt;Host&lt;/code&gt; header and figures out which shop it belongs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ShopHostMiddleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&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;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;requestHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&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="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isApexHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subdomainSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isCountry&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Countries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;isCountry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&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="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolveShop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isShopSubdomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c"&gt;// not a configured custom hostname&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShopContextKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&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;&lt;code&gt;resolveShop&lt;/code&gt; tries two strategies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain lookup&lt;/strong&gt;: extracts &lt;code&gt;mystore&lt;/code&gt; from &lt;code&gt;mystore.platform.example&lt;/code&gt; and looks up that unique slug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom-domain lookup&lt;/strong&gt;: normalizes the supported &lt;code&gt;www.store.example&lt;/code&gt; hostname to the stored root, &lt;code&gt;store.example&lt;/code&gt;, and looks it up through a unique index.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Country subdomains are explicitly excluded because they belong to the marketplace application. We parse hostnames with &lt;code&gt;net.SplitHostPort&lt;/code&gt;, lowercase them, and validate them against known suffixes. If a reverse proxy supplies &lt;code&gt;X-Forwarded-Host&lt;/code&gt;, trust it only from a proxy you control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare for SaaS: The SSL Part
&lt;/h2&gt;

&lt;p&gt;The hardest part of custom domains is not routing but certificate issuance and renewal. We use &lt;a href="https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/" rel="noopener noreferrer"&gt;Cloudflare for SaaS custom hostnames&lt;/a&gt; for that lifecycle.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;We configure a fallback origin such as &lt;code&gt;connect.platform.example&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;When a merchant adds a custom domain, our app calls the Cloudflare API to create a Custom Hostname&lt;/li&gt;
&lt;li&gt;The merchant sets &lt;code&gt;CNAME www → connect.platform.example&lt;/code&gt; at their DNS provider&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our Custom Hostname request uses HTTP DCV. Once the &lt;code&gt;www&lt;/code&gt; CNAME routes through Cloudflare, Cloudflare can validate the hostname and issue the certificate for &lt;code&gt;www.store.example&lt;/code&gt;. Cloudflare retries failed validation on a backoff schedule. A status &lt;code&gt;GET&lt;/code&gt; only observes that process; if we need an immediate recheck after DNS is fixed, the documented API flow is to &lt;code&gt;PATCH&lt;/code&gt; the hostname with its current SSL settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why www-Only
&lt;/h2&gt;

&lt;p&gt;We only support &lt;code&gt;www.{domain}&lt;/code&gt;, not bare apex domains. A conventional CNAME cannot coexist with the other records required at a DNS zone apex. Some DNS providers offer ALIAS/ANAME or CNAME flattening, but support and behavior vary. The merchant therefore configures an HTTP redirect from &lt;code&gt;store.example&lt;/code&gt; to &lt;code&gt;www.store.example&lt;/code&gt; with their DNS or registrar provider.&lt;/p&gt;

&lt;p&gt;This is a product constraint, not a universal Cloudflare limitation. Cloudflare for SaaS also documents &lt;a href="https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/#apex-proxying" rel="noopener noreferrer"&gt;apex proxying&lt;/a&gt;, but its prerequisites and plan availability should be checked before committing to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status Tracking
&lt;/h2&gt;

&lt;p&gt;For each shop we store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the provider's opaque Custom Hostname ID&lt;/li&gt;
&lt;li&gt;a local status: &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;, or &lt;code&gt;error&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a provider status or error summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a merchant connects a domain, we create or find the Cloudflare hostname, store its ID, and map Cloudflare's hostname and SSL states to our smaller status set. The dashboard can explicitly refresh that state. A scheduled poller is another option, but the current implementation does not depend on one.&lt;/p&gt;

&lt;p&gt;The API token stays server-side and is never returned to the browser; production credentials should have only the required zone permissions. Provider error text is untrusted. Our templates HTML-escape it, but a stronger boundary is to log detailed provider errors server-side and show merchants a bounded, generic message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define one canonical hostname.&lt;/strong&gt; In our case, input may include &lt;code&gt;www.&lt;/code&gt;, but storage uses the root and serving uses &lt;code&gt;www.&lt;/code&gt;. This rule must be consistent in validation, provisioning, and lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use reserved example domains in documentation.&lt;/strong&gt; A wildcard under a local &lt;code&gt;.test&lt;/code&gt; domain is enough to exercise host routing without Cloudflare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index both lookup keys.&lt;/strong&gt; Host resolution is on the request path, so subdomain slugs and custom domains need unique indexes. Add caching only with an explicit invalidation strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make provisioning idempotent.&lt;/strong&gt; Before creating a hostname, look it up; retries should not produce duplicate provider resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Custom domains are table stakes for many SaaS storefront products. Cloudflare for SaaS made it possible without running our own ACME client or certificate renewal infrastructure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>cloudflare</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Multi-Host Routing in Go: One Binary, Many Domains</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Tue, 29 Sep 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/towami/multi-host-routing-in-go-one-binary-many-domains-56bh</link>
      <guid>https://dev.to/towami/multi-host-routing-in-go-one-binary-many-domains-56bh</guid>
      <description>&lt;p&gt;Our marketplace is organized around multiple country sites. Each country gets its own subdomain, while the apex is the cross-country portal. A single Go application serves the portal and country catalogs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Middleware Approach
&lt;/h2&gt;

&lt;p&gt;Instead of path-based routing (&lt;code&gt;/cr/en/vehicles&lt;/code&gt;), we use host-based resolution. A Gin middleware extracts the country from the &lt;code&gt;Host&lt;/code&gt; header before any route handler fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CountryHostMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&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;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CountrySlugFromHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CountryContextKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OnCountryHostContextKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&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;&lt;code&gt;CountrySlugFromHost&lt;/code&gt; strips the port, lowercases the host, and accepts only a one-label subdomain in the country allowlist. If &lt;code&gt;cr.example.com&lt;/code&gt; comes in, the context gets &lt;code&gt;country=cr&lt;/code&gt;. Edge routing sends shop hosts to a different application; this middleware must not treat an arbitrary subdomain as a country.&lt;/p&gt;

&lt;p&gt;A later locale middleware derives language preferences and puts both country and language into the request context. Currency is resolved separately because a visitor may choose a display currency that differs from the country's default.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL Structure: Language in Path, Country in Host
&lt;/h2&gt;

&lt;p&gt;Each country host serves localized URLs such as &lt;code&gt;/{lang}/vehicles&lt;/code&gt; and &lt;code&gt;/{lang}/real-estate&lt;/code&gt;. The host's default-language homepage lives at &lt;code&gt;/&lt;/code&gt;; requesting the redundant default-language homepage returns a 301 to &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This design gives us clean hreflang tags for Google:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en-CR"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cr.example.com/en/vehicles"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"es-CR"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cr.example.com/es/vehiculos"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cr.example.com/es/vehiculos"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Cookie Problem
&lt;/h2&gt;

&lt;p&gt;Authentication is shared across the platform's apex and country subdomains. The session cookie uses &lt;code&gt;Domain=example.com&lt;/code&gt;, so the browser sends it to both &lt;code&gt;example.com&lt;/code&gt; and its subdomains. A leading dot is accepted by browsers but ignored by modern cookie semantics, so &lt;code&gt;Domain=.example.com&lt;/code&gt; does not create a different scope.&lt;/p&gt;

&lt;p&gt;That scope is also a security boundary. A compromised sibling subdomain may be able to shadow or overwrite a domain cookie, and &lt;code&gt;SameSite&lt;/code&gt; does not isolate sibling subdomains because they are the same site. We therefore share the cookie only with trusted platform hosts and set &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;HttpOnly&lt;/code&gt;, and an appropriate &lt;code&gt;SameSite&lt;/code&gt; value. Tenant-owned custom domains cannot share this cookie.&lt;/p&gt;

&lt;p&gt;If cross-subdomain login is unnecessary, a host-only cookie is safer and can use the &lt;code&gt;__Host-&lt;/code&gt; prefix. That prefix is incompatible with a &lt;code&gt;Domain&lt;/code&gt; attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Legacy URL Migration
&lt;/h2&gt;

&lt;p&gt;We originally had country paths on the apex: &lt;code&gt;example.com/cr/en/vehicles&lt;/code&gt;. When we moved to subdomains, we needed 301 redirects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;LegacyCountryRedirectMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&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;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnCountryHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LegacyCountryPathRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusMovedPermanently&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Abort&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&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;Old links such as &lt;code&gt;example.com/cr/en/vehicles&lt;/code&gt; now redirect permanently to &lt;code&gt;cr.example.com/en/vehicles&lt;/code&gt;. We also preserve the query string deliberately and test malformed country/language combinations so the redirect cannot become an open redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;One binary and one router serve the portal and multiple country catalogs. Adding a country still requires registry, locale, currency, SEO, and data changes, but it does not require a new service architecture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://cr.towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>saas</category>
    </item>
    <item>
      <title>How We Run 4 Services From One Go Monorepo</title>
      <dc:creator>Anton Makasin</dc:creator>
      <pubDate>Thu, 24 Sep 2026 21:21:09 +0000</pubDate>
      <link>https://dev.to/towami/how-we-run-4-services-from-one-go-monorepo-5dlm</link>
      <guid>https://dev.to/towami/how-we-run-4-services-from-one-go-monorepo-5dlm</guid>
      <description>&lt;p&gt;We're building Towami — a commerce platform that combines white-label online stores with country marketplaces. One codebase produces four long-running services, each with a separate entry point and responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Binaries
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmd/
├── site/     → marketplace portal + country catalogs
├── shops/    → merchant storefronts on custom domains
├── admin/    → back-office panel
└── worker/   → background jobs via Asynq (no HTTP)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;site&lt;/code&gt; serves the main domain and multiple country subdomains. &lt;code&gt;shops&lt;/code&gt; serves merchant storefronts on platform subdomains and custom domains. &lt;code&gt;admin&lt;/code&gt; is the back-office dashboard. &lt;code&gt;worker&lt;/code&gt; processes email, Telegram, and import tasks through Redis queues.&lt;/p&gt;

&lt;p&gt;The repository also has an importer command. It is a CLI utility rather than a long-running service, so it is outside the four in the title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared &lt;code&gt;internal/&lt;/code&gt; With Clean Boundaries
&lt;/h2&gt;

&lt;p&gt;All four binaries import from a single &lt;code&gt;internal/&lt;/code&gt; package tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;router → middleware → handler → service → repository → model/DB
                        ↘ templates (Templ)
                        ↘ dto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is sub-packaging by application: &lt;code&gt;handler/site/&lt;/code&gt;, &lt;code&gt;handler/shops/&lt;/code&gt;, and &lt;code&gt;handler/admin/&lt;/code&gt;, with the same split for services, repositories, DTOs, and templates. Each binary imports only the packages it needs. The worker, for example, does not import HTTP handlers or templates.&lt;/p&gt;

&lt;p&gt;Go's &lt;code&gt;internal&lt;/code&gt; directory also prevents code outside the parent module tree from importing these packages. It does not enforce our layer order, though; that remains an architectural convention backed by review and tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Microservices?
&lt;/h2&gt;

&lt;p&gt;Early on, we considered splitting into separate repos. Three things stopped us:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared models.&lt;/strong&gt; Products, shops, and users are the same entities everywhere. Duplicating model definitions across repos means sync hell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic refactors.&lt;/strong&gt; When we change a database column, the handler, service, repository, DTO, and template all update in one commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment simplicity.&lt;/strong&gt; Four builds with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt; produce self-contained binaries that can be managed as independent service units. We do not need container orchestration, a service mesh, or versioned APIs between these cooperating applications.&lt;/p&gt;

&lt;p&gt;This is still a modular monolith: the binaries share code and data contracts. Separate executables alone do not make them microservices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotcha: Air and Hot Reload
&lt;/h2&gt;

&lt;p&gt;In development, each service runs in Docker with &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt; for hot reload. Without scoped watch rules, an edit to shared code can rebuild every service.&lt;/p&gt;

&lt;p&gt;We solved this with scoped watch configs. The &lt;code&gt;worker&lt;/code&gt; Air config ignores &lt;code&gt;templates/&lt;/code&gt;, &lt;code&gt;handler/&lt;/code&gt;, and &lt;code&gt;router/&lt;/code&gt;. The &lt;code&gt;admin&lt;/code&gt; config ignores &lt;code&gt;handler/site/&lt;/code&gt; and &lt;code&gt;handler/shops/&lt;/code&gt;. Each service only rebuilds when files relevant to it change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# Worker: exclude HTTP and UI layers&lt;/span&gt;
&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;exclude_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"internal/templates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"internal/handler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"internal/router"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"storage"&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;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;A Go monorepo with one &lt;code&gt;cmd/&lt;/code&gt; entry point per service gives us process-level separation while keeping atomic refactors and one &lt;code&gt;go.mod&lt;/code&gt;. It is a good fit while the applications share a schema and are maintained by one team. If independent ownership or deployment becomes the real constraint, the boundary can change later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next up: how one of those binaries serves multiple country sites on different subdomains with a single process.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
