frontend.md
Frontend Layer
Island architecture for interactive JavaScript components within HTMX-driven pages.
Design Principles
| Principle | Rationale |
|---|---|
| Progressive enhancement | Works without JavaScript, islands add interactivity |
| Framework agnostic | You control render/unmount logic in your entry point |
| Explicit over magic | Framework code stays in your codebase, not ours |
| HTMX integration | Components re-mount after HTMX swaps |
Quick Start
1. Add Frontend Option
import (
"github.com/readysite/readysite/pkg/application"
"github.com/readysite/readysite/pkg/frontend"
"github.com/readysite/readysite/pkg/frontend/esbuild"
)
func main() {
application.Serve(views,
frontend.WithBundler(&esbuild.Config{
Entry: "components/index.ts",
Include: []string{"components"},
}),
application.WithController(controllers.Home()),
)
}
2. Add Template Functions
<!-- views/layouts/main.html -->
<!DOCTYPE html>
<html>
<head>
{{frontend_script}}
</head>
<body>
{{block "content" .}}{{end}}
</body>
</html>
3. Create Components
components/Counter.tsx:
import { useState } from 'react';
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return (
<button className="btn btn-primary" onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
components/index.ts:
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
// Render function - mounts a component to an element
export function render(el: HTMLElement, Component: React.ComponentType<any>, props: any) {
const root = ReactDOM.createRoot(el);
root.render(React.createElement(Component, props));
(el as any)._root = root;
}
// Unmount function - unmounts a component from an element
export function unmount(el: HTMLElement) {
if ((el as any)._root) {
(el as any)._root.unmount();
delete (el as any)._root;
}
}
// Export components
export * from './Counter';
4. Install Dependencies
package.json:
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0"
}
}
No build scripts needed - the Go server bundles components automatically using esbuild.
5. Use in Templates
{{define "content"}}
<div class="container">
<h1>Interactive Counter</h1>
{{render "Counter" (dict "initial" 5)}}
</div>
{{end}}
How It Works
-
{{frontend_script}}outputs:__renderAll()function that finds all[data-component]elements- HTMX integration for swap handling
- Script tag loading your components bundle
- Call to
__renderAll()after load
-
Your entry point exports:
render(el, Component, props)- mount logicunmount(el)- unmount logic- Named component exports (e.g.,
Counter)
-
The bundler wraps your exports:
- Assigns
rendertowindow.__render - Assigns
unmounttowindow.__unmount - Assigns components to
window.ComponentName
- Assigns
-
On page load:
__renderAll()mounts all components -
On HTMX swap: Unmounts replaced components, renders new ones
Config Options
&esbuild.Config{
// Entry point file (required)
Entry: "components/index.ts",
// Directories to watch for HMR (optional)
Include: []string{"components", "hooks", "utils"},
}
Template Functions
| Function | Purpose |
|---|---|
{{frontend_script}} |
Injects orchestration + script tags |
{{render "Name" props}} |
Renders component island with props |
render
Creates an island container:
<div data-component="Counter" data-props='{"initial":5}'>
<div class="skeleton h-32 w-full"></div>
</div>
Props are JSON-serialized. The skeleton placeholder displays while loading.
Directory Structure
project/
├── components/
│ ├── index.ts # Entry point with render/unmount
│ ├── Counter.tsx
│ └── PostEditor.tsx
├── views/
│ └── static/
│ └── scripts/
│ └── gen/ # Built output (gitignore this)
│ └── components.js
├── package.json
└── main.go
Other Frameworks
Solid.js
// components/index.ts
import { render as solidRender } from 'solid-js/web';
import { Counter } from './Counter';
export function render(el: HTMLElement, Component: any, props: any) {
(el as any)._dispose = solidRender(() => Component(props), el);
}
export function unmount(el: HTMLElement) {
if ((el as any)._dispose) (el as any)._dispose();
}
export { Counter };
Svelte
// components/index.ts
import Counter from './Counter.svelte';
export function render(el: HTMLElement, Component: any, props: any) {
(el as any)._component = new Component({ target: el, props });
}
export function unmount(el: HTMLElement) {
if ((el as any)._component) (el as any)._component.$destroy();
}
export { Counter };
Development Mode
In development (ENV != production):
- File watching is enabled
- Changes trigger automatic rebuild
- HMR notifies browser to reload
Best Practices
When to Use Islands
| Use Case | Approach |
|---|---|
| Simple interactions | HTMX (no island needed) |
| Rich text editing | React island |
| Data visualization | React island |
| File trees, drag-drop | React island |
| Forms, buttons | HTMX |
| Search, filters | HTMX with debounce |
Component Guidelines
- Keep islands small - Most of your page should be server-rendered
- Props are serializable - Pass simple data, not functions
- Use HTMX for data fetching - Islands don't need to own data
Troubleshooting
Component not mounting
- Check
window.ComponentNameis defined in browser console - Check
window.__renderexists - Verify
{{frontend_script}}is in<head> - Check browser console for
[frontend] Component not foundwarnings
HTMX swap not re-mounting
- Verify
unmountcleans up properly - Check HTMX events are firing (htmx:beforeSwap, htmx:afterSwap)