Vue.js + Go with Echo v5, part 2: Vue frontend with Vite

Mascote LinuxPro encaixando um bloco com o logo do Vue num quadro de componentes de interface, com o gopher do Go e o cachorro caramelo cyborg

Vue.js + Go with Echo v5 series: Part 1: environment and first API · Part 2: Vue frontend with Vite · Part 3: tasks REST API · Part 4: login with session and cookie · Part 5: embed, single binary and Docker

Complete code: all files from the series are in the gist vue-go-echo-v5, with a README that shows which folder each one goes in.

At part 1 we deployed the first API route with Echo v5. Now it's time for the frontend: let's create a project Vue 3 with Vite inside the Go repository, install Vue Router and configure the development proxy. With the proxy, the browser sees the frontend and the API under the same origin, without CORS, which is exactly what happens in production when Go serves the embedded Vue.

Create the Vue project with Vite

Vite is the official build tool of the Vue ecosystem: a development server with instant reload and an optimized production build. In the project root (vueapp/), create the frontend in the folder web using the template vue:

cd vueapp
npm create vite@latest web -- --template vue --no-interactive --no-immediate
cd web
npm install
npm install vue-router@4

These options avoid the interactive prompts: --template vue selects Vue with JavaScript, and --no-immediate does not start the server automatically. If you prefer TypeScript, use --template vue-ts; the rest of the series works the same, just with extensions .ts.

In testing this series, the package.json ended up with:

{
  "name": "web",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.5.42",
    "vue-router": "^4.6.4"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.8",
    "vite": "^8.3.0"
  }
}

Delete the example component, which we won't use:

rm -rf src/components src/assets/*.svg
mkdir -p src/views

Vite proxy to the Go API

In development, two servers run: Vite on localhost:5173 and Go on 127.0.0.1:8080. If the browser called Go directly, they would be different origins and you would have to configure CORS, and the session cookie from the 4 part would make it even more complicated. The simplest solution is for Vite to forward /api to Go. Edit web/vite.config.js:

import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [vue()],
  server: {
    // Em desenvolvimento, o Vite (5173) repassa /api para o Go (8080)
    proxy: {
      '/api': 'http://127.0.0.1:8080',
    },
  },
})

This way the browser only talks to localhost:5173. In production, Go serves the frontend and the API on the same port, and the Vue code doesn't change at all: it always calls /api/... with a relative path.

A small API client

We don't need an HTTP library. Create web/src/api.js with a function that sends and receives JSON and turns error responses into an exception with the API's message:

// Pequeno cliente da API: JSON na ida e na volta, cookie de sessão automático.
export async function api(method, path, body) {
  const res = await fetch(`/api${path}`, {
    method,
    headers: body ? { 'Content-Type': 'application/json' } : {},
    body: body ? JSON.stringify(body) : undefined,
  })
  if (res.status === 204) return null
  const data = await res.json().catch(() => ({}))
  if (!res.ok) {
    const err = new Error(data.message || `HTTP ${res.status}`)
    err.status = res.status
    throw err
  }
  return data
}

Since the API is on the same origin, the fetch automatically sends the session cookie; the default credentials: 'same-origin' is enough. Echo returns errors in the format {"message": "..."}, so data.message becomes the text displayed on the screen.

Vue Router routes

We will have two pages: /login and /tarefas. Create web/src/router.js. Route protection (meta.auth) is already declared here and starts working when the login API exists, in part 4:

import { createRouter, createWebHistory } from 'vue-router'
import { api } from './api'
import LoginView from './views/LoginView.vue'
import TasksView from './views/TasksView.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', redirect: '/tarefas' },
    { path: '/login', component: LoginView },
    { path: '/tarefas', component: TasksView, meta: { auth: true } },
  ],
})

// Antes de abrir uma página protegida, pergunta ao Go se a sessão vale
router.beforeEach(async (to) => {
  if (!to.meta.auth) return true
  try {
    await api('GET', '/me')
    return true
  } catch {
    return { path: '/login', query: { next: to.fullPath } }
  }
})

export default router

The createWebHistory() uses clean URLs, like /tarefas, without the #. The price is that the server must return the index.html when someone opens /tarefas directly in the browser. Vite does this on its own in development; in part 5 we configure Echo to do the same.

Wire up the router in web/src/main.js and reduce the App.vue to a container with the <RouterView />:

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')
<template>
  <main class="container">
    <RouterView />
  </main>
</template>

Placeholder pages

The full screens come in parts 3 and 4. For now, create both views with one call to the route /api/health of part 1, to prove that the proxy works. web/src/views/TasksView.vue:

<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'

const status = ref('...')
onMounted(async () => {
  status.value = (await api('GET', '/health')).status
})
</script>

<template>
  <section class="card">
    <h1>Tarefas</h1>
    <p>API: {{ status }}</p>
  </section>
</template>

And a web/src/views/LoginView.vue minimum, so that the route exists:

<template>
  <section class="card"><h1>Entrar</h1></section>
</template>

While the route /api/me does not exist, the router guard sends /tarefas to the login. To test the proxy at this step, temporarily comment out the meta: { auth: true }.

Run both servers

Terminal 1, at the root of the project:

go run .

Terminal 2, in web/:

npm run dev
  VITE v8.3.0  ready in 122 ms

  ➜  Local:   http://localhost:5173/

Open http://localhost:5173/tarefas: the page shows API: ok, coming from Go through the proxy. You can also check in the terminal:

curl http://localhost:5173/api/health
{"status":"ok"}

Note the address: Vite listens on localhost, which on many systems resolves to ::1 (IPv6). If curl http://127.0.0.1:5173 refuses the connection, use localhost, or start Vite with npm run dev -- --host 127.0.0.1.

Production build

To see what Go will embed at the end, run the build:

npm run build
dist/index.html                  0.45 kB │ gzip:  0.29 kB
dist/assets/index-o26dGMpn.css   0.92 kB │ gzip:  0.49 kB
dist/assets/index-U0LYTB29.js   92.56 kB │ gzip: 35.69 kB
✓ built in 59ms

The folder web/dist contains a index.html and the files with hash in the name, ready for long caching. The .gitignore generated by Vite already ignores dist; on the 5 side that will matter for the go:embed.

Files for this part in the gist

Each file opens directly in the gist of the series. The gist brings the final version of the project: main.go and routes.go they also get login in part 4 and the embedded frontend in part 5.

Next step

Frontend and backend are already talking to each other. In part 3 we wrote the tasks REST API in Echo v5, with a routes group, Bind JSON, validation, proper HTTP codes, and the complete tasks screen in Vue.

Vue.js + Go with Echo v5 series: Part 1: environment and first API · Part 2: Vue frontend with Vite · Part 3: tasks REST API · Part 4: login with session and cookie · Part 5: embed, single binary and Docker