← Back to writing

Minimal Go + Vue 3 stack template for solo SaaS

A minimal stack that folds a Go API and a Vue 3 SPA into one binary — directory layout, cookie auth, and deploy. The skeleton I reuse for every new derived SaaS.

Every new derived SaaS, I rebuild the same skeleton

When I started building the SaaS layer (gpdf-app, gpdf-cloud) on top of gpdf inside nadai, the thing I noticed was that the first day is wasted every time. Stand up a Go HTTP server, spin up Vue 3 with Vite, wire in routing and auth, build the deploy path. The product is different each time, but this skeleton is almost always the same — and rebuilding it from memory means every project starts with the same hour of yak-shaving instead of the actual idea.

So I froze it as a minimal template. Most of the derived products I run in parallel under nadai start from this shape. Three rules govern it: don't add a framework, don't add dependencies, ship one single binary at the end. None of these are dogma for its own sake — each one removes a class of future maintenance work, and at solo-developer scale future maintenance is the scarce resource, not lines of code. Here's the layout I actually use, and why each piece is shaped the way it is.

Directory layout

myapp/
├── main.go
├── internal/
│   ├── server/        # http.Handler assembly, middleware
│   ├── auth/          # cookie session
│   └── store/         # DB access (usually gsql)
├── web/               # a standalone Vite project
│   └── src/{main.ts, App.vue, router.ts, pages/}
└── web/dist/          # vite build output, pulled in via go:embed

Go and Vue live in one repo, and Go embeds web/dist at compile time. There's no monorepo tooling — no Nx, no Turborepo, no workspace orchestration. web/ is just a self-contained Vite project that happens to sit next to the Go module, and the only contract between them is the web/dist directory. The build is a few lines of Makefile (shown at the end). Keeping it this loose means I can throw away the front end and rewrite it in something else later without touching the Go side, or vice versa.

The Go side: the standard library is enough

No Echo, no Gin, no chi. The honest reason a third-party router used to be worth it was path parameters and method matching — and since Go 1.22, net/http.ServeMux handles both (GET /api/users/{id}), so routing at solo-developer scale is fully covered by the standard library. The features the big routers still add on top (middleware chains, route groups, binding helpers) are things you can write in a few dozen lines yourself, scoped exactly to what this product needs. Dropping the router makes the dependency tree one layer shallower, and a shallower tree is one fewer thing to audit when a CVE lands or a company runs a security review on your project. This is the same line of reasoning as "keep the audit surface small" from why single-binary distribution wins for solo developers.

//go:embed all:web/dist
var assets embed.FS

func main() {
	dist, _ := fs.Sub(assets, "web/dist")

	mux := http.NewServeMux()
	server.Mount(mux)                              // register /api/*
	mux.Handle("/", server.SPA(http.FS(dist)))     // everything else goes to the SPA

	log.Fatal(http.ListenAndServe(":8080", mux))
}

server.SPA is a handler that does one thing: serve the file if it exists, otherwise return index.html. Wrap http.FileServer, and when fsys.Open fails, rewrite r.URL.Path = "/". That's it — about twenty lines. When someone hits /dashboard directly under Vue Router's history mode, the server returns index.html, the bundled Vue app boots, and the client-side router resolves the path. Without this fallback you get a 404 on every deep link and bookmark, which is the single most common "why doesn't refresh work" bug in SPA deployments.

Middleware starts as just three things: request log, panic recover, session read. No CORS layer, because everything is served same-origin — a side effect of deciding not to put the front and the API on separate domains. That one decision deletes a whole category of preflight headaches and Access-Control-* debugging. Rate limiting and monitoring get added once traffic actually shows up; there's no point hardening against load that may never arrive. The DB is usually the type-safe gsql, which is Pure Go and therefore rides along in the single binary, so internal/store adds no runtime dependency either.

Vue 3 and auth: cookie sessions are enough

Front-end dependencies are vue, vue-router, pinia, vite — that's all. No UI library (Vuetify, Element Plus). A component library is the canonical "dependency that's hard to peel off later," and it's too heavy for a thin early product. vite.config.ts proxies /api to Go only in dev:

export default defineConfig({
  plugins: [vue()],
  build: { outDir: 'dist' },
  server: { proxy: { '/api': 'http://localhost:8080' } },
})

npm run dev puts Vite on :5173 and Go on :8080; the browser talks to :5173, and only /api calls get forwarded to Go. In production Go serves web/dist directly, so this proxy block is dev-only and never ships. The API client is a one-file fetch wrapper — no axios — and it does little more than prepend /api, set credentials: 'include' implicitly via same-origin, and throw on non-2xx. One useAuthStore in pinia holds login state and the current user; anything more than that I add when a screen actually needs it.

For auth I don't use JWT. I put an opaque session ID in an HttpOnly + Secure + SameSite=Lax cookie and look it up server-side — a SQLite table at first, Redis once it grows. The reason is simple: I don't want to carry JWT's revocation problem — the token stays valid until expiry even after logout, even after you disable the account — at the solo-developer stage, where I'm not going to build a token blacklist on day one. With a session table, logout and "kill all sessions for this user" are both one DELETE. Middleware stuffs the user into r.Context(), and each API pulls it back with auth.From(ctx), so handlers never touch cookies directly. On the Vue side, the app hits /api/me once at startup: 200 means logged in, 401 redirects to the login screen. That alone gives you "an SPA that keeps its state across reloads" without any token juggling in localStorage.

Deploy: build, scp, done

build:
	cd web && npm ci && npm run build
	go build -o myapp .

deploy: build
	scp myapp server:/opt/myapp/myapp && ssh server systemctl restart myapp

By the time go build runs, web/dist is already embedded, so the only thing you put on the server is the single myapp file. Write one systemd unit, terminate TLS with Caddy, point it at :8080. For Docker it's FROM gcr.io/distroless/static + COPY myapp / and a few-MB image with no shell, no package manager, nothing to patch. There's no Node runtime on the server, no node_modules, no separate static-file host or CDN to configure — the binary is the static-file host. This lightness of deploy is the precondition for running several derived products in parallel under nadai: each product's ops fits in "one file and one process," so the inventory I carry in my head doesn't blow up, and adding the Nth product doesn't multiply the operational surface. I wrote about the whole picture in nadai ecosystem: running multiple micro SaaS as one person.

What this isn't

This stack is not for "a marketing site that needs SEO." It's an SPA, so first paint depends on JS, crawlers see an empty shell unless they execute scripts, and generating per-page Open Graph tags means rendering server-side anyway — at which point you've half-built Nuxt. If a landing page, docs, or blog matters for the product, do that separately with SSR or SSG in Nuxt or Astro and let it own the public, indexable surface. nadai itself runs on Nuxt and is a different track from this SPA template — the app body (behind the login) is an SPA where SEO is irrelevant, the entrance is SSR where it's the whole point. That's the split, and trying to make one stack do both jobs is how you end up unhappy with both.

Plenty is left out: migrations, email, payments, background jobs. Whether you need them varies by product, and the moment you add them it's no longer "minimal." Payments in particular I wrestle with every time — I'd like a thin wrapper around calling Stripe from Go that I could reuse, but the billing model differs too much per product, and I haven't landed on "the" shape yet. I'll probably write it from scratch again on the next one.

Next steps

Once the skeleton exists, the next move is to focus on the hypothesis that product actually needs to validate. What to check and where, I laid out in pre-release validation strategy for solo SaaS.

Freeze the template too hard and it becomes a leash. For now I keep it to one repo-template skeleton and rebuild it a little each time, letting each product nudge the defaults. How long to keep the standard-library-only rule, how far to push SQLite before reaching for Postgres — those are still moving for me, and I'd rather re-decide them per product than bake yesterday's answer into a generator.

© 2026 nadai · JapanSet in Instrument Serif · Inter