My personal template for building a fullstack web application with my favorite tooling and technologies. This repo and its contents are subject to change as I discover better approaches.
- Backend: fuego for HTTP + automatic OpenAPI generation, fx for dependency injection, GORM + SQLite for persistence behind a repository layer.
- Frontend: SvelteKit (SPA via
adapter-static) with shadcn-svelte + Tailwind CSS for UI and a client generated by oazapfts. - Tooling: mise manages every dependency (Go, Node, pnpm, air, goreleaser) and every task.
The example API is a tiny notes CRUD, persisted to SQLite, so the moving parts are obvious. Rip it out and build your own.
The request path is layered: controller → service → repository → DB. HTTP
and validation live in the controller, business logic in the service, and every
database call in the repository.
Install mise. It provides the exact
Go, Node, pnpm, air, and goreleaser versions pinned in mise.toml:
mise install
mise run install:webmise run devThis runs both dev servers at once:
- Go API with live reload (air) on http://localhost:3000
- SvelteKit dev server on http://localhost:5173
Open the SvelteKit server. Its Vite config proxies /api to the Go server, so
the browser talks to a single origin in development just like it does in
production.
Run them separately with mise run dev:server and mise run dev:web.
This is the core of the template. The Go server is the single source of truth for the API shape.
- You write a fuego handler with typed request/response structs.
mise run openapiregeneratesserver/openapi.json, then regeneratesweb/src/lib/sdk/client.tsfrom it.- The frontend imports typed functions and types from
$lib/sdk.
Both openapi.json and client.ts are committed so the frontend builds without
running the server. CI fails if they drift out of sync. Always run
mise run openapi after changing a handler.
-
Model the data. Persisted types go in
server/internal/database/models/with GORM tags; register the model withdb.AutoMigrate(...)indatabase.go. Request-only types (e.g. a create body) go inserver/internal/dtos/. Struct tags drive the spec too:validate:"required"makes a field required,json:",omitempty"makes it optional. -
Add a repository method in
server/internal/repositories/: the only place that touches*gorm.DB. -
Add a service method in
server/internal/services/for the logic, calling the repository and translating errors (e.g.gorm.ErrRecordNotFound→ a domain error). -
Register a route in a controller under
server/internal/controllers/. Give every route a stableOptionOperationID: it becomes the client function name:fuego.Get(route, "/{id}", func(ctx fuego.ContextNoBody) (models.Note, error) { note, err := c.svc.Get(ctx.PathParam("id")) if errors.Is(err, services.ErrNoteNotFound) { return models.Note{}, fuego.NotFoundError{Detail: "note not found"} } return note, err }, fuego.OptionOperationID("getNote"))
-
Regenerate and use it:
mise run openapi
import { getNote } from '$lib/sdk'; const note = await getNote(id);
New controllers, services, and repositories are wired up through their Module
variables (controllers.go, services.go, repositories.go) which fx
assembles in main.go.
server/ Go backend
main.go fx application wiring
server.go fuego server + OpenAPI generation
static.go serves the built frontend in production
openapi.json generated spec (committed)
internal/
config/ environment configuration
controllers/ HTTP routes (one file per resource)
dtos/ request-only types
services/ business logic
repositories/ database access (GORM)
database/ connection, migration, seed, and models/
web/ SvelteKit frontend
components.json shadcn-svelte config
src/lib/sdk/ generated client (client.ts) + wrapper (index.ts)
src/lib/components/ui/ shadcn-svelte components
src/routes/ pages + app.css (Tailwind + theme)
docs/websockets.md how to add Socket.IO push updates
.github/workflows/ build, lint, release
Dockerfile production image (assembled by goreleaser)
mise.toml tools + tasks
| Task | What it does |
|---|---|
mise run dev |
Run both dev servers |
mise run openapi |
Regenerate the spec and the SDK client |
mise run build |
Build the frontend and the server binary |
mise run lint |
go vet + prettier + eslint |
mise run check |
Type-check the frontend |
mise run format |
Format Go and web code |
mise run test |
Run the Go tests |
mise tasks |
List every task |
mise run build produces:
server/tmp/app: the API binaryweb/build: the static frontend
The server serves the frontend when WEB_STATIC_PATH points at the build
output, so a single process handles both the app and the API:
cd server
WEB_STATIC_PATH=../web/build ./tmp/app| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the server listens on |
WEB_STATIC_PATH |
(unset) | Directory of the built frontend to serve; unset in dev |
DATA_DIR |
. |
Directory for application data |
GitHub Actions in .github/workflows/:
- build: builds and tests the server; builds the web bundle.
- lint: formatting,
go vet, eslint, type-check, and a check that the committed OpenAPI spec and SDK client are up to date. - release: on a
v*tag, goreleaser builds cross-platform binaries and a multi-arch Docker image pushed to GitHub Container Registry (ghcr.io/<owner>/<repo>).
- WebSockets: see docs/websockets.md for adding Socket.IO push updates.
- UI components: add more shadcn-svelte components with the CLI, e.g.
pnpm dlx shadcn-svelte@latest add dialog. They land insrc/lib/components/ui/. - Database: SQLite is the default so the template runs with zero setup. To
use Postgres instead, swap the driver in
database.go(gorm.io/driver/postgres) and pointNewDatabaseat a connection string; the repositories are unchanged.
-
Click Use this template on GitHub (or copy the files).
-
The Go module is named
app. To rename it, edit themoduleline inserver/go.modand update theapp/internal/...imports:cd server go mod edit -module yourname grep -rl '"app/internal' . | xargs sed -i '' 's#"app/internal#"yourname/internal#g'
-
The release workflow derives the image name from the repository, so no changes are needed there.