What We’ll Build
A task manager with:- Go API backend
- React frontend with TypeScript
- Real-time task updates via SSE
- Production build embedded in Go binary
Prerequisites
- Go 1.22 or later
- Node.js 18 or later
- Completed previous tutorials (helpful but not required)
Step 1: Create the Project
Copy
# Create project structure
mkdir taskmanager && cd taskmanager
go mod init taskmanager
go get github.com/go-mizu/mizu
# Create frontend with Vite
npm create vite@latest web -- --template react-ts
cd web && npm install && cd ..
Copy
taskmanager/
├── main.go # Go backend
├── go.mod
├── web/ # React frontend
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
Step 2: Create the Backend API
Createmain.go:
Copy
package main
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"sync"
"time"
"github.com/go-mizu/mizu"
)
//go:embed web/dist/*
var frontendFS embed.FS
// Task model
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"createdAt"`
}
// Storage
var (
tasks = make(map[string]*Task)
tasksMu sync.RWMutex
nextID = 1
subscribers = make(map[chan *Task]bool)
subMu sync.RWMutex
)
// Handlers
func listTasks(c *mizu.Ctx) error {
tasksMu.RLock()
defer tasksMu.RUnlock()
result := make([]*Task, 0, len(tasks))
for _, t := range tasks {
result = append(result, t)
}
return c.JSON(200, result)
}
func createTask(c *mizu.Ctx) error {
var input struct {
Title string `json:"title"`
}
if err := c.BindJSON(&input); err != nil || input.Title == "" {
return c.JSON(400, map[string]string{"error": "title required"})
}
tasksMu.Lock()
id := fmt.Sprintf("%d", nextID)
nextID++
task := &Task{
ID: id,
Title: input.Title,
CreatedAt: time.Now(),
}
tasks[id] = task
tasksMu.Unlock()
// Notify subscribers
broadcast(task)
return c.JSON(201, task)
}
func toggleTask(c *mizu.Ctx) error {
id := c.Param("id")
tasksMu.Lock()
defer tasksMu.Unlock()
task, ok := tasks[id]
if !ok {
return c.JSON(404, map[string]string{"error": "not found"})
}
task.Completed = !task.Completed
broadcast(task)
return c.JSON(200, task)
}
func deleteTask(c *mizu.Ctx) error {
id := c.Param("id")
tasksMu.Lock()
defer tasksMu.Unlock()
if _, ok := tasks[id]; !ok {
return c.JSON(404, map[string]string{"error": "not found"})
}
delete(tasks, id)
return c.NoContent(204)
}
// SSE for real-time updates
func subscribe(c *mizu.Ctx) error {
c.Header().Set("Content-Type", "text/event-stream")
c.Header().Set("Cache-Control", "no-cache")
c.Header().Set("Connection", "keep-alive")
ch := make(chan *Task, 10)
subMu.Lock()
subscribers[ch] = true
subMu.Unlock()
defer func() {
subMu.Lock()
delete(subscribers, ch)
subMu.Unlock()
close(ch)
}()
ctx := c.Context()
for {
select {
case <-ctx.Done():
return nil
case task := <-ch:
data, _ := json.Marshal(task)
fmt.Fprintf(c.Writer(), "data: %s\n\n", data)
c.Writer().(http.Flusher).Flush()
}
}
}
func broadcast(task *Task) {
subMu.RLock()
defer subMu.RUnlock()
for ch := range subscribers {
select {
case ch <- task:
default:
}
}
}
func main() {
app := mizu.New()
// API routes
api := app.Group("/api")
api.Get("/tasks", listTasks)
api.Post("/tasks", createTask)
api.Put("/tasks/{id}/toggle", toggleTask)
api.Delete("/tasks/{id}", deleteTask)
api.Get("/events", subscribe)
// Serve frontend
if os.Getenv("ENV") == "development" {
// Proxy to Vite dev server
fmt.Println("Running in development mode")
fmt.Println("Start Vite: cd web && npm run dev")
} else {
// Serve embedded files
sub, _ := fs.Sub(frontendFS, "web/dist")
fileServer := http.FileServer(http.FS(sub))
app.Get("/{path...}", func(c *mizu.Ctx) error {
// Try to serve file, fallback to index.html
path := c.Param("path")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(sub, path); err != nil {
path = "index.html"
}
c.Request().URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer(), c.Request())
return nil
})
}
fmt.Println("Server running at http://localhost:3000")
app.Listen(":3000")
}
Step 3: Configure Vite Proxy
Updateweb/vite.config.ts:
Copy
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
},
},
})
Step 4: Create the React Frontend
Replaceweb/src/App.tsx:
Copy
import { useState, useEffect } from 'react'
import './App.css'
interface Task {
id: string
title: string
completed: boolean
createdAt: string
}
function App() {
const [tasks, setTasks] = useState<Task[]>([])
const [newTitle, setNewTitle] = useState('')
const [loading, setLoading] = useState(true)
// Fetch initial tasks
useEffect(() => {
fetch('/api/tasks')
.then(res => res.json())
.then(data => {
setTasks(data)
setLoading(false)
})
}, [])
// Subscribe to real-time updates
useEffect(() => {
const eventSource = new EventSource('/api/events')
eventSource.onmessage = (event) => {
const updatedTask: Task = JSON.parse(event.data)
setTasks(prev => {
const index = prev.findIndex(t => t.id === updatedTask.id)
if (index >= 0) {
const newTasks = [...prev]
newTasks[index] = updatedTask
return newTasks
}
return [...prev, updatedTask]
})
}
return () => eventSource.close()
}, [])
const addTask = async (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle }),
})
if (res.ok) {
setNewTitle('')
// Task will be added via SSE
}
}
const toggleTask = async (id: string) => {
await fetch(`/api/tasks/${id}/toggle`, { method: 'PUT' })
// Update will come via SSE
}
const deleteTask = async (id: string) => {
await fetch(`/api/tasks/${id}`, { method: 'DELETE' })
setTasks(prev => prev.filter(t => t.id !== id))
}
if (loading) {
return <div className="loading">Loading...</div>
}
return (
<div className="app">
<header>
<h1>Task Manager</h1>
<p>Built with Mizu + React</p>
</header>
<form onSubmit={addTask} className="add-form">
<input
type="text"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
<ul className="task-list">
{tasks.map(task => (
<li key={task.id} className={task.completed ? 'completed' : ''}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span>{task.title}</span>
</label>
<button onClick={() => deleteTask(task.id)} className="delete">
×
</button>
</li>
))}
</ul>
{tasks.length === 0 && (
<p className="empty">No tasks yet. Add one above!</p>
)}
<footer>
{tasks.filter(t => !t.completed).length} tasks remaining
</footer>
</div>
)
}
export default App
web/src/App.css:
Copy
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.app {
max-width: 500px;
margin: 0 auto;
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
color: #333;
font-size: 2rem;
margin-bottom: 0.5rem;
}
header p {
color: #666;
}
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.add-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #eee;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.add-form input:focus {
outline: none;
border-color: #667eea;
}
.add-form button {
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.add-form button:hover {
background: #5a6fd6;
}
.task-list {
list-style: none;
}
.task-list li {
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
transition: background 0.2s;
}
.task-list li:hover {
background: #f9f9f9;
}
.task-list li.completed span {
text-decoration: line-through;
color: #999;
}
.task-list label {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.task-list input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.task-list .delete {
background: none;
border: none;
font-size: 1.5rem;
color: #ccc;
cursor: pointer;
padding: 0 0.5rem;
transition: color 0.2s;
}
.task-list .delete:hover {
color: #e74c3c;
}
.empty {
text-align: center;
color: #999;
padding: 2rem;
}
footer {
text-align: center;
color: #666;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.loading {
text-align: center;
padding: 4rem;
color: white;
font-size: 1.5rem;
}
web/src/index.css (remove default Vite styles):
Copy
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
Step 5: Development Mode
Run both servers:Copy
# Terminal 1: Start Go backend
go run main.go
# Terminal 2: Start Vite dev server
cd web && npm run dev
Step 6: Production Build
Build everything:Copy
# Build frontend
cd web && npm run build && cd ..
# Build Go binary with embedded frontend
go build -o taskmanager .
Copy
./taskmanager
Step 7: Create a Makefile
CreateMakefile:
Copy
.PHONY: dev build clean
dev:
@echo "Starting development servers..."
@echo "Backend: http://localhost:3000"
@echo "Frontend: http://localhost:5173"
@cd web && npm run dev &
@ENV=development go run main.go
build:
cd web && npm run build
go build -o taskmanager .
clean:
rm -f taskmanager
rm -rf web/dist
What You Learned
- Combining Go backend with React frontend
- Development proxy with Vite
- Embedding frontend in Go binary
- Real-time updates with SSE
- Single-binary deployment
Project Structure
Copy
taskmanager/
├── main.go
├── go.mod
├── go.sum
├── Makefile
└── web/
├── src/
│ ├── App.tsx
│ ├── App.css
│ └── main.tsx
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts