Go-app is a framework for building progressive web applications (PWAs) entirely in Go, compiled to WebAssembly. It allows backend Go developers to write single-page applications using the same language they use for servers, APIs, and CLI tools—without learning JavaScript or TypeScript. The framework provides a component model with lifecycle hooks, declarative HTML rendering via Go function calls, event handling, routing, and state management, all running in the browser via a Wasm binary.
The fundamental architecture compiles Go code to a .wasm binary that the browser downloads and executes. A thin Go HTTP server serves the initial HTML shell, the Wasm binary, and a JavaScript glue file (wasm_exec.js from the Go toolchain) that bootstraps the WebAssembly module. Once loaded, the Go runtime executes in the browser with access to DOM manipulation through go-app’s virtual DOM diffing engine. Components render by returning a tree of HTML nodes built with Go functions like app.Div(), app.H1(), and app.Text().
This approach trades initial load time (the Wasm binary can be several megabytes) for the ability to share types, validation logic, and business rules between server and client. For internal tools, dashboards, and applications where Go expertise already exists on the team, go-app eliminates the context-switching cost of maintaining a separate JavaScript frontend. The search platform uses this pattern to build interactive visualization interfaces for embedding similarity exploration.
Every go-app component implements the app.Composer interface, which requires a Render() method that returns an app.UI tree. Components are Go structs whose exported fields become state. The framework calls Render() whenever state changes, diffs the result against the current DOM, and patches only the changed elements. This is conceptually identical to React’s component model but expressed in Go syntax.
Instead of templates or JSX, go-app uses a fluent Go API to construct HTML. app.Div().Class("container").Body(app.H1().Text("Hello")) produces <div class="container"><h1>Hello</h1></div>. Every HTML element has a corresponding Go function, and attributes are set via method chaining. This approach provides compile-time type checking for the UI layer—misspelled attributes become compile errors rather than silent bugs.
Components can implement optional interfaces for lifecycle events: OnMount (component added to DOM, used for initialization and data fetching), OnNav (URL changed while component is mounted), OnDismount (component removed, used for cleanup like canceling timers), and OnUpdate (component re-rendered after state change). These hooks map directly to React’s useEffect patterns but are expressed as interface methods on the component struct.
The Wasm binary contains the entire Go runtime including garbage collector, goroutine scheduler, and all imported packages. The browser downloads this binary (typically 5–15 MB gzipped depending on dependencies), instantiates it with wasm_exec.js, and hands control to the Go main() function which calls app.RunWhenOnBrowser(). From that point, Go code manages the DOM. The server only handles the initial page load and API requests.
Go’s syscall/js package provides direct access to JavaScript objects and functions from Go code. Go-app wraps this for common operations (DOM manipulation, event handling, local storage), but developers can call arbitrary JavaScript APIs when needed—for example, to use browser APIs like navigator.clipboard, WebSocket, or third-party JS libraries that have no Go equivalent.
A go-app project typically has a main.go that serves double duty: when compiled normally (GOARCH=amd64), it runs the HTTP server; when compiled to Wasm (GOARCH=wasm GOOS=js), it runs the client application. Build tags or runtime checks distinguish the two paths.
// main.go
package main
import (
"log"
"net/http"
"github.com/maxence-charriere/go-app/v10/pkg/app"
)
func main() {
app.Route("/", func() app.Composer { return &HomePage{} })
app.Route("/search", func() app.Composer { return &SearchPage{} })
app.Route("/viz", func() app.Composer { return &VizPage{} })
// When running in the browser (Wasm), start the client
app.RunWhenOnBrowser()
// When running on the server, serve the app
http.Handle("/", &app.Handler{
Name: "Embedding Explorer",
Description: "Interactive embedding visualization",
Styles: []string{
"/web/app.css",
},
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Components are Go structs with a Render() method. State is managed through exported struct fields. Calling c.Update() triggers a re-render.
type SearchPage struct {
app.Compo // Embed base component
query string
results []SearchResult
loading bool
}
func (c *SearchPage) Render() app.UI {
return app.Div().Class("search-page").Body(
app.H1().Text("Embedding Search"),
app.Input().
Type("text").
Placeholder("Enter search query...").
Value(c.query).
OnInput(c.onQueryChange),
app.Button().
Text("Search").
OnClick(c.onSearch).
Disabled(c.loading),
app.If(c.loading,
app.Div().Class("spinner").Text("Searching..."),
).Else(
app.Range(c.results).Slice(func(i int) app.UI {
r := c.results[i]
return app.Div().Class("result-card").Body(
app.H3().Text(r.Title),
app.P().Text(r.Snippet),
app.Span().Class("score").
Text(fmt.Sprintf("Score: %.4f", r.Score)),
)
}),
),
)
}
Event handlers are methods on the component struct. They modify state and call Update() to trigger re-rendering. Async operations use goroutines with app.Dispatch() to safely update state from background threads.
func (c *SearchPage) onQueryChange(ctx app.Context, e app.Event) {
c.query = ctx.JSSrc().Get("value").String()
}
func (c *SearchPage) onSearch(ctx app.Context, e app.Event) {
c.loading = true
c.Update()
ctx.Async(func() {
results, err := fetchSearchResults(c.query)
if err != nil {
app.Log("search error:", err)
return
}
ctx.Dispatch(func(ctx app.Context) {
c.results = results
c.loading = false
})
})
}
func fetchSearchResults(query string) ([]SearchResult, error) {
// Uses net/http compiled to Wasm - makes fetch() calls under the hood
resp, err := http.Get("/api/search?q=" + url.QueryEscape(query))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var results []SearchResult
err = json.NewDecoder(resp.Body).Decode(&results)
return results, err
}
The build process produces two artifacts: the Wasm binary for the browser and the server binary that serves it. Go-app’s app.Handler automatically serves the Wasm file, the JavaScript loader, and the HTML shell.
# Build the Wasm binary for the browser
GOARCH=wasm GOOS=js go build -o web/app.wasm ./cmd/client
# Build the server binary
go build -o server ./cmd/server
# Run the server (serves both the app and API endpoints)
./server
In the search platform, go-app powers an interactive interface for exploring embedding similarity. The same Go types that define embedding vectors on the server are shared with the client, eliminating serialization mismatches. Users can input text queries, see nearest-neighbor results with similarity scores, and navigate between related embeddings—all rendered by Go code running in the browser.
Go-app is particularly effective for internal developer tools and dashboards where the team is Go-centric. The ability to import business logic packages directly into the frontend means validation rules, data transformation functions, and domain models don’t need to be reimplemented in JavaScript. This is valuable for tools that display complex computed data (like embedding projections or search relevance metrics) where the computation logic already exists in Go.
PWA features (offline support, installability, push notifications) come built into go-app through its app.Handler configuration. The framework generates a service worker that caches the Wasm binary and static assets, enabling offline access after the first load. For field tools or intermittently-connected environments, this eliminates the need for a separate mobile application.
Place shared types in a package that both server and Wasm client import. This ensures API responses deserialize into identical structs on both sides.
// pkg/models/embedding.go — imported by both server and client
package models
type EmbeddingResult struct {
ID string `json:"id"`
Text string `json:"text"`
Vector []float64 `json:"vector,omitempty"`
Score float64 `json:"score"`
Metadata Metadata `json:"metadata"`
}
type Metadata struct {
Source string `json:"source"`
Category string `json:"category"`
Created string `json:"created"`
}
Go-app provides a context-based state system for sharing data across components without prop drilling. State is set on the context and observed by any component that needs it.
// Set global state from any component
func (c *AuthPage) onLoginSuccess(ctx app.Context, user User) {
ctx.SetState("currentUser", user)
ctx.Navigate("/dashboard")
}
// Observe state in another component
func (c *NavBar) OnMount(ctx app.Context) {
ctx.ObserveState("currentUser").Value(&c.user)
}
func (c *NavBar) Render() app.UI {
return app.Nav().Body(
app.If(c.user.Name != "",
app.Span().Text("Welcome, "+c.user.Name),
).Else(
app.A().Href("/login").Text("Sign In"),
),
)
}
Go-app uses app.If()/app.Else() for conditional rendering and app.Range() for list rendering, mirroring patterns familiar from React but with Go’s type system.
func (c *ResultsList) Render() app.UI {
return app.Div().Body(
app.If(len(c.items) == 0,
app.P().Class("empty").Text("No results found"),
).ElseIf(c.error != nil,
app.P().Class("error").Text(c.error.Error()),
).Else(
app.Ul().Body(
app.Range(c.items).Slice(func(i int) app.UI {
return app.Li().Body(
&ResultCard{Item: c.items[i]},
)
}),
),
),
)
}
-ldflags="-s -w" to strip debug symbols, and avoid importing heavy packages (like net/http when syscall/js fetch wrappers suffice). Consider TinyGo for smaller binaries, though it has compatibility limitations.go func() run on the Go scheduler but cannot safely call DOM APIs. Always use ctx.Dispatch() to marshal state updates back to the main rendering loop; directly modifying component fields from a goroutine causes race conditions.syscall/js cannot be tested with normal go test because the JS runtime is not available outside the browser. Separate browser-dependent code into thin adapters and test business logic independently with standard Go tests.app.Handler. Enable HTTP compression (gzip/brotli) on the server—Wasm binaries compress exceptionally well (often 70-80% reduction).ctx.ObserveState() are tied to the component lifecycle. If a component sets up observers in OnMount but is never dismounted (e.g., due to routing issues), the observer keeps the component alive. Always verify that navigation properly dismounts old page components.app.Log() (which maps to console.log) liberally during development. Source maps for Go Wasm are experimental; most debugging relies on log output and the Go runtime’s panic stack traces that appear in the browser console.