Go-App WebAssembly Frontend Framework

Frontend Engineering advanced Building with AI ~25 min read

Overview

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.

Core Concepts

Component Interface

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.

Declarative HTML in Go

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.

Lifecycle Hooks

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.

WebAssembly Binary Lifecycle

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.

JS Interop via syscall/js

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.

How It Works

Project Structure

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))
}

Defining a Component

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 Handling and State Updates

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
}

Building and Deploying

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

Practical Applications

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.

Implementation Patterns

Shared Types Between Server and Client

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"`
}

Context-Based State Sharing

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"),
        ),
    )
}

Conditional Rendering and Lists

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]},
                    )
                }),
            ),
        ),
    )
}

Common Pitfalls

Further Reading