Building a Modern Desktop Chat App with Wails: Go + Vue Made Easy

This article walks through creating a lightweight, cross‑platform desktop chat application using Wails, demonstrating project structure, Go‑to‑frontend API generation, real‑time event communication, SQLite storage, UI implementation with Vue3, image handling, a one‑click dev script, and packaging size advantages.

Code Wrench
Code Wrench
Code Wrench
Building a Modern Desktop Chat App with Wails: Go + Vue Made Easy

Project Structure

Wails uses a clear directory layout: Go source files reside in backend/, the Vue3 UI in frontend/, the entry point is main.go, and project configuration is stored in wails.json.

chat-app/
├─ backend/          # Go backend
│  ├─ chat.go
│  ├─ chat_ws.go
│  ├─ repository.go
│  ├─ file.go
│  └─ models.go
├─ frontend/         # Vue3 frontend
│  └─ src/
│      ├─ views/Chat.vue
│      ├─ components/MessageBubble.vue
│      ├─ store/chat.ts
│      └─ styles/
├─ wails.json
└─ main.go

Automatic Frontend API Generation

Wails generates a TypeScript SDK that exposes Go methods directly to the frontend, eliminating the need for bridge code.

func (c *ChatService) SendMessage(roomID string, msg string) error {
    // store message
    return nil
}
import { SendMessage } from '@/wailsjs/go/chat/ChatService';
SendMessage("room1", "Hello");

Bidirectional Event Communication

The backend can emit events that the frontend listens to, providing a simple real‑time update mechanism.

runtime.EventsEmit(c.ctx, "new_message", msg)
EventsOn("new_message", (msg: Message) => {
    chatStore.addMsg(msg);
});

Local SQLite Storage

Messages are persisted in a local SQLite database, enabling instant loading of chat history on startup.

func (r *Repository) SaveMessage(m *Message) error {
    _, err := r.db.Exec(`
        INSERT INTO messages (room_id, sender, type, content, created_at)
        VALUES (?, ?, ?, ?, ?)
    `, m.RoomID, m.Sender, m.Type, m.Content, m.CreatedAt)
    return err
}
func (r *Repository) GetMessages(roomID string) ([]Message, error) {
    rows, err := r.db.Query(`
        SELECT sender, type, content, created_at
        FROM messages WHERE room_id = ?
        ORDER BY created_at ASC
    `, roomID)
    // process rows ...
    return msgs, err
}

Frontend loads the history with a promise call:

LoadHistory(roomID).then(msgs => store.setHistory(roomID, msgs));

UI Implementation (Vue3 + TailwindCSS + Pinia)

The chat UI is built with Vue components and Tailwind for styling. The following snippet shows the message list rendering logic, handling text, emoji, image, and recall message types.

<div ref="msgContainer" class="flex-1 overflow-auto p-6 bg-gradient-to-b from-white to-gray-50">
  <div v-for="m in messages" :key="m.id" class="mb-4 flex" :class="m.username===username ? 'justify-end' : 'justify-start'">
    <div :class="['max-w-[60%] p-4 rounded-2xl shadow-md', m.username===username ? 'bg-gradient-to-r from-indigo-500 to-indigo-600 text-white' : 'bg-white border border-gray-200 shadow-sm']">
      <div class="flex items-center justify-between">
        <div class="text-sm font-medium">{{ m.username }}</div>
        <div class="text-xs text-gray-200" v-if="m.username===username">{{ formatTime(m.timestamp) }}</div>
      </div>
      <div class="mt-2">
        <template v-if="m.type==='text'">
          <div class="whitespace-pre-wrap">{{ m.content }}</div>
        </template>
        <template v-else-if="m.type==='emoji'">
          <div class="text-2xl">{{ m.content }}</div>
        </template>
        <template v-else-if="m.type==='image'">
          <img :src="m.content" class="w-48 h-48 object-cover rounded cursor-pointer" @click="viewImage(m.content)" />
        </template>
        <template v-else-if="m.type==='recall'">
          <div class="italic text-sm text-gray-400">Message recalled</div>
        </template>
      </div>
      <div class="text-xs text-gray-400 mt-1" v-if="m.username!==username">{{ formatTime(m.timestamp) }}</div>
    </div>
  </div>
</div>

File Handling (Image & Emoji)

Frontend selects a local file and uploads it; the backend writes the file to disk.

function selectImage() {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = 'image/*';
  input.onchange = () => {
    const file = input.files[0];
    UploadImage(file).then(url => SendMessage(room, url));
  };
  input.click();
}
func (c *ChatService) UploadImage(roomID string, data []byte) (string, error) {
    filename := fmt.Sprintf("img_%d.png", time.Now().Unix())
    path := filepath.Join(c.DataDir, filename)
    os.WriteFile(path, data, 0644)
    return filename, nil
}

Development Script

A minimal Bash script sets up Go modules and launches the Wails development server.

#!/bin/bash
go mod tidy
wails dev

Binary Size

The final packaged binary is only a few dozen megabytes, considerably smaller than typical Electron builds that start at 100 MB.

Repository

Source code is available at:

GitHub: https://github.com/louis-xie-programmer/chat-app

Gitee: https://gitee.com/louis_xie/chat-app

frontendGoVueSQLitedesktop appWails
Code Wrench
Written by

Code Wrench

Focuses on code debugging, performance optimization, and real-world engineering, sharing efficient development tips and pitfall guides. We break down technical challenges in a down-to-earth style, helping you craft handy tools so every line of code becomes a problem‑solving weapon. 🔧💻

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.