Handling Inline Scripts in htmx with Go Templates

javascript dev.to

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
Enter fullscreen mode Exit fullscreen mode
<!-- index.html (layout) -->
<div class="layout">
  <!-- ...table and gallery HTML... -->
</div>

<script>
  function setViewMode(mode) { ... }
  function initGalleryView() { ... }
  document.addEventListener('htmx:afterSettle', function(evt) { ... });
</script>
Enter fullscreen mode Exit fullscreen mode
<!-- objects.html (htmx fragment) -->
<div class="main-view">
  <!-- ...table and gallery HTML... -->
</div>
Enter fullscreen mode Exit fullscreen mode

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 (like main.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
Enter fullscreen mode Exit fullscreen mode
<!-- index.html (layout) -->
<div class="layout">
  <!-- ...table and gallery HTML... -->
</div>

<script>
  document.addEventListener('htmx:afterSettle', function(evt) { ... });
</script>
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- objects.html (htmx fragment) -->
<script src="static/buckets-objects.js"></script>
<div class="main-view">
  <!-- ...table and gallery HTML... -->
</div>
Enter fullscreen mode Exit fullscreen mode

✅ 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
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

✅ 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}}`
Enter fullscreen mode Exit fullscreen mode
<!-- index.html (layout) -->
<div class="layout">
  <!-- ...table and gallery HTML... -->
</div>
{{template "scripts:buckets/objects.html" nil}}
Enter fullscreen mode Exit fullscreen mode
<!-- 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}}
Enter fullscreen mode Exit fullscreen mode

✅ 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
Enter fullscreen mode Exit fullscreen mode

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!

Source: dev.to

arrow_back Back to Tutorials