Pre-1.0 — feature-complete for current use cases. APIs are stabilizing but may still change before v1.0.
godom is a framework for building local apps in Go that use the browser as the UI layer. It is not a web framework — there are no API endpoints, no frontend/backend split, no JavaScript to author for typical use. You build a Go struct, bind HTML to it with directives, and go build gives you a single binary. Run it, and the UI appears in your browser.
The browser is the rendering engine. All state and logic live in your Go process. The JS bridge is a thin command executor that the framework injects. For most apps, you never touch JS — but when you need to integrate a JS library (charts, maps, editors), the plugin system lets you bridge Go data to any JS library.
godom also works as a local network service: run the binary on a headless machine and access the UI from any browser on the network. See docs/why.md for the full rationale and how godom differs from Electron, Tauri, and Wails.
| Solar System | System Monitor (Chart.js) |
|---|---|
![]() |
![]() |
| 3D engine in Go, Canvas 2D rendering | Live charts with Chart.js plugin |
| Terminal | Terminal + Claude Code |
|---|---|
![]() |
![]() |
| Full PTY shell via xterm.js | Claude Code running in the browser terminal |
package main
import (
"embed"
"log"
"github.com/anupshinde/godom"
)
//go:embed ui
var ui embed.FS
type App struct {
godom.Island
Count int
Step int
}
func (a *App) Increment() {
a.Count += a.Step
}
func (a *App) Decrement() {
a.Count -= a.Step
}
func main() {
app := &App{Step: 1}
app.Template = "ui/index.html"
eng := godom.NewEngine()
eng.SetFS(ui)
log.Fatal(eng.QuickServe(app))
}<!-- ui/index.html -->
<h1><span g-text="Count">0</span></h1>
<button g-click="Decrement">−</button>
<button g-click="Increment">+</button>
<div>
Step size: <input type="number" min="1" max="100" g-bind="Step"/>
</div>Run go build and you get a single binary that opens the browser and shows a live counter. The HTML, CSS, and JS bridge are all embedded into the binary via Go's embed package — there are no external files to ship or manage.
- Your Go struct holds all application state
- HTML templates use
g-*directives to bind to struct fields and methods - A virtual DOM in Go diffs state changes and sends minimal patches via binary WebSocket (Protocol Buffers) — no page reloads
- State lives in the Go process and survives browser close/reopen — close the tab, reopen it, and you're back where you left off
- Open the same app in multiple browser tabs and they stay in sync — type in one, see the update in the other. This falls out naturally from the architecture: Go owns the state and pushes DOM patches to every connected tab
- All directives are validated at startup — typos in field/method names cause
log.Fatal, not silent runtime bugs
Use in your project:
go get github.com/anupshinde/godom
Run the examples:
git clone https://github.com/anupshinde/godom.git
cd godom
go run ./examples/counter
Requires Go 1.25+ and a web browser.
| Directive | Example | Description |
|---|---|---|
g-text |
g-text="Name" |
Set element's text content from a field |
g-bind |
g-bind="InputText" |
Two-way bind an input's value to a field |
g-value |
g-value="Name" |
One-way bind an input's value (no sync back to Go) |
g-checked |
g-checked="todo.Done" |
Bind checkbox checked state |
g-show |
g-show="IsVisible" |
Toggle display: none when falsy |
g-hide |
g-hide="IsHidden" |
Toggle display: none when truthy |
g-if |
g-if="HasItems" |
Exclude element from tree when falsy |
g-class:name |
g-class:done="todo.Done" |
Add/remove a CSS class conditionally |
g-attr:name |
g-attr:transform="Rotation" |
Set any HTML/SVG attribute from a field |
g-style:prop |
g-style:width="BarWidth" |
Set an inline CSS property from a field |
g-plugin:name |
g-plugin:chartjs="MyChart" |
Send field data to a registered JS plugin |
g-shadow |
g-shadow |
Render island inside a Shadow DOM for CSS isolation |
| Directive | Example | Description |
|---|---|---|
g-click |
g-click="Save" |
Call a method on click |
g-click |
g-click="Remove(i)" |
Call with arguments resolved from context |
g-keydown |
g-keydown="Submit" |
Call method on every key press |
g-keydown |
g-keydown="Enter:Submit" |
Call method on specific key press |
g-keydown |
g-keydown="ArrowUp:Up;ArrowDown:Down" |
Multiple key bindings (semicolon-separated) |
g-mousedown |
g-mousedown="OnDown" |
Mouse button pressed — method receives (x, y float64) |
g-mousemove |
g-mousemove="OnMove" |
Mouse moved — throttled to animation frame, receives (x, y float64) |
g-mouseup |
g-mouseup="OnUp" |
Mouse button released — receives (x, y float64) |
g-wheel |
g-wheel="OnScroll" |
Scroll wheel — receives (deltaY float64) |
| Directive | Example | Description |
|---|---|---|
g-draggable |
g-draggable="i" |
Make element draggable, with the given value as drag data |
g-draggable:group |
g-draggable:palette="'red'" |
Draggable with a named group — only matching g-drop:group/g-dropzone:group handlers accept the drop |
g-drop |
g-drop="Reorder" |
Call method on drop — receives (from, to) |
g-drop:group |
g-drop:palette="Add" |
Drop handler filtered by group — only fires for matching g-draggable:group |
g-dropzone |
g-dropzone="HandleDrop" |
Synonym for g-drop — registers a drop handler on a "zone" element (often without its own g-draggable, in which case to arrives as "null") |
Groups isolate drag interactions. A g-draggable:palette element can only be dropped on a g-drop:palette handler. Without a group, all draggables and drop handlers interact freely.
Drop data is passed as method arguments: from (the draggable's value) and to (the drop target's g-draggable value, or "null" if it has none). Any extra args declared in the directive (g-drop="Reorder(item)") are appended after them. String and numeric values are preserved automatically.
CSS classes are applied automatically during drag operations:
.g-dragging— on the element being dragged.g-drag-over— on a drop zone when a compatible draggable hovers over it
See docs/drag-drop.md for the full design rationale — why this split between bridge and Go, how groups are filtered, and alternatives considered.
<li g-for="todo, i in Todos">
<span g-text="todo.Text"></span>
<input type="checkbox" g-checked="todo.Done" g-click="Toggle(i)" />
<button g-click="Remove(i)">×</button>
</li>g-for="item, index in ListField" repeats the element for each item in a slice field. The index variable is optional (g-for="item in Items" works too).
List rendering uses VDOM diffing — only changed items get DOM updates, new items are appended, removed items are truncated.
Add g-key to give list items a stable identity for efficient reordering:
<li g-for="todo, i in Todos" g-key="todo.ID">
<span g-text="todo.Text"></span>
</li>Without g-key, children are matched positionally. With it, the differ detects inserts, deletes, and moves, producing minimal DOM operations instead of redrawing.
g-for loops can be nested. Inner loops iterate over fields of the outer item:
<div g-for="field, i in Fields">
<label g-text="field.Label"></label>
<select g-show="field.IsSelect" style="display:none">
<option g-for="opt in field.Options" g-text="opt"></option>
</select>
</div>The inner g-for resolves field.Options from the outer loop variable. This works to arbitrary nesting depth. See docs/nested-for.md for the design details.
Directives support:
- Field access:
FieldName - Dotted paths:
todo.Text,item.Address.City - Loop variables:
todo,ifromg-for - Literals:
true,false, integers, quoted strings - Text interpolation:
{{Name}}in HTML text content (e.g.,<p>Hello, {{Name}}!</p>)
All expressions are resolved in Go (the browser-side bridge is a pure command executor).
godom has two composition tiers: partials (stateless HTML includes) and islands (stateful, goroutine-backed runtime units). Reach for a partial when you just want shared HTML; reach for an island when you want isolated state and behavior. See docs/why-islands.md for the full explanation.
Split HTML into reusable files. Any HTML file in your embedded filesystem can be used as a custom element:
<!-- ui/todo-item.html -->
<li g-class:done="todo.Done">
<input type="checkbox" g-checked="todo.Done" g-click="Toggle(index)" />
<span g-text="todo.Text"></span>
<button g-click="Remove(index)">×</button>
</li><!-- ui/index.html -->
<ul>
<todo-item g-for="todo, i in Todos"></todo-item>
</ul>Partials are expanded inline at registration time — directives resolve against the enclosing island's state. Loop variables (todo, i) are available inside the included template. Partials carry no state, no goroutine, no lifecycle.
Partials can also be registered by name, independent of any filesystem — useful for reusable snippets shared across islands.
// Register a raw HTML string as a shared partial:
eng.RegisterPartial("my-badge", `<span class="badge"><g-slot/></span>`)
// Or bulk-register every *.html file from a directory:
//go:embed partials
var partialsFS embed.FS
eng.UsePartials(partialsFS, "partials") // partials/my-badge.html → <my-badge>Partial lookup order for <my-tag>:
- Island's own FS at the entry template's directory (sibling file).
- Engine's partial registry (populated via
RegisterPartial/UsePartials). - If neither has it, startup errors with every location searched.
A partial can contain a <g-slot/> marker. Whatever sits between a consumer's custom-element tags replaces the slot:
<!-- info-note.html -->
<div class="callout">
<svg>...icon...</svg>
<div><g-slot/></div>
</div><info-note>
<p>This content lands inside the callout.</p>
</info-note>Partials without a <g-slot/> discard any inner content (the default).
For apps with multiple independent pieces of state, register separate islands. Each gets its own Go struct, HTML template, VDOM tree, goroutine, and refresh cycle. The parent declares insertion points with g-island, and child islands render into them.
eng.SetFS(ui)
// Child islands — set TargetName and Template, then register
counter := &Counter{Step: 1}
counter.TargetName = "counter"
counter.Template = "ui/counter/index.html"
eng.Register(counter)
// Root island owns the page layout (QuickServe auto-sets TargetName to "document.body")
layout := &Layout{}
layout.Template = "ui/layout/index.html"
log.Fatal(eng.QuickServe(layout))The parent template declares targets with the g-island attribute:
<!-- ui/layout/index.html -->
<body>
<h1>My App</h1>
<div class="sidebar" g-island="sidebar"></div>
<div class="main" g-island="counter"></div>
</body>Child templates are HTML fragments (no <html>/<head>/<body>) — they render into the parent's target element:
<!-- ui/counter/index.html -->
<div>
<span g-text="Count">0</span>
<button g-click="Increment">+</button>
</div>Register is variadic, so you can register all children in one call:
eng.Register(navbar, toast, sidebar, counter, clock, monitor, ticker, tips)Cross-island communication uses Go callbacks:
sidebar.OnNavigate = func(msg, kind string) { toast.Show(msg, kind) }For portable tool packages, each island can bring its own filesystem — Go code and HTML colocate in one folder:
// tools/counter/counter.go
//go:embed *.html
var fsys embed.FS
func New() *Counter {
return &Counter{Island: godom.Island{
TargetName: "counter",
Template: "counter.html", // path inside counter's own fs
AssetsFS: fsys,
}}
}Main no longer needs SetFS if every island brings its own. Local sibling partials still work — the lookup searches the island's own FS first.
AssetsFS is fs.FS, so os.DirFS(".") also works for dev-mode edits without rebuilding.
For tiny one-off islands, skip the filesystem entirely:
const tmpl = `<span class="font-mono" g-text="Time">--:--:--</span>`
func New() *DigiClock {
return &DigiClock{Island: godom.Island{
TargetName: "digiclock",
TemplateHTML: tmpl, // no Template, no AssetsFS
}}
}Inline-HTML islands can still use shared partials from the registry; they just don't have sibling-file lookup.
See examples/multi-island/ for a full 9-island demo.
eng := godom.NewEngine() // Create a new Engine
eng.Port = 8081 // Set port (0 = random)
eng.Host = "0.0.0.0" // Bind to all interfaces (default "localhost")
eng.NoAuth = true // Disable token auth (default false = auth enabled)
eng.FixedAuthToken = "my-secret" // Fixed token (default: random per startup)
eng.NoBrowser = true // Don't auto-open browser
eng.Quiet = true // Suppress startup output
eng.DisableExecJS = true // Disable ExecJS server-side
eng.Use(chartjs.Plugin, plotly.Plugin) // Register plugins (Chart.js, Plotly, ECharts, etc.)
eng.RegisterPlugin("myplugin", bridgeJS) // Register a custom plugin with one or more JS scripts
eng.SetFS(fsys) // Default UI filesystem; optional if every island has AssetsFS
eng.RegisterPartial("my-badge", html) // Register a shared partial by name (raw string)
eng.UsePartials(fsys, "partials") // Bulk-register partials from a directory (scans *.html)
eng.DisconnectHTML = "<div>Custom overlay</div>" // Custom disconnect overlay (root mode)
eng.DisconnectBadgeHTML = "<span>Offline</span>" // Custom disconnect badge (embedded mode)
child.TargetName = "name" // Matches g-island="name" in parent template
child.Template = "ui/child.html" // Template path (resolved against AssetsFS or engine SetFS)
child.AssetsFS = childFS // Optional per-island filesystem (overrides SetFS for this island)
// or:
child.TemplateHTML = "<div>...</div>" // Inline HTML — no filesystem at all
eng.Register(child) // Register one or more islands (variadic)
app.Template = "ui/index.html"
log.Fatal(eng.QuickServe(app)) // Auto-sets TargetName to "document.body", registers, serves, blocksFor developer-owned servers, wire godom into your mux and lifecycle explicitly:
mux := http.NewServeMux()
mux.HandleFunc("/", servePage)
eng.SetMux(mux, &godom.MuxOptions{
WSPath: "/app/ws",
ScriptPath: "/assets/godom.js",
})
eng.SetAuth(myAuthFunc) // optional
if err := eng.Run(); err != nil {
log.Fatal(err)
}
log.Fatal(eng.ListenAndServe())Run() validates templates, registers godom handlers on the mux from SetMux(), and starts island event processors. ListenAndServe() binds the configured host/port, wraps the mux with auth middleware, opens the browser unless disabled, and blocks serving requests. If you manage shutdown yourself, call Cleanup() before exit to stop island goroutines cleanly.
Settings can also be set via environment variables before NewEngine() runs:
GODOM_PORT=8081 GODOM_HOST=0.0.0.0 GODOM_DEBUG=true ./myapp
Boolean env vars (GODOM_DEBUG, GODOM_NO_AUTH, GODOM_NO_BROWSER, GODOM_QUIET) accept any value recognized by Go's strconv.ParseBool: 1, t, true, TRUE, 0, f, false, FALSE, etc.
NewEngine() reads env vars into the engine's initial state; any field you set in code after NewEngine() overrides the env-derived value. GODOM_DEBUG is server-side only: it enables debug logging and bridge warnings, but it is not an Engine field. godom does not parse CLI flags, so your binary owns its flags entirely.
For external hosting (embedding godom islands in pages not served by godom), set browser-side variables before loading the bundle:
<script>
window.GODOM_WS_URL = "ws://localhost:9091/ws"; // Connect to godom on a different origin
window.GODOM_NS = "myApp"; // Rename window.godom to window.myApp
</script>
<script src="http://localhost:9091/godom.js"></script>See docs/configuration.md for the full reference on settings, environment variables, authentication, and precedence rules.
Embed godom.Island in your struct. TargetName matches g-island="name" attributes in parent templates. The island's HTML comes from one of three sources:
| Field | Use when |
|---|---|
Template + engine's SetFS(fs) |
Flat single-FS apps. Template is resolved against the engine-wide FS. |
Template + AssetsFS (per-island) |
Tool packages that ship HTML colocated with Go code. Each island brings its own //go:embed. Local sibling partials resolve from this FS automatically. |
TemplateHTML (inline) |
Tiny islands with no partials — the template is a plain Go string literal. |
AssetsFS is fs.FS — so embed.FS, os.DirFS (runtime edits), fstest.MapFS, or any custom filesystem all work.
type MyApp struct {
godom.Island // TargetName, Template, TemplateHTML, AssetsFS all live here
Name string // exported fields = state
Items []Item // slices work with g-for
}
func (a *MyApp) DoSomething() {
// exported methods = event handlers
// mutate fields directly, framework handles sync
}Push state to all connected browsers from a background goroutine:
func (a *App) monitor() {
for {
time.Sleep(1 * time.Second)
a.Value = readSensor()
a.Refresh() // broadcast to all browsers
}
}Call Refresh() after mutating fields outside of user-triggered events (clicks, input). This is how you build dashboards, monitors, and live-updating UIs.
For large UIs where only a few fields changed, mark specific fields for surgical refresh:
func (a *App) UpdatePrice(i int) {
a.Stocks[i].Price = fetchPrice()
a.MarkRefresh("Stocks") // only rebuild nodes bound to Stocks
a.Refresh()
}This avoids a full tree diff — only the nodes bound to the marked fields are rebuilt and patched.
Run a JavaScript expression in each connected browser and receive each result asynchronously:
a.ExecJS("location.pathname", func(result []byte, err string) {
if err != "" {
log.Println("exec error:", err)
return
}
log.Printf("browser returned: %s", result)
})This is mainly for browser-only capabilities or one-off integrations. It can be disabled on the server with eng.DisableExecJS = true and on the page with window.GODOM_DISABLE_EXEC = true.
Integrate JS libraries (charts, maps, editors) without authoring JS yourself. A plugin is a thin JS adapter that receives Go data via the g-plugin:name directive:
<canvas g-plugin:chartjs="MyChart"></canvas>Shipped plugins are registered with eng.Use():
eng.Use(chartjs.Plugin) // Chart.js — line, bar, pie, doughnut
eng.Use(plotly.Plugin) // Plotly — scatter, bar, heatmaps, dual-axis
eng.Use(echarts.Plugin) // ECharts — line, bar, pie, candlestick, geoFor custom/one-off integrations, use the lower-level eng.RegisterPlugin(name, scripts...) to register JS adapters directly. The plugin JS calls godom.register(name, {init, update}) to handle data from Go. Scripts are injected in order — typically the library first, then the bridge.
See docs/plugins.md for the full list and docs/javascript-libraries.md for a guide on using any JS library — with or without a plugin package.
- examples/counter/ — minimal example (the one shown above)
- examples/progress-bar/ — animated progress bar with
Refresh()andg-style:widthfrom a goroutine - examples/clock/ — analog clock with
Refresh()andg-attr(server-pushed updates) - examples/todolist/ — template includes with prop passing
- examples/sync-demo/ — multi-tab state sync demonstration
- examples/system-monitor/ — live system monitor dashboard with
Refresh(),g-attr, and template includes - examples/system-monitor-chartjs/ — system monitor with Chart.js plugin (CPU, memory, disk, swap, load charts)
- examples/charts-without-plugin/ — ApexCharts with inline bridge adapter (no plugin package)
- examples/drag-tiles/ — 24 colored tiles with drag-to-reorder and a periodic shine animation sweep
- examples/drag-demo/ — drag-and-drop demo with groups, dropzones, and string data (palette → canvas → trash)
- examples/basic-form-builder/ — drag-and-drop form builder with palette, canvas, config panel, preview mode, and JSON export (uses drag groups, nested g-for, conditional rendering)
- examples/stock-ticker/ — live stock ticker dashboard with 30 simulated stocks, per-stock tick intervals, table with color-coded gainers/losers, and external CSS via static file serving
- examples/solar-system/ — 3D solar system with a Go-built 3D engine and Canvas 2D rendering (mouse drag, scroll zoom, follow planets)
- examples/terminal/ — browser-based terminal with full shell access via PTY and xterm.js (session respawn, resize, multi-tab, Tailscale-friendly)
- examples/multi-page-v2/ — reference for Phase B features — tool packages with
//go:embed+AssetsFS, inlineTemplateHTML, shared partials viaUsePartials/RegisterPartial,<g-slot/>children substitution,os.DirFSdev mode, multi-page routing with Go html/template chrome - examples/multi-island/ — 9-island dashboard with
g-islandcomposition, cross-island callbacks, Chart.js plugin, drag-and-drop reorder, goroutine-driven updates - examples/embedded-widget/ — godom islands embedded in an external HTML page (separate static server,
GODOM_WS_URL,/godom.jsscript tag,g-islandtargets,g-shadowfor CSS isolation) - examples/same-island-repeated/ — same island type rendered into multiple
g-islandtargets simultaneously - examples/video-player/ — video player with Go decoding frames via ffmpeg and rendering on canvas
- examples/breakout-game/ — breakout game with Go-side physics, canvas rendering, keyboard input, and collision detection
- examples/chart-plugins/ — Plotly and ECharts plugins side by side with live-updating charts
- examples/crash-test/ — intentionally crashes after startup to exercise the disconnect UI
- examples/markdown-editor/ — two-pane markdown editor with live preview and plain JS scroll sync
- examples/multi-page/ — multi-page app with developer-owned mux and routing between pages
- examples/select-test/ — focused repro app for select/input sync behavior
- examples/shared-state/ — shared state between islands via embedded struct pointers
- examples/dynamic-mount/ — dynamic island mounting and unmounting via
godom.mount() - examples/exec-and-call/ — ExecJS (Go→browser) and
godom.call()(browser→Go) demo - examples/ws-lifecycle/ — WebSocket lifecycle hooks (
onconnect,ondisconnect,onerror)
After cloning the repo (see Install), run any example with:
go run ./examples/counter
The system-monitor, system-monitor-chartjs, and terminal examples have their own go.mod (for platform-specific or extra dependencies), so run them from their directory:
cd examples/system-monitor && go run .
cd examples/system-monitor-chartjs && go run .
cd examples/terminal && go run .
This starts the server and opens your browser. To build a standalone binary instead:
go build -o counter ./examples/counter
./counter
godom includes a Chrome extension that injects godom.js into any website, letting your Go app enhance pages you don't control. Configure URL patterns to decide which pages get injection, and a sidebar panel renders your godom island alongside the host page.
- Configurable include/exclude URL patterns per rule with enable/disable toggles
- Resizable sidebar panel with CSS isolation (
g-shadow), maximize/restore, and page-split layout - Sidebar state persists across page navigations within the same site
- Works with named islands — the sidebar renders a
g-islandof your choice (default:extension) - Root mode (
document.body) is blocked by default to prevent replacing the host page - Hide badge per rule for screen recordings and demos
- Export/import rules as JSON for sharing across machines
- Cross-machine support via HTTPS reverse proxy (e.g. Caddy)
See browser_extension/README.md for installation and configuration.
- Minimal JavaScript — the JS bridge is injected automatically. For most apps, you write zero JS. When you need a JS library (charts, maps, editors), the plugin system bridges Go data to it with a thin adapter. For purely browser-side micro-interactions (scroll sync, focus, animations), a plain
<script>tag in your template works — see docs/javascript-libraries.md - Thin bridge — the JS bridge builds the DOM from a tree description on init and applies minimal patches on updates. It does not evaluate expressions, resolve data, diff state, or make decisions. Go builds a virtual DOM tree, diffs it, and sends patches as binary Protocol Buffers over WebSocket. This means all logic is testable in Go, the bridge stays in sync with framework semantics, and debugging stays in one language. Plugins extend the bridge to delegate rendering to JS libraries when needed.
g-bindfires on every keystroke with no debounce, keeping two-way binding instant (see docs/transport.md for why this matters) - State in Go — the browser is a rendering engine, not the source of truth
- Fail fast — all directives validated at startup against your struct
- Single binary —
go buildproduces one executable, no node_modules - Local apps — designed for local use and trusted networks, not the public internet. Token-based auth is on by default to prevent other local users from accessing your app. No HTTPS, no deployment ceremony. Also runs as a service on headless machines (why?)
This project was coded with the help of Claude (Anthropic). The architecture, design decisions, and all code were produced through human-AI collaboration using Claude Code.
The documentation including this README is also maintained by AI.
See docs/AI_USAGE.md for the full philosophy on how AI was used, what has and hasn't been reviewed, and what that means if you use this project.
- AI Agent Reference — complete API reference for AI agents and LLMs building godom apps
- Getting Started Guide — build your first godom app step by step
- docs/why.md — project rationale, comparison with Electron/Tauri/Wails
- docs/architecture.md — system design, VDOM pipeline, wire protocol
- docs/configuration.md — settings, environment variables, authentication
- docs/plugins.md — plugin system overview
- docs/javascript-libraries.md — using JS libraries with godom
- docs/drag-drop.md — drag and drop design and implementation
- docs/why-islands.md — why godom calls its stateful units "islands", not "components"
- docs/nested-islands.md — nested island composition in embedded mode
- docs/planning/next.md — prioritized roadmap
MIT — see LICENSE.



