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.
The Problem With One Big CSS File
A render-blocking styles.css 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.
Our Three-Tier CSS Strategy
We split CSS into three categories based on when it's needed:
1. Inlined Route CSS
The shared layout inlines its base styles, and public routes inline a small route-specific stylesheet in the server-rendered response:
@templ.Raw("<style>" + assets.GetMainCSS("catalog_list_mobile.css") + "</style>")
@templ.Raw(helper.DesktopStyle(
assets.GetMainCSS("catalog_list_desktop.css"),
))
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.
2. File-Linked CSS (account pages)
Account pages change frequently and benefit less from duplicating CSS in every HTML response, so they use cacheable stylesheets:
<link rel="stylesheet" href="/storage/static/css/site/main_account_mobile.css">
3. Lazy-Loaded Bundles (everything else)
Non-critical shared CSS — such as the footer, lightbox, and secondary catalog UI — loads asynchronously. Editor styles currently live in the linked account stylesheet:
<link rel="stylesheet" href="/storage/static/css/site/bundle_mobile.css"
media="print" data-media="all"
onload="this.media=this.dataset.media;this.onload=null">
<noscript>
<link rel="stylesheet" href="/storage/static/css/site/bundle_mobile.css">
</noscript>
The media="print" pattern keeps the stylesheet from blocking initial screen rendering; once loaded, the handler changes its target media to all. The noscript fallback matters, and content must remain usable before the bundle arrives. This technique is suitable only for genuinely non-critical CSS.
Mobile-First File Structure
scss/
├── mobile/ ← base styles and small-screen rules
├── desktop/ ← desktop overrides
└── common/ ← shared variables, mixins
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 not guarantee that mobile browsers never download those bytes.
Each page section compiles to separate files:
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
The Templ Integration
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:
func GetMainCSS(filename string) string {
if production {
return loadOnce(filename)
}
return readCompiledCSS(filename)
}
In the Templ layout:
@templ.Raw("<style>" + assets.GetMainCSS("catalog_list_mobile.css") + "</style>")
templ.Raw 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.
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.
The Results
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.
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.
This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.
Top comments (0)