In this sequel to Go + HTMX To-Do, we keep the exact same Go backend and layer a Next.js frontend on top. This is a great way to learn API-driven UIs without re-writing your server.
Goals
- Reuse the Go server (no changes)
- Next.js App Router frontend with simple styles
- Client-side fetch for CRUD actions
1) Confirm Your Go Backend
Use the backend from the previous article. Ensure the following endpoints work:
GET /
renders HTML (for HTMX) — we’ll ignore this on the Next.js sidePOST /todos
(form:title
)GET /toggle?id=1
GET /delete?id=1
Optional: add a JSON endpoint if you prefer fetching todos in JSON:
mux.HandleFunc("/api/todos", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.todos)
})
2) Create Next.js Page
Create a new Next.js app (or add a route) that points to your Go server (running on http://localhost:8080
).
// app/todos/page.tsx
'use client'
import { useEffect, useState } from 'react'
type Todo = { id: number; title: string; done: boolean }
const API = 'http://localhost:8080'
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([])
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(false)
async function load() {
setLoading(true)
try {
const res = await fetch(`${API}/api/todos`, { cache: 'no-store' })
const data = await res.json()
setTodos(data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
async function addTodo(e: React.FormEvent) {
e.preventDefault()
const form = new FormData()
form.set('title', title)
await fetch(`${API}/todos`, { method: 'POST', body: form })
setTitle('')
await load()
}
async function toggle(id: number) {
await fetch(`${API}/toggle?id=${id}`)
await load()
}
async function del(id: number) {
await fetch(`${API}/delete?id=${id}`)
await load()
}
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-semibold mb-4">Next.js + Go To-Do</h1>
<form onSubmit={addTodo} className="flex gap-2 mb-4">
<input
value={title}
onChange={(e)=> setTitle(e.target.value)}
placeholder="Add a task"
className="flex-1 border rounded px-3 py-2"
/>
<button className="border rounded px-3 py-2">Add</button>
</form>
{loading ? (
<p>Loading…</p>
) : (
<ul>
{todos.length === 0 ? (
<li>No todos yet — add one!</li>
) : (
todos.map((t) => (
<li key={t.id} className="flex items-center justify-between py-1">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={t.done}
onChange={()=> toggle(t.id)}
/>
<span className={t.done ? 'line-through text-gray-500' : ''}>{t.title}</span>
</label>
<button onClick={()=> del(t.id)} className="text-red-600">✕</button>
</li>
))
)}
</ul>
)}
<p className="mt-6 text-sm text-gray-500">
Backend: <code>{API}</code>
</p>
</div>
)
}
If you don’t want to add a JSON endpoint, you can still scrape the server-rendered HTML or refactor the Go handlers to support both HTML and JSON via the Accept
header.
3) SEO & DX Notes
- Keep
/todos
crawlable by rendering a server component with initial data (optional). - Use your site’s dynamic OG image:
/og?title=Next.js%20%2B%20Go%20To-Do&subtitle=Same%20Backend%2C%20New%20UI
. - Add a canonical link if you cross-post tutorials.
4) Deploy
- Deploy the Go server (Fly.io, Render, Railway, Cloud Run)
- Deploy Next.js (Vercel)
- Configure CORS or proxy via Next.js
rewrites
if needed
Conclusion: you can keep a clean, simple Go backend and still enjoy a polished React UI when you need it. For the starter server, see the article above; for a production-ready sample (auth, DB, migrations), ping me and I’ll publish one next.