Wails: Go Desktop Apps with Web UI
AI TOOLS Feb. 9, 2026, 11:30 a.m.

Wails: Go Desktop Apps with Web UI

Wails is a lightweight framework that lets you build native desktop applications using Go for the backend and any web technology—HTML, CSS, and JavaScript—for the frontend. It bridges the gap between the performance and safety of Go and the flexibility of modern web UI libraries like React, Vue, or Svelte. In this article we’ll explore the core concepts of Wails, walk through a couple of hands‑on examples, and discuss real‑world scenarios where Wails shines.

Unlike heavyweight Electron apps, Wails ships a minimal native webview and runs your Go code as a compiled binary, which keeps the final bundle small and the memory footprint low. This makes it an attractive choice for developers who already love Go and want to extend their skill set to desktop UI without learning a completely new language or framework.

Getting Started: Installation and Project Scaffold

The first step is to install the Wails CLI. It’s a single binary that works on Windows, macOS, and Linux.

go install github.com/wailsapp/wails/v2/cmd/wails@latest

After the CLI is on your $PATH, creating a new project is as simple as running wails init. The command walks you through a series of prompts to pick a frontend template (React, Vue, Svelte, or plain HTML) and sets up a Go module with the necessary dependencies.

wails init -n myapp -t react

Once the scaffold is ready, you can jump straight into development with wails dev. This launches a hot‑reloading development server that watches both Go and frontend files, giving you instant feedback as you code.

Project Structure Explained

A typical Wails project has three top‑level directories:

  • frontend/ – contains all web assets (HTML, CSS, JavaScript). This is where you integrate your favorite UI library.
  • backend/ – holds Go source files that expose methods to the frontend via the Wails runtime.
  • build/ – generated files for packaging the app for Windows, macOS, or Linux.

The magic happens in the backend package. Any exported Go method that follows the signature func (b *Bindings) MyMethod(ctx context.Context, args ...interface{}) (interface{}, error) becomes callable from JavaScript using wails.Runtime.Call. Wails handles JSON marshaling automatically, so you can pass complex structs without extra boilerplate.

Binding Go Functions to JavaScript

Let’s create a simple binding that returns the current system time. Add the following file to backend/timer.go:

package backend

import (
    "context"
    "time"
)

// Timer provides time‑related utilities to the frontend.
type Timer struct{}

// NewTimer returns a new instance of Timer.
func NewTimer() *Timer {
    return &Timer{}
}

// Now returns the current local time as a formatted string.
func (t *Timer) Now(_ context.Context) (string, error) {
    return time.Now().Format(time.RFC1123), nil
}

Now register the binding in backend/main.go:

package main

import (
    "github.com/wailsapp/wails/v2/pkg/runtime"
    "github.com/yourusername/myapp/backend"
)

func main() {
    app := wails.NewApp(&wails.AppConfig{
        Width:  1024,
        Height: 768,
        Title:  "Wails Clock Demo",
        JS:     runtime.Asset("frontend/dist/main.js"),
        CSS:    runtime.Asset("frontend/dist/main.css"),
        Colour: "#21252B",
    })

    // Register the Timer binding.
    timer := backend.NewTimer()
    app.Bind(timer)

    app.Run()
}

On the frontend side, you can now call window.backend.Now() (or the equivalent generated function if you’re using the Wails JavaScript SDK). The call returns a promise that resolves to the formatted time string.

Calling Go from JavaScript (React Example)

Assuming you chose the React template, edit frontend/src/App.jsx to fetch the time every second:

import React, { useEffect, useState } from 'react';

function App() {
  const [time, setTime] = useState('Loading...');

  useEffect(() => {
    const fetchTime = async () => {
      const now = await window.backend.Now();
      setTime(now);
    };
    fetchTime();
    const interval = setInterval(fetchTime, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div className="App">
      <h1>Current Time</h1>
      <p>{time}</p>
    </div>
  );
}

export default App;

Run wails dev and you’ll see a native window displaying a live clock, powered by Go on the backend and React on the frontend. This tiny example already demonstrates the core workflow: Go functions expose business logic, while the UI is built with familiar web tools.

Pro tip: Use wails runtime.EventsEmit to push updates from Go to the UI without polling. This reduces latency and conserves resources, especially for real‑time data streams.

Real‑World Use Cases

Wails isn’t just a playground; several production‑grade applications already rely on it. Below are three scenarios where Wails provides a clear advantage.

1. Internal Tools & Dashboards

Many companies need custom dashboards that aggregate data from internal APIs, databases, or hardware devices. With Wails you can write the data‑ingestion layer in Go—leveraging its concurrency model and static typing—while presenting the results with a polished React or Vue UI. The resulting binary can be distributed to non‑technical staff without requiring a separate runtime installation.

2. Cross‑Platform System Utilities

System utilities (e.g., disk cleaners, network monitors) often need low‑level OS access. Go’s standard library offers excellent cross‑platform support for file I/O, networking, and system calls. By wrapping these capabilities in Wails bindings, you can expose a clean, modern UI without sacrificing performance or portability.

3. Prototyping SaaS Frontends Locally

When building a SaaS product, developers sometimes need a “desktop preview” of the web app that can run offline. Wails lets you bundle the web UI with a Go server that mimics the production API, enabling rapid prototyping and user testing without deploying to the cloud.

Advanced Features: Events, Menus, and System Tray

Beyond simple function calls, Wails provides a rich set of native integrations. Let’s explore three of the most useful ones: event broadcasting, custom menus, and system tray icons.

Event Broadcasting

Events allow Go to push data to the frontend asynchronously. First, define an event name in Go and emit it whenever new data is available.

package backend

import (
    "context"
    "github.com/wailsapp/wails/v2/pkg/runtime"
)

type Notifier struct{}

// NewNotifier creates a new Notifier instance.
func NewNotifier() *Notifier {
    return &Notifier{}
}

// Start begins a background ticker that emits a "tick" event every second.
func (n *Notifier) Start(ctx context.Context) {
    go func() {
        ticker := time.NewTicker(1 * time.Second)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case t := <-ticker.C:
                runtime.EventsEmit(ctx, "tick", t.Format(time.RFC3339))
            }
        }
    }()
}

Register the notifier and start it in main.go:

notifier := backend.NewNotifier()
app.Bind(notifier)
notifier.Start(app.Context())

On the JavaScript side, listen for the event using the Wails SDK:

window.wails.EventsOn('tick', (timestamp) => {
  console.log('Received tick at', timestamp);
  // Update UI accordingly...
});

This pattern is perfect for live logs, sensor streams, or any scenario where the backend needs to inform the UI instantly.

Custom Menus

Desktop users expect native menus for actions like “File → Open” or “Help → About”. Wails lets you define these menus in Go, and you can attach callbacks that invoke any bound method.

import (
    "github.com/wailsapp/wails/v2/pkg/menu"
)

func buildMenu() *menu.Menu {
    fileMenu := menu.NewMenu()
    fileMenu.AddItem(menu.NewMenuItem("Open", func(_ *menu.CallbackData) {
        // Open file dialog or perform action
        fmt.Println("Open clicked")
    }))

    helpMenu := menu.NewMenu()
    helpMenu.AddItem(menu.NewMenuItem("About", func(_ *menu.CallbackData) {
        runtime.MessageDialog(app.Context(), runtime.MessageDialogOptions{
            Type:    runtime.InfoDialog,
            Title:   "About",
            Message: "Wails Demo App v1.0",
        })
    }))

    mainMenu := menu.NewMenu()
    mainMenu.AddSubmenu("File", fileMenu)
    mainMenu.AddSubmenu("Help", helpMenu)
    return mainMenu
}

Pass the generated menu to the app configuration:

app := wails.NewApp(&wails.AppConfig{
    // ... other config ...
    Menu: buildMenu(),
})

Now your app feels truly native, with platform‑appropriate menu shortcuts automatically applied.

System Tray Integration

For utilities that run in the background, a system tray icon offers quick access without cluttering the taskbar. Wails provides a simple API to create tray items and bind click events.

import (
    "github.com/wailsapp/wails/v2/pkg/tray"
)

func buildTray() *tray.Tray {
    trayMenu := tray.NewMenu()
    trayMenu.AddItem(tray.NewMenuItem("Show", func(_ *tray.CallbackData) {
        app.Show()
    }))
    trayMenu.AddItem(tray.NewMenuItem("Quit", func(_ *tray.CallbackData) {
        app.Quit()
    }))

    return tray.NewTray(&tray.TrayConfig{
        Title:    "Wails Demo",
        Tooltip:  "Click to open",
        Menu:     trayMenu,
        Icon:     runtime.Asset("frontend/dist/icon.png"),
    })
}

Attach the tray to the app config:

app := wails.NewApp(&wails.AppConfig{
    // ... other config ...
    Tray: buildTray(),
})

When the user minimizes the window, you can hide it and let the tray icon keep the app alive. This is especially useful for monitoring tools or background services.

Pro tip: Combine runtime.EventsEmit with tray menu actions to provide context‑aware shortcuts—e.g., “Pause Monitoring” directly from the tray without opening the main window.

Packaging and Distribution

After you’ve polished your app, the next step is to ship it. Wails ships with a built‑in wails build command that compiles your Go code, bundles the frontend assets, and creates native installers for each platform.

# Build for the current OS
wails build

# Cross‑compile for Windows from macOS or Linux
wails build -platform windows/amd64

# Generate a macOS .app bundle
wails build -platform darwin/arm64 -output MyApp.app

The resulting binaries are typically under 30 MB, far smaller than the 100+ MB Electron bundles. This translates to faster download times and lower memory usage on the user's machine.

For advanced distribution, you can integrate with tools like GoReleaser to automate versioning, changelog generation, and publishing to GitHub Releases or Homebrew taps.

Testing Strategies

Testing a Wails app involves both Go unit tests and frontend integration tests. Go’s built‑in testing package works seamlessly with Wails bindings because you can instantiate the runtime in a headless mode.

func TestNow(t *testing.T) {
    timer := backend.NewTimer()
    result, err := timer.Now(context.Background())
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result == "" {
        t.Error("expected non‑empty time string")
    }
}

For the UI layer, tools like Cypress (for React/Vue) or Playwright can drive the rendered webview just like any browser. Since Wails serves the frontend over a local HTTP server during development, you can point your test runner at http://localhost:34115 (default dev port).

Performance Considerations

While Wails is lightweight, there are a few best practices to keep your app snappy:

  • Minify and bundle assets. Use the production build of your chosen frontend framework (e.g., npm run build) before packaging.
  • Avoid blocking the Go runtime. Long‑running tasks should run in separate goroutines or be offloaded to worker pools to keep the UI responsive.
  • Leverage streaming APIs. If you need to send large datasets, consider streaming JSON lines or using binary protobuf over Wails events instead of sending a massive payload in one call.
Pro tip: The wails dev command automatically enables hot‑reloading, but for production profiling use go tool pprof on the compiled binary. This reveals any unexpected CPU spikes caused by UI‑related goroutine contention.

Extending Wails with Plugins

Wails supports Go plugins, allowing you to load additional functionality at runtime. This is handy for modular applications where features can be turned on or off without rebuilding the whole binary.

import (
    "plugin"
)

func loadPlugin(path string) (interface{}, error) {
    p, err := plugin.Open(path)
    if err != nil {
        return nil, err
    }
    sym, err := p.Lookup("ExportedSymbol")
    if err != nil {
        return nil, err
    }
    return sym, nil
}

After loading, you can bind the plugin’s methods just like any other Go struct. Keep in mind that plugins are platform‑specific; you’ll need separate builds for Windows, macOS, and Linux.

Security Best Practices

Because Wails runs a webview, it inherits the same security concerns as any web application. Here are three quick safeguards:

  • Disable remote URLs. By default Wails only loads local assets, but double‑check the Frontend configuration to prevent loading external scripts.
  • Validate all input. Even though Go is type‑safe, data coming from JavaScript arrives as generic interface{} values. Perform thorough validation before using them.
  • Use CSP (Content Security Policy). Inject a CSP header in the HTML to restrict inline scripts and only allow trusted sources.

Following these guidelines helps you avoid common pitfalls like XSS or code injection, especially if you ever embed a remote web UI for updates.

Conclusion

Wails offers a compelling blend of Go’s performance and the expressive power of

Share this article