Vue.js + Go with Echo v5, part 3: tasks REST API

Mascote LinuxPro inserindo um cartão de dados JSON num rack de servidores, com o gopher do Go sobre o rack e o cachorro caramelo cyborg trazendo um envelope

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.

With Vue talking to Go through Vite's proxy (part 2), it's time for the real backend. In this part, we write a tasks REST API in Echo v5: list, create, mark as done, and delete. Along the way, you'll see the resources you'll use in any API with Echo: route groups, path parameters, , Bind of JSON, validation, , HTTPError , and proper status codes. In the end, the Vue tasks screen starts working.

The API contract

Before the code, the routes. They all live under /api:

  • GET /api/tasks: lists tasks, 200;
  • POST /api/tasks with {"title": "..."}: creates, 201 with the created task;
  • PATCH /api/tasks/:id with {"done": true} or {"title": "..."}: updates, 200;
  • DELETE /api/tasks/:id: removes, 204 with no body.

Errors follow the Echo pattern, {"message": "..."}: 400 for JSON or invalid id, 422 for empty or too long title, 404 for non-existent task. That is the message that api.js part 2 shows on screen.

The model and in-memory storage

To keep the focus on Echo, tasks are kept in memory, protected by a sync.Mutex: the server handles multiple requests at the same time, and without the lock, two POST simultaneous ones would corrupt the list. Restarting the process erases everything. Switching to SQLite or PostgreSQL later only changes this file.

Create internal/api/tasks.go:

package api

import (
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/labstack/echo/v5"
)

// Task é uma tarefa da lista.
type Task struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}

// Tasks guarda as tarefas em memória. Reiniciar o processo apaga tudo.
type Tasks struct {
	mu     sync.Mutex
	nextID int
	items  []Task
}

func NewTasks() *Tasks { return &Tasks{nextID: 1} }

The tags json:"..." define the field names in the JSON: created_at instead of CreatedAt.

The handlers

In Echo v5, a handler is a function func(c *echo.Context) error. Here they are methods of *Tasks, which gives access to the list without a global variable. Continue in tasks.go:

func (t *Tasks) List(c *echo.Context) error {
	t.mu.Lock()
	defer t.mu.Unlock()
	return c.JSON(http.StatusOK, append([]Task{}, t.items...))
}

type taskInput struct {
	Title string `json:"title"`
	Done  *bool  `json:"done"`
}

func (t *Tasks) Create(c *echo.Context) error {
	var in taskInput
	if err := c.Bind(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "JSON inválido")
	}
	title := strings.TrimSpace(in.Title)
	if title == "" || len(title) > 200 {
		return echo.NewHTTPError(http.StatusUnprocessableEntity, "o título precisa ter de 1 a 200 caracteres")
	}
	t.mu.Lock()
	task := Task{ID: t.nextID, Title: title, CreatedAt: time.Now().UTC()}
	t.nextID++
	t.items = append(t.items, task)
	t.mu.Unlock()
	return c.JSON(http.StatusCreated, task)
}

Some details:

  • append([]Task{}, t.items...) returns a copy. With the list empty, the JSON comes out as [], instead of null, which avoids a v-for broken in Vue;
  • c.Bind(&in) reads the body according to the Content-Type. For JSON, it needs the header Content-Type: application/json, which api.js already sends;
  • echo.NewHTTPError(código, mensagem) interrupts the handler and becomes the response {"message": ...} with the right status;
  • Done é *bool at the entry to distinguish “not sent” from false: without the pointer, changing only the title would uncheck the task.

Update and remove read the :id from the path with c.Param:

func (t *Tasks) Update(c *echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "id inválido")
	}
	var in taskInput
	if err := c.Bind(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "JSON inválido")
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	for i := range t.items {
		if t.items[i].ID == id {
			if title := strings.TrimSpace(in.Title); title != "" {
				t.items[i].Title = title
			}
			if in.Done != nil {
				t.items[i].Done = *in.Done
			}
			return c.JSON(http.StatusOK, t.items[i])
		}
	}
	return echo.NewHTTPError(http.StatusNotFound, "tarefa não encontrada")
}

func (t *Tasks) Delete(c *echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "id inválido")
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	for i := range t.items {
		if t.items[i].ID == id {
			t.items = append(t.items[:i], t.items[i+1:]...)
			return c.NoContent(http.StatusNoContent)
		}
	}
	return echo.NewHTTPError(http.StatusNotFound, "tarefa não encontrada")
}

Register the routes with a group

A group applies a prefix, and optionally middlewares, to several routes. Create internal/api/routes.go. In this part the routes are still public; the login goes into part 4:

// Package api reúne as rotas JSON da aplicação.
package api

import (
	"net/http"

	"github.com/labstack/echo/v5"
)

// Register liga as rotas em /api.
func Register(e *echo.Echo, tasks *Tasks) {
	g := e.Group("/api")

	g.GET("/health", func(c *echo.Context) error {
		return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
	})
	g.GET("/tasks", tasks.List)
	g.POST("/tasks", tasks.Create)
	g.PATCH("/tasks/:id", tasks.Update)
	g.DELETE("/tasks/:id", tasks.Delete)
}

And main.go starts delegating routes to the package api. The route /api/health from part 1 moved into the group:

package main

import (
	"log/slog"

	"github.com/exemplo/vueapp/internal/api"
	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.RequestLogger())
	e.Use(middleware.Recover())

	api.Register(e, api.NewTasks())

	if err := e.Start("127.0.0.1:8080"); err != nil {
		slog.Error("servidor", "error", err)
	}
}

Test with curl

go run .
B=http://127.0.0.1:8080
curl -s -H 'Content-Type: application/json' -d '{"title":"Estudar Echo v5"}' $B/api/tasks
curl -s -H 'Content-Type: application/json' -d '{"title":"  "}' $B/api/tasks
curl -s -X PATCH -H 'Content-Type: application/json' -d '{"done":true}' $B/api/tasks/1
curl -s $B/api/tasks
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE $B/api/tasks/1
curl -s $B/api/naoexiste

Output from our test:

{"id":1,"title":"Estudar Echo v5","done":false,"created_at":"2026-09-23T01:31:05.742644306Z"}
{"message":"o título precisa ter de 1 a 200 caracteres"}
{"id":1,"title":"Estudar Echo v5","done":true,"created_at":"2026-09-23T01:31:05.742644306Z"}
[{"id":1,"title":"Estudar Echo v5","done":true,"created_at":"2026-09-23T01:31:05.742644306Z"}]
204
{"message":"Not Found"}

Each response has the status that the contract promises, including Echo's standard 404 for a non-existent route.

The tasks screen in Vue

Replace the provisional view from part 2 with web/src/views/TasksView.vue complete. The logged-in user and the logout button now go in and start working in part 4:

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

const router = useRouter()
const user = ref('')
const tasks = ref([])
const title = ref('')
const error = ref('')

async function load() {
  user.value = (await api('GET', '/me')).username
  tasks.value = await api('GET', '/tasks')
}

async function add() {
  error.value = ''
  try {
    tasks.value.push(await api('POST', '/tasks', { title: title.value }))
    title.value = ''
  } catch (e) {
    error.value = e.message
  }
}

async function toggle(task) {
  Object.assign(task, await api('PATCH', `/tasks/${task.id}`, { done: !task.done }))
}

async function remove(task) {
  await api('DELETE', `/tasks/${task.id}`)
  tasks.value = tasks.value.filter((t) => t.id !== task.id)
}

async function logout() {
  await api('POST', '/logout')
  router.push('/login')
}

onMounted(load)
</script>

<template>
  <section class="card">
    <header>
      <h1>Tarefas</h1>
      <span>{{ user }} · <a href="#" @click.prevent="logout">Sair</a></span>
    </header>
    <form class="row" @submit.prevent="add">
      <input v-model="title" placeholder="Nova tarefa" maxlength="200" />
      <button>Adicionar</button>
    </form>
    <p v-if="error" class="error">{{ error }}</p>
    <ul>
      <li v-for="task in tasks" :key="task.id" :class="{ done: task.done }">
        <label><input type="checkbox" :checked="task.done" @change="toggle(task)" /> {{ task.title }}</label>
        <button class="link" @click="remove(task)">remover</button>
      </li>
    </ul>
    <p v-if="!tasks.length" class="muted">Nenhuma tarefa ainda.</p>
  </section>
</template>

The pattern is always the same: call the API and update the reactive state with the server's response. The toggle copies to the task whatever Go returned, so the screen never shows a state that the backend didn't accept. While the route /api/me does not exist, remove the first line from the load() to test.

A basic CSS keeps the screen presentable. Put it in web/src/style.css:

:root { font-family: system-ui, sans-serif; color: #1f2937; background: #f3f4f6; }
body { margin: 0; }
.container { max-width: 480px; margin: 4rem auto; padding: 0 1rem; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); }
.card label { display: block; margin-bottom: .75rem; }
input { width: 100%; box-sizing: border-box; padding: .5rem; margin-top: .25rem; }
input[type=checkbox] { width: auto; margin: 0 .5rem 0 0; }
button { padding: .5rem 1rem; background: #2563eb; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
button.link { background: none; color: #b91c1c; padding: 0; }
header { display: flex; justify-content: space-between; align-items: baseline; }
.row { display: flex; gap: .5rem; margin-bottom: 1rem; }
.row input { margin: 0; }
ul { list-style: none; padding: 0; }
li { display: flex; justify-content: space-between; padding: .4rem 0; border-bottom: 1px solid #e5e7eb; }
li label { margin: 0; }
li.done label { text-decoration: line-through; color: #9ca3af; }
.error { color: #b91c1c; }
.muted { color: #6b7280; }

Tela de tarefas em Vue consumindo a API Go com Echo v5, com três tarefas e uma marcada como concluída

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

The API works, but anyone can delete your tasks. In section 4 we create login with bcrypt password, cookie-based session HttpOnly, an Echo middleware that protects the routes, and the login 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