GoLinks — a minimalist URL shortener inspired by Google's internal golinks system.
Stack
- Backend: Go (standard library +
gorilla/mux), SQLite viamattn/go-sqlite3, Clean Architecture underinternal/. - Frontend: React 18 + TypeScript + Vite, Tailwind CSS with shadcn/ui primitives, TanStack Query for server state, react-hook-form + zod for forms, react-router-dom for client routing,
@mdx-js/mdxfor runtime MDX compilation. - Distribution: Single Go binary. The Vite build output (
web/frontend/dist/) is embedded via//go:embed all:distinweb/frontend/embed.go— noweb/directory exists at runtime.
See README.md for user-facing details and commands.
cmd/server/ Application entrypoint
internal/
├── config/ Env / dotenv configuration
├── database/ SQLite connection + migrations
├── domain/ Models (json + db tagged) + sentinel errors
├── handlers/ HTTP handlers (JSON API + redirect) + auth middleware
├── logger/ Structured logger
├── repository/ Data access layer (shortcuts, queries, users, sessions)
└── service/ Business logic (links, documents, auth)
web/frontend/ Vite + React SPA
├── src/components/ App components
├── src/components/ui/ shadcn primitives
├── src/pages/ Route-level pages
├── src/lib/ api.ts, mdx.tsx, utils.ts
├── public/ Static assets (favicon)
├── dist/ Build output, embedded into the Go binary
└── embed.go go:embed bridge + SPA fallback handler
docs/ User-uploaded .md / .mdx, read from disk at runtime
- Clean Architecture layering: handlers → service → repository → database. Handlers never touch the DB directly.
- Interface-driven: handlers depend on service interfaces declared in the handlers package; services depend on repository interfaces.
- Composition over inheritance; small, purpose-built interfaces.
- No global state — wire dependencies through constructors.
- Short, single-purpose functions. Wrap errors with
fmt.Errorf("context: %w", err). - Pass
context.Contextthrough the call chain; service methods takectxas the first arg. - Defer closing every resource you open (rows, files, response bodies).
- Validate input at request boundaries — never trust query strings, form values, or JSON bodies.
- One JSON shape per endpoint, encoded via the
writeJSON(w, status, body)helper. Don't hand-roll JSON in each handler. - The golink resolver (
/query/{path:.*}) MUST stay a server-side 302 — it's the contract that lets browser search-engine integrations work. Never replace it with a client-side redirect. It stays public unconditionally. /api/*and/auth/*are reserved for JSON. The catch-all SPA handler refuses these prefixes defensively as a backstop against route-registration regressions./update/(form-encoded) is a legacy create alias. It now requires auth likePOST /api/links— closing the old unauthenticated write path.
- Email + password, bcrypt-hashed (
golang.org/x/crypto/bcrypt). Sessions are server-side: an opaque random token lives in anHttpOnly,SameSite=Lax,Secure(prod) cookie, and only its SHA-256 hash is stored in thesessionstable. No JWT, no signing secret. - Bootstrap: the first user created on an empty DB (
POST /auth/setup) becomesadmin. Registration is closed afterward; admins create users viaPOST /api/users. - Gating model: a global
Authenticatemiddleware loads the optional user into request context (anonymous if no/invalid cookie — it never rejects, so reads stay public). Two subrouters carry guards:RequireAuth(401 if anonymous) andRequireAdmin(401/403). Wire write routes onto these incmd/server/main.go; never gate inside handler bodies. - Reads public, writes authed. Docs upload/delete are admin-only (runtime MDX evaluates JSX in the browser). Get the current user with
handlers.UserFromContext(ctx); it returns nil when anonymous. - Map auth failures to status codes via the
domain.Err*sentinels (seeinternal/domain/errors.go) — never leak whether an email exists.
- Table-driven unit tests with parallel execution.
- Mock external interfaces with handwritten mocks colocated in
_test.gofiles. ThemockLinkServiceininternal/handlers/handler_test.gois the reference pattern. go test ./... -racemust pass before merging.
gofmt -s,goimports -local golinks,golangci-lint run --timeout=3m. Wired intomake fmt,make fix,make lint.make ciis the full gate: frontend install + build, lint, test, build.
- The SPA owns ALL UI. The Go server returns JSON or 302; it never renders HTML except the embedded
index.html. - Components stay presentational. TanStack Query owns server state — don't reinvent caching with
useEffect+ local state for fetched data. - Keep components small and colocated by feature. shadcn primitives live under
components/ui/; app components live one level up. - Don't introduce a global state library (Redux, Zustand). TanStack Query + URL params + component state cover the surface.
- Use Tailwind utility classes. The only bespoke CSS file is
src/index.css, which holds theme tokens and prose overrides. - The palette is a Rams-inspired set ported to shadcn HSL CSS variables.
--primaryis Braun orange — keep it as the distinctive accent. Don't hard-code hex values; reference tokens viabg-primary,text-foreground,border-border, etc. - Long-form rendered documents use
@tailwindcss/typographyproseclasses with overrides inindex.css. - Border radius flows from
--radius(4px). Don't introduce arbitrary radius values.
- All HTTP calls go through
src/lib/api.ts. Never callfetchdirectly from a component. - Each endpoint gets a typed wrapper returning a typed response. Add new endpoints there with explicit types.
- Errors throw
ApiError; query/mutationonErrorhandlers display them viasonnertoast.
- Use
react-hook-form+zod. Define a zod schema, infer the form type, wirezodResolver. TheLinkFormcomponent is the reference.
react-router-domv6. Routes live insrc/App.tsx. Page components live undersrc/pages/and never import each other.- Deep-linkable URLs. State that can be in the URL (filters, search, current tab) should be — use
useSearchParams.
tsc -bmust pass.tsconfig.app.jsonhasstrict,noUnusedLocals,noUnusedParametersenabled.- No
anyunless interfacing with an unyielding library type. Preferunknown+ narrowing. - Path alias
@/*maps tosrc/*.
- Real MDX compilation happens client-side via
@mdx-js/mdx'sevaluate()insrc/lib/mdx.tsx. The server returns raw source from/api/docs/{filename}. - Components exposed to MDX are explicitly enumerated in the
mdxComponentsmap. Adding a new component for authors means: import it, add it to that map. No magic auto-discovery. remark-gfmprovides tables, strikethrough, task lists;rehype-highlightprovides syntax highlighting (GitHub theme).- Security: runtime MDX evaluates JSX as code in the viewer's browser, so
POST /api/docs(andDELETE) are admin-only (gated via theRequireAdminsubrouter). This closes the former unauthenticated-upload hole. Reads stay public.
make devruns the Go server (withairif installed) and the Vite dev server (:5173) concurrently. Vite proxies/api,/query, and/authto the backend (default:8080; override withVITE_PROXY_TARGETinweb/frontend/.env.local).- Frontend-only:
make frontend-dev. Backend-only:go run ./cmd/server(the committed stubdist/index.htmlwill serve a "build the frontend" page until you runmake frontend-build).
make buildrunsnpm run buildthengo build. The Vite output is embedded via//go:embed all:dist.- A stub
dist/index.htmlis committed sogit clone && go buildproduces a runnable binary even before the frontend has been built.
- Three-stage
Dockerfile:node:20-alpinebuilds the SPA →golang:1.21-alpinebuilds the binary with the SPA embedded →alpine:3.18runtime with only the binary,docs/, and the data volume. - The runtime image must have no
web/directory. If you see one, the Dockerfile has regressed.
- Single artifact. One Go binary serves API, redirects, and SPA. Don't introduce a separate frontend service.
- Trust the boundary. Validate at request edges (
internal/handlers/*, frontendlib/api.ts). Inside the boundary, types are honest. - Reuse over rewrite. Before adding a component or helper, check
components/ui/,lib/utils.ts,lib/api.ts, and the service layer. - Boring tech. Stick to the existing stack unless there's a concrete reason to add a dependency.
- Readable, testable, documented. Every exported Go function gets a GoDoc-style comment. Every JSON endpoint has at least a smoke test.
These are good practices to apply if and when the project grows into them — don't shoehorn them into the current codebase.
- OpenTelemetry tracing, metrics, and structured logs. Adopt once there's an actual observability backend to ship to (Collector, Jaeger, Prometheus, etc.).
- Distributed rate-limiting (Redis-backed). Single-instance deployment doesn't need it.
- CSRF tokens. Writes currently rely on
SameSite=Laxcookies + JSON-only bodies, which is adequate for a same-origin SPA. Add token-based CSRF protection before any cross-origin or multi-tenant public deployment. - OAuth / SSO. The auth layer is email+password today; the service is structured so an OAuth provider can be added as an alternate login path.
- Retries / circuit breakers / backoff. Add when external dependencies appear; today there are none beyond SQLite.