GitHub - anupshinde/godom: Build local apps in Go with the browser as the UI layer.

16 min read Original article ↗

Tests Go Report Card Go Reference

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.

Showcase

Solar System System Monitor (Chart.js)
Solar System System Monitor with Chart.js
3D engine in Go, Canvas 2D rendering Live charts with Chart.js plugin
Terminal Terminal + Claude Code
Terminal Claude Code in browser terminal
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.Component
    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.

How it works

  • 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

Install

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.

Directives reference

Data binding

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 component inside a Shadow DOM for CSS isolation

Events

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)

Drag and drop

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 dropzones accept the drop
g-dropzone g-dropzone="'canvas'" Mark element as a drop zone with a named value (used as to in drop handler)
g-drop g-drop="Reorder" Call method on drop — receives (from, to) or (from, to, position)
g-drop:group g-drop:palette="Add" Drop handler filtered by group — only fires for matching g-draggable:group

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), to (the dropzone's value or the target's drag data), and optionally position ("above" or "below" based on cursor position). 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
  • .g-drag-over-above / .g-drag-over-below — on sortable items indicating cursor position

See docs/drag-drop.md for the full design rationale — why this split between bridge and Go, why MIME types for groups, and alternatives considered.

Lists

<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)">&times;</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.

Keyed lists

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.

Nested lists

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.

Expressions

Directives support:

  • Field access: FieldName
  • Dotted paths: todo.Text, item.Address.City
  • Loop variables: todo, i from g-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).

Components

Template includes

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)">&times;</button>
</li>
<!-- ui/index.html -->
<ul>
    <todo-item g-for="todo, i in Todos"></todo-item>
</ul>

Custom elements are expanded inline at registration time — directives resolve against the parent component's state. Loop variables (todo, i) are available inside the included template.

Multiple components

For apps with multiple independent pieces of state, register separate components. Each gets its own Go struct, HTML template, VDOM tree, and refresh cycle. The parent declares insertion points with g-component, and children render into them.

eng.SetFS(ui)

// Child components — set TargetName and Template, then register
counter := &Counter{Step: 1}
counter.TargetName = "counter"
counter.Template = "ui/counter/index.html"
eng.Register(counter)

// Root component 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-component attribute:

<!-- ui/layout/index.html -->
<body>
    <h1>My App</h1>
    <div class="sidebar" g-component="sidebar"></div>
    <div class="main" g-component="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-component communication uses Go callbacks:

sidebar.OnNavigate = func(msg, kind string) { toast.Show(msg, kind) }

See examples/multi-component/ for a full 9-component demo.

API

Engine

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)                                   // Set the shared UI filesystem for templates
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-component="name" in parent template
child.Template = "ui/child.html"                  // Template path relative to SetFS filesystem
eng.Register(child)                               // Register one or more components (variadic)

app.Template = "ui/index.html"
log.Fatal(eng.QuickServe(app))                    // Auto-sets TargetName to "document.body", registers, serves, blocks

For 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 component 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 component 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 components 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.

Component

Embed godom.Component in your struct. The TargetName field matches g-component="name" attributes in parent templates. The Template field is the path to the HTML template relative to the filesystem set via SetFS.

type MyApp struct {
    godom.Component            // TargetName, Template fields come from 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
}

Refresh

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.

MarkRefresh (surgical updates)

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.

ExecJS

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.

Plugins

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, geo

For 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

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

Browser extension

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 component 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 components — the sidebar renders a g-component of 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.

Design principles

  • 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-bind fires 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 binarygo build produces 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?)

AI disclosure

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.

Documentation

License

MIT — see LICENSE.