A deep-research synthesis across the official templ docs, templ-components' own ADR 0005, the T.A.H. Stack (templ + Alpine + HTMX), Datastar, React islands, CSP compliance, TypeScript workflows, and the View Transitions API.
Before writing ANY JavaScript, walk this ladder. Stop at the first rung that solves your problem.
| Rung | Tool | When to use | JS required |
|---|---|---|---|
| 1 | Native HTML | <details>, <form>, :checked, :target |
Zero |
| 2 | HTMX | Server round-trips, partial updates, form submits | Zero (attrs) |
| 3 | Inline <script> + singleton |
Document-level event delegation (dropdowns, tabs, accordions) | Minimal, CSP-safe |
| 4 | Alpine.js | Client-side state (toggles, modals, filtering) without server round-trip | ~8KB lib |
| 5 | External JS bundle (esbuild) | Complex client-side logic, charts, editors | Full build |
| 6 | React/Vue islands | Rich interactive widgets that can't be server-rendered | Framework + bundler |
| 7 | Datastar | Server-driven reactive UI with SSE streaming (replaces HTMX + Alpine) | ~15KB lib |
Used by: templ-components (this repo), production Go apps that want zero JS dependencies.
Every interactive component emits ONE inline <script> that registers a
document-level event listener, guarded by a global flag so HTMX re-renders are
idempotent:
templ Dropdown(props DropdownProps) {
<div data-tc-dropdown={ id }>
<button data-tc-dropdown-trigger={ id }>Open</button>
<div data-tc-dropdown-menu={ id } class="hidden">...</div>
</div>
<script nonce={ props.Nonce }>
if (!window.tcDropdownAttached) {
window.tcDropdownAttached = true;
document.addEventListener('click', function(e) {
var trigger = e.target.closest('[data-tc-dropdown-trigger]');
if (trigger) {
var id = trigger.getAttribute('data-tc-dropdown-trigger');
var menu = document.querySelector('[data-tc-dropdown-menu="' + id + '"]');
menu.classList.toggle('hidden');
}
});
}
</script>
}window.tc*Attachedguard prevents double-binding on HTMX swaps.document.addEventListener+e.target.closest('[data-*]')= event delegation (works for dynamically added elements).nonce={ props.Nonce }on every<script>for CSP compliance.- Zero inline handlers (
onclick, etc.) — fullyscript-src-attr 'none'compliant.
- You're building a component library (like templ-components) where adding a JS framework dependency is unacceptable.
- Your interactivity is document-level event delegation (dropdowns, accordions, tabs, copy buttons, dismiss buttons).
- You need HTMX DOM-swap compatibility — document-level listeners automatically handle newly swapped elements.
Modal and Drawer use per-instance IIFE (not document-level delegation) because they
need per-instance state (focus trap, previous-focus element). See
overlayTrapJS() in display/shared.go.
| Pros | Cons |
|---|---|
| Zero dependencies | Verbose for complex state |
| HTMX-compatible | Manual focus management |
| CSP-safe (nonce on every script) | No reactivity system |
| Works after DOM swaps | Each component repeats the boilerplate |
This repo uses 12 singleton guards across all interactive components (see ADR 0005).
Used by: Server-rendered apps where the server is the source of truth.
templ SearchResults(query string, results []Result) {
<form hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:300ms">
<input name="q" value={ query } />
</form>
<div id="results">
for _, r := range results {
<div>{ r.Title }</div>
}
</div>
}The same templ component renders both the initial page AND the HTMX partial update. No separate API endpoint or JSON serialization needed.
| Pros | Cons |
|---|---|
| Simplest possible interactivity | Server round-trip for every interaction |
| Progressive enhancement (works w/o JS) | No client-side state |
| Reuses same templ component | Latency on every interaction |
Used by: templUI, most community templates, the "GOAT Stack"
Alpine.js handles client-side state (toggles, dropdowns); HTMX handles server communication.
templ EditableRow(item Item) {
<tr x-data="{ editMode: false }">
<td>
<template x-if="!editMode">
<span x-text={ templ.JSONString(item.Email) }></span>
</template>
<template x-if="editMode">
<input type="text" x-model={ templ.JSONString(item.Email) } />
</template>
</td>
<td>
<button x-show="!editMode" @click="editMode = true">Edit</button>
<button x-show="editMode" hx-post={ "/save/" + item.ID } hx-swap="outerHTML">Save</button>
</td>
</tr>
}<div x-data={ templ.JSONString(myGoStruct) }>templ.JSONString HTML-encodes the JSON so it's safe in an attribute value.
| Feature | Directive | Use case |
|---|---|---|
| Toggles/show-hide | x-show, @click |
Dropdowns, modals, confirmation |
| Conditional rendering | x-if (template) |
Edit mode toggling |
| Two-way data binding | x-model |
Form inputs |
| Text binding | x-text |
Displaying server data reactively |
| List rendering | x-for (template) |
Client-side filtering |
| Attribute binding | x-bind / :attr |
Dynamic classes, styles |
When templ renders a Go string directly into x-text or x-model, the string is
output without quotes, which Alpine interprets as a JS variable name rather than
a string literal. Use templ.JSONString() or wrap in a x-data initialization to
avoid this.
| Pros | Cons |
|---|---|
| No build step | Another library (~8KB) |
| Tiny, declarative | x-data values are JS expressions (escape!) |
| Handles client-side state elegantly | Doesn't help with server communication |
Used by: datastarui (shadcn/ui port in Go), growing adoption in Go community
Replaces both HTMX and Alpine.js with a single ~15KB library using SSE.
templ Counter(signals CounterSignals) {
<div data-signals={ templ.JSONString(signals) }>
<button data-on:click="@post('/increment')">+1</button>
<span data-text="$count">0</span>
</div>
}sse := datastar.NewSSE(w, r)
// Option A: Patch HTML elements (morphed into DOM by ID)
sse.MergeFragmentTempl(counterTemplate(updatedSignals))
// Option B: Patch only signals (most efficient — no HTML sent)
sse.MarshalAndMergeSignals(patchJSON)| Aspect | HTMX | Datastar |
|---|---|---|
| Architecture | Frontend-driven (attrs on trigger) | Server-driven (server sends patches) |
| Transport | AJAX (fetch) request/response | SSE streaming |
| DOM updates | innerHTML swap (morph via ext) |
DOM morphing by default (preserves state) |
| Client state | None (pair with Alpine.js) | Built-in reactive signals |
| Bundle size | ~14KB | ~15KB |
| Expressions | hx-* attributes + hyperscript |
data-* attributes use plain JS + $signal |
| Pros | Cons |
|---|---|
| Single library (no Alpine needed) | Steeper learning curve |
| SSE streaming for real-time | SSE lifecycle management on server |
| DOM morphing preserves focus/scroll/input | Newer ecosystem than HTMX |
| Signal-only patches (no HTML round-trip) | data-* expressions are JS (escaping!) |
Used by: Apps with complex widgets (charts, editors, React Flow) that can't be server-rendered.
templ ChartWidget(data ChartData) {
<div id="chart" data-config={ templ.JSONString(data) }>
<script nonce={ nonce }>
bundle.renderChart(document.currentScript.closest('div'));
</script>
</div>
}Build: esbuild --bundle --global-name=bundle --outfile=assets/js/chart.js src/Chart.tsx
The server renders a placeholder <div> with data-* attributes; the JS bundle
mounts the React component into it. The rest of the page is pure templ.
| Feature | Purpose | Example |
|---|---|---|
templ.NewOnceHandle() |
Render a <script> block only once per page |
@handle.Once() { <script>...</script> } |
templ.WithNonce(ctx, nonce) |
Inject CSP nonce into context | All inline <script> tags get nonce="..." |
templ.JSFuncCall("fn", data) |
Call JS function with JSON-encoded Go data | onclick={ templ.JSFuncCall("alert", msg) } |
templ.JSExpression("event") |
Pass raw JS expression (event objects, this) |
onclick={ templ.JSFuncCall("fn", templ.JSExpression("event")) } |
templ.JSONString(data) |
Encode Go data as JSON in an HTML attribute | x-data={ templ.JSONString(data) } |
templ.JSONScript("id", data) |
Embed data in <script type="application/json"> |
Large data payloads, chart configs |
{{ value }} in <script> |
Interpolate Go values into JS (auto-escaped) | const msg = "{{ user.Name }}"; |
When a component is used multiple times on a page, use OnceHandle so its
<script> block renders only once:
var helloHandle = templ.NewOnceHandle()
templ hello(label, name string) {
@helloHandle.Once() {
<script>
function hello(name) { alert('Hello, ' + name + '!'); }
</script>
}
<button data-name={ name }>{ label }</button>
}Warning: Don't write
@templ.NewOnceHandle().Once()— this creates a new handle each time, defeating the purpose. Always declare the handle as a package-levelvar.
@templ.JSONScript("page-config", config)Output: <script id="page-config" type="application/json">{"theme":"dark"}</script>
Client-side: const config = JSON.parse(document.getElementById('page-config').textContent);
[x] Every <script> has nonce={ props.Nonce }
[x] Zero inline event handlers (onclick, onload, etc.)
[x] Use document.addEventListener with event delegation
[x] Use templ.WithNonce() middleware to inject nonce via context
[x] External scripts use layout.Script(nonce, src, attrs)
[ ] CSP header includes: script-src 'nonce-<random>'
[ ] If using Alpine.js hx-on:click: add 'unsafe-hashes' or 'unsafe-inline'
CSP gotcha: Even with nonces, inline
onclick/hx-on:clickattributes require'unsafe-hashes'in your CSP. This is a browser limitation, not a templ issue. Avoid inline handlers entirely if your CSP is strict — use event delegation (Pattern 1) oraddEventListenervia IIFE scripts.
func withNonce(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce := securelyGenerateRandomString()
w.Header().Set("Content-Security-Policy",
fmt.Sprintf("script-src 'nonce-%s'", nonce))
ctx := templ.WithNonce(r.Context(), nonce)
next.ServeHTTP(w, r.WithContext(ctx))
})
}project/
├── assets/js/ # esbuild output (served by Go's http.FileServer)
├── src/ts/ # TypeScript source
│ └── main.ts
├── *.templ # templ templates
└── go.mod
esbuild --bundle --minify --outfile=assets/js/app.js src/ts/main.ts@layout.Script(nonce, "/assets/js/app.js", templ.Attributes{"defer": true})@templ.JSONScript("page-data", pageConfig)const config = JSON.parse(document.getElementById("page-data").textContent);- WGO — stable Go dev server with file watching
- Templiér — hot-reload for templ dev mode
- esbuild's
--watchflag for TS rebuilds
HTMX 1.9.0+ supports native browser View Transitions — smooth page transitions with zero JS:
<div hx-get="/page" hx-swap="innerHTML transition:true" hx-target="main">
Load Page
</div>::view-transition-old(root) {
animation: 180ms both fade-out;
}
::view-transition-new(root) {
animation: 420ms both fade-in;
}For multi-page apps (MPA), Chrome supports cross-document view transitions natively — no JS at all. This eliminates the "ka-chunk" of full-page reloads.
Mozilla considers inline event handlers (onclick, onload) bad practice. Templ
enables a clean alternative using IIFE scripts with document.currentScript:
templ hello(label, name string) {
<div>
<input type="button" value={ label } data-name={ name }/>
<script nonce={ nonce }>
(() => {
let parent = document.currentScript.closest('div');
let btn = parent.querySelector('input[data-name]');
btn.addEventListener('click', function() {
let name = btn.getAttribute('data-name');
alert('Hello, ' + name + '!');
});
})()
</script>
</div>
}The key technique: document.currentScript lets the inline script find its own
position in the DOM, then traverse to sibling elements. This avoids needing unique
IDs and works after HTMX swaps.
| Anti-pattern | Why | Fix |
|---|---|---|
Inline onclick / onload handlers |
Breaks strict CSP, Mozilla bad practice | Use addEventListener or event delegation |
@templ.NewOnceHandle().Once() inline |
Creates new handle each render | Declare as package-level var |
templ.JSExpression with user data |
Bypasses escaping, XSS risk | Use templ.JSONString or templ.JSFuncCall |
Per-element addEventListener without delegation |
Lost on HTMX DOM swap | Use document.addEventListener + closest() |
Missing nonce on <script> |
Blocked by CSP | Use layout.Script(nonce, src, attrs) |
x-text={ goString } without quotes (Alpine.js) |
Alpine interprets as JS variable, not string | Use templ.JSONString(goString) |
| Project type | Recommended stack | Why |
|---|---|---|
| Content site / blog | HTMX only | Server renders everything, minimal interactivity |
| Admin dashboard | HTMX + singleton-guard JS | Type-safe, CSP-safe, zero deps (this repo's pattern) |
| SaaS app (forms heavy) | HTMX + Alpine.js | Client-side form state, validation, toggles |
| Real-time app (chat, live data) | Datastar | SSE streaming, signal patching, built-in reactivity |
| App with complex widgets | HTMX + React islands | Server-render most pages, islands for widgets |
- templ official docs: JavaScript
- templ official docs: CSP
- templ official docs: HTMX
- templ official docs: Datastar
- templ official docs: React islands
- ADR 0005: JavaScript Attachment Patterns
- GitHub Issue #220: CSP safe templ
- templ-csp-example
- templUI (templ + Alpine.js component library)
- DatastarUI (Go/templ port of shadcn/ui)
- datastar-templ (type-safe Datastar helpers)
- Go Templ + Alpine.js + HTMX guide
- HTMX View Transitions essay
- surreal (JS helper library)