For the Web Console of S2, I took a small gamble by adopting htmx. At the time, I only had a conceptual understanding, so I relied on Claude Code for the initial implementation. I did a quick review of the code and focused on verifying that things worked before shipping.
Once the core S2 features settled down, I came back to refactor the console — and ran into a specific problem with a solution I think is worth sharing.
The Problem: Script Bloat in index.html
Before the refactor, the structure looked like this:
- index.html — layout, contains scripts for fragment operations
- buckets/
- objects.html — htmx fragment
<!-- index.html (layout) -->
<div class="layout">
<!-- ...table and gallery HTML... -->
</div>
<script>
function setViewMode(mode) { ... }
function initGalleryView() { ... }
document.addEventListener('htmx:afterSettle', function(evt) { ... });
</script>
<!-- objects.html (htmx fragment) -->
<div class="main-view">
<!-- ...table and gallery HTML... -->
</div>
All the scripts were concentrated in the layout file. These were vanilla JS snippets for things like view mode switching and lazy-loading gallery images — nothing fancy, but they belonged logically with the fragment, not the layout.
Breaking this down, two problems emerge:
- As the app grows, the layout's script block gets bloated.
- Scripts are physically separated from the fragment they belong to, making development harder.
htmx has a tendency to push scripts toward the layout if you're not careful. Here's why:
-
Initialization: To avoid re-running
<script>tags every time a fragment loads, the easiest approach is to centralize everything in one place (likemain.js) using event delegation (htmx.on(...)). - Library dependencies: Heavy libraries like Chart.js or Leaflet can't be reloaded on every fragment swap, so they end up loaded once in the layout.
- Global state: Managing things like modal open/close state or notifications naturally drifts toward the shared layout.
The common solutions — Alpine.js or Custom Elements (Web Components) — didn't quite fit:
- Alpine.js: Adds another external dependency, and in practice the lazy-loading logic I needed became harder to read.
- Custom Elements: No dependencies and it's a web standard, but the class definition/registration boilerplate felt like overkill for this use case.
So I went looking for another way.
Solution 1: Move Scripts into the Fragment
The obvious first idea: just move the scripts into the fragment.
- index.html — layout, event listener registration remains
- buckets/
- objects.html — fragment with its own scripts
<!-- index.html (layout) -->
<div class="layout">
<!-- ...table and gallery HTML... -->
</div>
<script>
document.addEventListener('htmx:afterSettle', function(evt) { ... });
</script>
<!-- objects.html (htmx fragment) -->
<div class="main-view">
<!-- ...table and gallery HTML... -->
</div>
<script>
// Re-executes every time the fragment loads
function setViewMode(mode) { ... }
function initGalleryView() { ... }
</script>
This works as intended for the most part, but the <script> block re-executes every time objects.html is loaded.
✅ Scripts don't pile up in index.html
✅ Scripts live with the fragment they belong to
❌ Event listeners still need to live in index.html (because of the re-execution problem)
❌ The script block runs again on every load
Solution 2: External Script Files per Fragment
Next idea: extract scripts into per-fragment external files.
- index.html — layout
- static/
- buckets-objects.js — per-screen script
- buckets/
- objects.html — fragment, loads static/buckets-objects.js
<!-- objects.html (htmx fragment) -->
<script src="static/buckets-objects.js"></script>
<div class="main-view">
<!-- ...table and gallery HTML... -->
</div>
✅ Scripts are gone from index.html
✅ Re-execution problem is solved
✅ Event listeners can move out of index.html
❌ Scripts are now separated from the fragment again — back to square one for discoverability
Solution 3: JavaScript Guard Flag
What if we keep scripts inline but guard against re-execution?
- index.html — layout
- buckets/
- objects.html — fragment, script guarded inline
<!-- objects.html (htmx fragment) -->
<div class="main-view">
<!-- ...table and gallery HTML... -->
</div>
<script>
if (!window._objectsScriptLoaded) {
window._objectsScriptLoaded = true;
document.addEventListener('htmx:afterSettle', function(evt) { ... });
function setViewMode(mode) { ... }
function initGalleryView() { ... }
}
</script>
✅ Scripts are gone from index.html
✅ Scripts live with the fragment
✅ Functions aren't re-defined thanks to the flag
❌ The <script> block still re-executes (even if the body is skipped)
❌ Every fragment accumulates a low-value if wrapper
Solution 4: Using Go's {{define}} (The One I Actually Shipped)
This is the approach I ended up with. It came out of a back-and-forth with Claude Code — it told me "this isn't possible, let's revert" a few times, but I kept pushing and we got there.
- index.html — layout, embeds `{{define}}` blocks via `{{template}}`
- buckets/
- objects.html — fragment, scripts wrapped in `{{define}}`
<!-- index.html (layout) -->
<div class="layout">
<!-- ...table and gallery HTML... -->
</div>
{{template "scripts:buckets/objects.html" nil}}
<!-- objects.html (htmx fragment) -->
<div class="main-view">
<!-- ...table and gallery HTML... -->
</div>
<!-- define blocks are not rendered when this template executes -->
{{define "scripts:buckets/objects.html"}}
<script>
document.addEventListener('htmx:afterSettle', function(evt) { ... });
function setViewMode(mode) { ... }
function initGalleryView() { ... }
</script>
{{end}}
✅ Scripts are gone from index.html
✅ Scripts live with the fragment they belong to
❌ (none that I've found)
This pattern is only possible because the project uses Go's html/template. Here's how it works end to end.
How It Works
Page load (GET /)
└→ Render index.html
└→ {{template "scripts:buckets/objects.html" .}} embeds the script block
Bucket click (htmx swap)
└→ GET /buckets/foo → render objects.html
└→ {{define "scripts:..."}} produces no output
└→ No function re-definition, no duplicate listeners
The key insight: when Go renders a template, {{define}} blocks within that template are registered but not output. They only produce output when explicitly called via {{template}}. So when htmx swaps in the fragment, the script definition is silently ignored — no re-execution, no guards needed.
Why This Pattern Works Well
| Concern | How it's handled |
|---|---|
| Locality | Scripts live in the same file as the HTML they control |
| No re-execution |
{{define}} blocks are never output during fragment renders — guaranteed by the language |
| No guards needed | No {{if}} wrappers, no flag variables |
| Discoverability |
scripts: prefix makes them trivially grep-able |
| Single source of truth |
{{template}} in the layout is the one registration point |
Wrap-up
By combining Go's {{define}} and {{template}}, I found a way to keep fragment scripts co-located with their HTML while ensuring they load exactly once.
If you're building an htmx app and running into the "where do I put my scripts" problem, it's worth looking at what your server-side templating engine can do — the answer might already be there.
Hope this is useful for anyone else working with Go + htmx!