Application with Wails 3 and ReactJS

published: ⏰ 10

Another variation on the theme of combining backend and frontend in a single application. There have already been examples of Spring Boot with ReactJS, Go with HTMX and Electron with ReactJS. This time, the alternative to Electron is Wails v3, which combines Go and ReactJS to create native desktop applications.

Introduction

Wails is a framework that allows using Go for the backend and modern web technologies for the frontend. Unlike Electron, which packages an entire Chromium, Wails uses the OS native webview: WebView2 on Windows, WKWebView on macOS, and WebKit on Linux. This means the resulting binary is 10 times smaller and consumes significantly less memory.

Under the hood, Wails v3 works like this: Go code is compiled into a native binary, and the frontend is built via Vite and embedded into the binary using go:embed. Go services are automatically exposed to JavaScript through generated TypeScript bindings. No manual IPC – just call a Go method from React as if it were a regular API.

One of the important advantages of Wails v3 is the built-in MCP server. It allows LLM agents (Claude Code, Copilot, opencode) to connect to the running application, manage windows, take DOM snapshots, click on elements, and call Go methods. This can greatly simplify testing and debugging processes.

Why Go Instead of Node.js

When comparing Wails and Electron, the main differences lie in the choice of backend: Go vs Node.js.

MetricWails 3 (Go)Electron (Node.js)
Binary size~15 MB100–150 MB
Memory usage~10 MB100+ MB
Start time< 0.5 s2–3 s
RenderingNative OS webviewEmbedded Chromium
Backend languageCompiled (Go)Interpreted (JS/TS)
ConcurrencyGoroutinesEvent loop (single thread)
Type safetyStatic (Go)Dynamic (JS) / Static (TS)

Binary size is perhaps the most noticeable difference. Electron packages Chromium, which adds ~100 MB even to an empty application. Wails uses the webview already installed in the system, so the binary contains only Go code and the compiled frontend.

Memory consumption also differs significantly. Each Electron window is a separate Chromium process, consuming from 50 to 200 MB. The native webview in Wails uses system resources and consumes significantly less memory.

Go, as a compiled language, provides predictable performance. Goroutines allow easily handling concurrent operations: reading from a database, file system, network requests – all of this works in parallel without blocking the UI. TypeScript in Electron (Node.js) is limited by a single-threaded event loop, and for concurrency, one must use worker-threads, which complicates the architecture.

Go’s type safety adds another advantage: the compiler catches errors during the build stage, not at runtime. Wails additionally generates TypeScript bindings from Go structs, ensuring type safety on the frontend side as well.

Wails Architecture

In Electron, the architecture is built around three processes: main, preload, and renderer, which communicate via IPC. In Wails, it’s simpler: one Go process + webview, and calling Go methods from JavaScript happens directly through generated bindings. A full description with diagrams can be found on the developers’ site How Wails Works.

The architecture of the example application consists of three layers:

arch.svg

Frontend (React 19 + TypeScript) – user interface built via Vite. React hooks (useTodos, useNotes, useTags) encapsulate calls to Go services via automatically generated TypeScript bindings. UI components are built using shadcn/ui with Radix UI.

Service Layer (Go) – application business logic. Services (TodoService, NoteService, TagService, AppService) contain data validation, business rules, and interact with the repository layer. Service methods are automatically pushed to JavaScript via Wails bindings.

Repository Layer (Go + SQLite) – data access. The modernc.org/sqlite driver is used – pure Go, without CGO, which simplifies cross-platform builds.

The key difference from Electron: no need to describe IPC channels, write preload scripts, or declare types for window.api. Wails automatically generates TypeScript wrappers from Go code, and calling TodoService.List() from React looks like a regular asynchronous call.

Creating the Application

To get started with Wails v3, you need to install the CLI:

Shell
go install github.com/wailsapp/wails/v3/cmd/wails3@latest

Creating a new project:

Shell
# Creating a new project with React + TypeScript
wails3 create -n sample-wails-react-golang -t react-ts

# Go to directory and install dependencies
cd sample-wails-react-golang
go mod tidy
cd frontend && npm install && cd ..

# Launch in development mode with hot-reload
wails3 dev

The wails3 dev command starts the Go application with hot-reload and the Vite server for the frontend. When Go files are changed, the application restarts, and frontend changes are applied instantly via HMR.

Again, I will not provide the full example code – the article will refer to the repository and describe only the important points. The project is a task (todos) and notes management application with tags, search, and filtering.

Go Backend

Entry Point

The main.go file initializes the database, creates repositories and services, registers services in the application, and configures the window:

Go
    func main() {
        // Path to database in XDG data directory
        dbPath := filepath.Join(xdg.DataHome, "sample-wails-react-golang", "app.db")
        
        // Initialize database
        db, err := repository.NewDB(dbPath)
        if err != nil {
            log.Fatalf("Failed to initialize database: %v", err)
        }
        defer db.Close()
        
        // Create repository
        todoRepo := repository.NewTodoRepo(db)
        noteRepo := repository.NewNoteRepo(db)
        tagRepo := repository.NewTagRepo(db)
        
        // Register services
        app := application.New(application.Options{
            Name:        "ToDo Notes",
            Description: "A to-do and notes application",
            Services: []application.Service{
                application.NewService(service.NewTodoService(todoRepo)),
                application.NewService(service.NewNoteService(noteRepo)),
                application.NewService(service.NewTagService(tagRepo)),
                application.NewService(service.NewAppService()),
            },
            Assets: application.AssetOptions{
                Handler: application.AssetFileServerFS(assets),
            },
        })

    // Configure window
    app.Window.NewWithOptions(application.WebviewWindowOptions{
        Title:  "ToDo Notes",
        Width:  1200,
        Height: 800,
    })
    
    if err := app.Run(); err != nil {
        log.Fatal(err)
    }
}

The frontend is embedded into the binary via go:embed:

Go
//go:embed all:frontend/dist
var assets embed.FS

Services

Services contain business logic and validation. For example, TodoService provides methods for working with tasks:

Go
    func (s *TodoService) Create(input model.TodoCreateInput) (*model.Todo, error) {
        if input.Title == "" {
            return nil, fmt.Errorf("title is required")
        }
        return s.repo.Create(input)
    }

    func (s *TodoService) Search(query string) ([]model.Todo, error) {
        if query == "" {
            return []model.Todo{}, nil
        }
        return s.repo.Search(query)
    }

Similarly structured are NoteService (CRUD + notes search), TagService (tags list), and AppService (application version information).

Data Repositories

The application uses SQLite via the modernc.org/sqlite driver – pure Go, without CGO. This means that cross-platform builds do not require installing system SQLite dependencies. The database file is stored in the XDG directory: $XDG_DATA_HOME/sample-wails-react-golang/app.db.

Repositories work with SQLite within a transaction. For example, TodoRepo creates tasks and automatically adds new tags:

Go
    func (r *TodoRepo) Create(input model.TodoCreateInput) (*model.Todo, error) {
        tx, err := r.db.Begin()
        if err != nil {
            return nil, fmt.Errorf("begin tx: %w", err)
        }
        defer tx.Rollback()
    
        priority := input.Priority
        if priority == "" {
            priority = "medium"
        }
        
        res, err := tx.Exec(
            `INSERT INTO todos (title, description, status, priority, due_date) VALUES (?, ?, 'pending', ?, ?)`,
            input.Title, input.Description, priority, input.DueDate)
        // ... creating tags and commit
    }

All queries are parameterized – SQL injections are excluded at the code level.

Migrations are performed during the first database initialization in db.go.

Automatic Bindings

One of the most convenient features of Wails is the automatic generation of TypeScript bindings from Go code. When Wails finds public methods in registered services, it generates TypeScript modules in frontend/bindings/.

For example, for TodoService, todoservice.ts is generated:

TypeScript
import {Call as $Call} from "@wailsio/runtime";
import * as model from "../../pkg/model/models.js";

export function List(filter: model.TodoFilter | null): $CancellablePromise<model.Todo[] | null> {
    return $Call.ByID(3506783982, filter);
}

export function Create(input: model.TodoCreateInput): $CancellablePromise<model.Todo | null> {
    return $Call.ByID(3583200614, input);
}

Each Go method receives a unique ID, and the call via $Call.ByID() routes the request to the corresponding service method. TypeScript types are generated from Go structs, so the compiler checks the correctness of calls.

In Electron, you would have to manually describe IPC channels: declare types for window.api, write a preload script, and register handlers in ipcMain. In Wails, all this is done automatically.

Frontend

The frontend is built on React 19 + TypeScript + Vite 8. UI components use shadcn/ui (Radix UI) and Tailwind CSS v4. Navigation is implemented via a simple useState + switch without a router, as the application is small.

Hooks for Working with Go Services

Each domain (todos, notes, tags) has its own custom hook. useTodos encapsulates all operations with tasks:

TypeScript
import * as TodoService from '@bindings/.../service/todoservice';

export function useTodos() {
    const [todos, setTodos] = useState<Todo[]>([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    const fetchTodos = useCallback(async (filter?: TodoFilter) => {
        try {
            setLoading(true);
            const result = await TodoService.List(filter ?? null);
            setTodos(result ? result.map(normalizeTodo) : []);
            setError(null);
        } catch (e: unknown) {
            setError((e as Error)?.message || 'Failed to load todos');
        } finally {
            setLoading(false);
        }
    }, []);

    useEffect(() => {
        fetchTodos();
    }, [fetchTodos]);

    const createTodo = async (input: TodoCreateInput): Promise<Todo | null> => {
        const result = await TodoService.Create(input);
        if (result) {
            setTodos(prev => [...prev, normalizeTodo(result)]);
            return normalizeTodo(result);
        }
        return null;
    };

    // ... updateTodo, deleteTodo, search

    return {todos, loading, error, fetchTodos, createTodo, updateTodo, deleteTodo, search};
}

The hook calls the Go service directly through generated bindings, updates the local React state, and handles errors. No IPC, preload, or ipcRenderer.invoke.

Components and Pages

The TodosPage page uses the useTodos hook, displays a list of tasks with search and filtering, and provides a form for creating/editing:

Tsx
export default function TodosPage() {
    const {todos, loading, createTodo, updateTodo, deleteTodo, search} = useTodos();
    const {tags: availableTags} = useTags();
    const [filter, setFilter] = useState<TodoFilter>({});

    return (
        <div className="space-y-6">
            <div className="flex items-center justify-between">
                <h2 className="text-xl font-bold">Todos</h2>
                <Button onClick={() => setFormOpen(true)} size="sm">
                    <Plus className="h-4 w-4 mr-1"/> New Todo
                </Button>
            </div>
            <SearchBar onSearch={handleSearch} placeholder="Search todos..."/>
            <TodoList todos={todos} filter={filter} onFilterChange={setFilter} ... />
            <TodoForm open={formOpen} onClose={...} onCreate={...} ... />
        </div>
    );
}

UI components (Button, Card, Badge, Select) are built on shadcn/ui with support for CVA (class-variance-authority) for style variants. Icons are from lucide-react.

MCP Server (LLM Control)

Wails v3 includes an experimental MCP server – a built-in Model Context Protocol server that allows LLM agents to connect to the running application, manage it, and test the UI.

A connected agent can:

  • Manage windows – size, position, focus, fullscreen, devtools, reload.
  • Inspect DOM – CSS queries, HTML content, structural snapshot of the page.
  • Execute arbitrary code inside the application window.
  • Simulate input – mouse movement, clicks, drag-and-drop, scroll.
  • Enter text and press keys.
  • Call services (Go), for example main.TodoService.List.
  • Work with events (emit and wait for Wails events).

ℹ️ Warning

The MCP server gives full programmatic control over the application. Anyone who has access to the port can read the DOM, execute JavaScript, click on elements, and call Go methods.

For security reasons, the following restrictions apply:

  • The server binds to 127.0.0.1 by default and rejects non-local requests (protection against DNS rebinding).
  • Production builds must not include the mcp tag. The wails3 build command without WAILS_MCP=1 does not include the server.
  • For testing in a LAN, you can set WAILS_MCP_HOST=0.0.0.0, but only in trusted networks.

Activation

The MCP server is compiled into the application only if the mcp tag is present. Without this tag, the server code is completely absent from the binary – no overhead, no open ports, no additional attack surface.

To activate, simply set the environment variable WAILS_MCP=1:

Shell
# Development mode
WAILS_MCP=1 wails3 dev

# Production build with MCP
WAILS_MCP=1 wails3 build

Or pass the tag directly:

Shell
go run -tags mcp .
go build -tags mcp -o myapp .

Upon launch, the application outputs the MCP server address:

INFO MCP server started. Connect MCP clients using the streamable HTTP transport.
      url=http://127.0.0.1:9099/mcp

Connecting Clients

Claude Code:

Shell
claude mcp add --transport http my-app http://127.0.0.1:9099/mcp

Then you can ask Claude to interact with the application:

Take a DOM snapshot of the main window.
Click the "New Todo" button, type "Buy milk" in the input, then press Enter and verify the item appears.
Call the bound method main.TodoService.List and return the result.

VS Code (GitHub Copilot):

JSON
{
  "github.copilot.chat.mcp.enabled": true,
  "mcp": {
    "servers": {
      "my-wails-app": {
        "type": "http",
        "url": "http://127.0.0.1:9099/mcp"
      }
    }
  }
}

opencode:

opencode supports connecting to MCP servers via MCP. To connect to a Wails application, you need to add the MCP server configuration to opencode.json or opencode.jsonc:

JSON
{
  "mcp": {
    "servers": {
      "wails-app": {
        "type": "http",
        "url": "http://127.0.0.1:9099/mcp"
      }
    }
  }
}

Application Build

When the application is ready, it is built with the command:

Shell
# Production build for current OS
wails3 build

Wails builds the frontend via Vite, generates TypeScript bindings, compiles Go code, and packages everything into a single binary. The result appears in the build/bin/ directory.

Cross-platform build:

Shell
GOOS=linux   wails3 build
GOOS=darwin  wails3 build
GOOS=windows wails3 build

For Linux distributions, Wails uses nfpm to create packages (.deb, .rpm). The nfpm configuration is located in build/linux/nfpm/nfpm.yaml.

Conclusion

Wails 3 + ReactJS is a more performant and in some ways more convenient alternative to Electron for creating desktop applications. Main advantages:

  • Size: ~15 MB versus 100+ MB for Electron.
  • Memory: native webview consumes significantly less.
  • Simplicity: no IPC, preload scripts, and manual bindings – Go methods are available from React directly.
  • Performance: compiled Go with goroutines instead of Node.js event loop.
  • MCP server: built-in ability to connect LLM agents for testing and debugging.

There are also limitations: native webview may differ between platforms (WebView2 vs WKWebView), which requires testing on each OS. The Wails v3 ecosystem is still smaller than Electron’s, but it is actively developing.

ℹ️ Note

The example project is available in open access.

Wails Reactjs Golang Desktop Devmix