This document describes how GoLinks is wired end-to-end: the layered Go backend, the React/Vite SPA, the way they're glued together into a single binary, and exactly what each HTTP endpoint does.
README.md covers what the app is and how to use it. CLAUDE.md captures conventions for contributors. This file is the implementation reference — read it when you need to understand or change behaviour.
┌────────────────────────────────────────────────────────────────────┐
│ golinks (single binary) │
│ │
│ cmd/server/main.go │
│ └── gorilla/mux router │
│ ├── /query/{path:.*} → 302 redirect │
│ ├── /api/links (GET/POST) → JSON │
│ ├── /update/ (POST) → JSON (legacy form) │
│ ├── /api/docs (GET/POST) → JSON │
│ ├── /api/docs/{file} (GET/DEL) → JSON │
│ └── /* (catch-all) → embedded SPA / index.html│
│ │
│ internal/ │
│ handlers ──▶ service ──▶ repository ──▶ database (SQLite) │
│ ▲ │ │
│ └── domain models (json + db tagged) ◀────┘ │
│ │
│ web/frontend/ │
│ React 18 + TS + Vite + Tailwind + shadcn/ui │
│ TanStack Query · react-hook-form+zod · @mdx-js/mdx │
│ └── dist/ ◀── //go:embed all:dist (web/frontend/embed.go) │
│ │
│ docs/ ── on-disk markdown/MDX (read at runtime) │
│ data/ ── SQLite database file │
└────────────────────────────────────────────────────────────────────┘
One process. Two TCP listeners only in development (:8080 Go, :5173 Vite proxying back). In production, one listener: :8080.
cmd/server/main.go Entrypoint: config → DB → repos → services → handlers → router
internal/
├── config/config.go Env / .env loading; Config struct
├── database/sqlite.go sql.DB + schema migrations
├── domain/models.go Shortcut, Query, KeywordInfo, PopularQuery, LinkRequest (json+db tags)
├── handlers/handler.go Redirect + /api/links + legacy /update/
├── handlers/document.go /api/docs CRUD
├── handlers/handler_test.go Table-driven tests with mock services
├── logger/logger.go slog wrapper
├── repository/shortcut.go SQL for linktable
├── repository/query.go SQL for queries (analytics)
└── service/
├── link.go LinkService: GetLink, UpdateLink, GetRecentQueries, GetAllKeywords
└── document.go DocumentService: GetDocument, SaveDocument, ListDocuments, DeleteDocument
web/frontend/
├── embed.go //go:embed all:dist + SPA fallback handler
├── vite.config.ts Dev server proxy: /api, /query → :8080
├── src/
│ ├── App.tsx Routes
│ ├── main.tsx Entry: QueryClientProvider, BrowserRouter, Toaster
│ ├── index.css Tailwind + Rams tokens → shadcn HSL vars + prose overrides
│ ├── components/ Navbar, LinkForm, KeywordTable, RecentQueries, DocUploader, MDXRenderer
│ ├── components/ui/ shadcn primitives (button, input, card, table, alert, dialog, tabs, sonner, skeleton)
│ ├── pages/ Home, Setup, DocsList, Doc, NotFound
│ └── lib/
│ ├── api.ts Typed fetch wrappers + ApiError
│ ├── mdx.tsx @mdx-js/mdx evaluate() + component map
│ └── utils.ts cn() helper
docs/ Sample markdown/MDX (and uploaded user docs)
.zed/debug.json Zed debugger configs (Backend Go, Vite, Chrome)
Dockerfile Three-stage: node → go → alpine
Makefile dev / build / test / docker / lint
The backend follows Clean Architecture. Each layer depends only on the layer below it via interfaces.
Orchestrates startup in this order (main.go:24-73):
config.Load()— reads.env(optional) and env vars (PORT,DATABASE_PATH,BASE_URL,ENVIRONMENT,LOG_LEVEL).logger.Initialize(cfg.Logging)— structured slog logger.database.NewSQLiteDB(cfg.DatabasePath)+database.Migrate(db)— open connection, run idempotent migrations.- Construct repositories (
shortcutRepo,queryRepo). - Construct services (
linkService,docService). - Construct handlers (
handler,docHandler). - Build router; register routes; mount the embedded SPA at the catch-all.
- Start HTTP server in a goroutine; block on
SIGINT/SIGTERM; graceful shutdown with 30 s timeout.
The thinnest layer: parse requests, call services, encode responses. No business logic. Errors of type service.InvalidQueryError are mapped to HTTP 400; everything else is 500.
writeJSON(w, status, body) in internal/handlers/document.go:124 is the canonical JSON encoder used across both files.
The brain. LinkService implements golink resolution semantics including space-splitting and aliasing (more under /query/ below). DocumentService does file I/O and frontmatter parsing.
Services depend on repository interfaces (ShortcutRepository, QueryRepository declared in service/link.go:15-25), not concrete types — that's how mockLinkService in handler_test.go is possible.
Plain database/sql queries. No ORM. Each method takes a context.Context and returns domain types defined in internal/domain/.
Three tables (internal/database/sqlite.go:28-46):
- linktable —
(id, word, link, user, created_at). - queries —
(query_id, word_id → linktable.id, created_at). - tags —
(id, word_id → linktable.id, tag)— defined but currently unused.
Indexes on linktable.word, queries.word_id, queries.created_at. Foreign keys are enabled via the connection string (?_foreign_keys=on).
POGOs (Plain Old Go Objects) with json: and db: tags. Used unchanged as both DB row targets and API response bodies — so a schema rename ripples through both layers automatically.
Compile-time //go:embed all:dist pulls the Vite build output into the binary. The exported Handler(reservedPrefixes...) returns an http.Handler that:
- Refuses non-GET/HEAD with 405.
- Refuses any path starting with
api/orquery/(defense in depth — those routes match earlier, but if registration order ever changes, the SPA must not shadow them). - Serves real files from the embedded FS when they exist (
/assets/*,/favicon.ico). - Falls back to
index.htmlwithCache-Control: no-cachefor everything else, so React Router can take over on hard refreshes of/setup,/docs/foo, etc.
There's also a brokenHandler that returns 503 with a helpful message if the embedded dist/ is missing index.html. Combined with the committed stub dist/index.html, this guarantees git clone && go build always produces a runnable binary.
src/main.tsx mounts <App /> inside QueryClientProvider + BrowserRouter and renders <Toaster> for sonner.
src/App.tsx is the route table:
| Path | Component | Notes |
|---|---|---|
/ |
HomePage |
Form + keyword list + recent queries |
/homepage |
HomePage |
Legacy alias from the template era |
/setup |
SetupPage |
Per-browser instructions in shadcn Tabs |
/docs |
DocsListPage |
List + upload + delete |
/docs/:filename |
DocPage |
Fetches raw source, runtime-compiles MDX |
* |
NotFoundPage |
Custom 404 with shortcuts back to home/setup |
- Server state → TanStack Query. Every fetch goes through a
useQuery(read) oruseMutation(write). Mutations callqueryClient.invalidateQueries(['links'])etc. on success to refresh stale views. - URL state →
useSearchParams. The?missing=fooquery param after a failed redirect is read inHomePageand shown as a toast, then cleared. - Form state →
react-hook-form+zod.LinkFormis the canonical example. - Local UI state →
useState. Component-scoped, never lifted into a global store.
src/lib/api.ts exports an api object with one function per endpoint. Each is typed end-to-end:
api.listLinks() : Promise<LinksResponse>
api.createLink({word,link}): Promise<{success: true}>
api.listDocs() : Promise<{documents: DocumentInfo[]}>
api.getDoc(filename) : Promise<DocumentSource>
api.uploadDoc(file: File) : Promise<{success: true; filename; url}>
api.deleteDoc(filename) : Promise<{success: true}>Non-2xx responses throw ApiError carrying the body text + status code.
src/index.css:
- Imports
@fontsource/inter(300/400/500/600),@fontsource/jetbrains-mono(400/500), andhighlight.js/styles/github.css. @tailwind base/components/utilities.- Defines shadcn HSL CSS variables under
:root:--background,--foreground,--primary(Braun orange),--accent(functional blue),--destructive,--muted, etc. These map onto Dieter Rams-inspired tokens — see comments in the file for the original hex values. - Prose overrides under
@layer basemake MDX-rendered docs match the rest of the UI (orange-on-hover links, mono code blocks, etc.).
tailwind.config.ts extends Tailwind with the shadcn token mapping (bg-primary → hsl(var(--primary))).
Doc page mounts
└── api.getDoc(filename) → { source, type, metadata }
└── <MDXRenderer source={source} />
└── compileMDX(source) // src/lib/mdx.tsx
└── @mdx-js/mdx evaluate()
├── remark-gfm (tables, task lists, strikethrough)
├── rehype-highlight (syntax highlighting)
└── useMDXComponents → mdxComponents map
├── Alert, AlertTitle, AlertDescription
├── Card, CardHeader, CardTitle, CardDescription, CardContent
├── Button
├── Tabs, TabsList, TabsTrigger, TabsContent
└── table/thead/tbody/tr/th/td → shadcn Table primitives
The whole pipeline runs in the browser. The Go server never sees compiled HTML — it just serves the raw .md / .mdx source.
make dev runs the Go server (via air if installed, else go run) and the Vite dev server (:5173) concurrently. Vite's dev server has a proxy (vite.config.ts:14-17) that forwards /api/* and /query/* to http://localhost:8080, so the React app can call relative URLs and hit the Go backend without CORS gymnastics.
For breakpoint debugging, see .zed/debug.json — three configs: Backend (Go via Delve), Frontend dev server (Vite via Node), Frontend (Chrome).
make build runs:
npm ci && npm run buildinsideweb/frontend/→ producesweb/frontend/dist/.go build -o build/golinks ./cmd/server— the//go:embed all:distdirective inweb/frontend/embed.gopulls the dist into the binary.
Result: a single ~14 MB binary that needs only the docs/ directory and the SQLite db file at runtime.
Dockerfile is three-stage:
node:20-alpine— installsweb/frontend/package*.json, runsnpm ci, thennpm run build. Output:/app/web/frontend/dist/.golang:1.21-alpine— copies the dist over the top of any committed stub, runsCGO_ENABLED=1 go build(CGO needed for the SQLite driver).alpine:3.18— final runtime. Copies the binary and thedocs/directory. Noweb/in the runtime image. Drops to a non-rootgolinksuser. Exposes 8080. SQLite db lives in/app/data/.
The HEALTHCHECK hits http://localhost:8080/ every 30 s.
Routes are registered in internal/handlers/handler.go:46-58 (links + redirect) and internal/handlers/document.go:33-38 (docs), with the SPA catch-all wired in cmd/server/main.go:75-78. Match order matters — gorilla/mux matches in registration order.
The contract that justifies a server-side rendered handler. Browser search-engine integrations issue plain HTTP requests; they don't run JavaScript, so this MUST be a 302 from the Go server.
Flow (handler.go:RedirectHandler):
- Strip trailing slash from the captured
path. - Call
linkService.GetLink(ctx, path, ""). - On success →
302to the resolved URL. - On
InvalidQueryError→302to${BASE_URL}/?missing=<path>(the SPA picks up themissingparam and shows a toast). - On any other error →
500.
Resolution semantics (service/link.go:GetLink):
- Look up
wordinlinktable. If found:- Log a hit in
queries(best-effort; logging failure does not fail the request). - If the stored
linkis itself a keyword (nothttp(s)://...), recurse → enables aliases. - If the stored
linkcontains{*}, substitute thesearchTerm(URL-encoded). - Return the URL.
- Log a hit in
- If not found and
wordcontains spaces, peel the last token off and treat it as a search term (go google cats→ look upgoogle cats, fail, thengooglewith searchTermcats). - If still not found → return
InvalidQueryError.
handler.go:ListLinks. Returns everything the homepage needs in one call.
{
"keywords": [ { "word": "...", "link": "...", "created_at": "..." }, ... ],
"recent_queries":[ { "count": 5, "word": "...", "link": "..." }, ... ],
"base_url": "http://localhost:8080"
}keywords←linkService.GetAllKeywords()→ all rows fromlinktablefiltered to URL-shaped links (skipping aliases).recent_queries←linkService.GetRecentQueries()→ top 20 by count over the last 3 days, joined back tolinktablefor the URL.base_url←cfg.BaseURL, used by the SPA to display the search-engine URL on the home and setup pages.
handler.go:CreateLink. JSON body: {"word":"...","link":"..."}.
- Decode JSON; trim whitespace on both fields.
- Call
linkService.UpdateLink, which validates:- Non-empty
wordandlink. worddoes not end in/.linkstarts withhttp://orhttps://.link != word(no self-loops).
- Non-empty
- Insert a new row into
linktable. - Return
{"success": true}(200) or400with the validation message.
handler.go:UpdateLinkLegacy. Same semantics as POST /api/links, but accepts application/x-www-form-urlencoded (word=...&link=...) and returns plain text Link added successfully!. Kept so anyone who configured their browser against the pre-migration /update/ endpoint still works.
document.go:ListDocuments. Reads docs/, returns one entry per .md / .mdx file. For each file, peeks at the first lines to extract title and description from YAML frontmatter (service/document.go:peekFrontmatter); falls back to the filename without extension. Type is inferred from the extension.
{ "documents": [ { "title": "...", "description": "...", "type": "markdown|mdx", "path": "sample.md" } ] }document.go:GetDocument. Returns the full file contents (frontmatter included) plus parsed metadata.
{
"source": "---\ntitle: ...\n---\n# ...",
"type": "markdown|mdx",
"metadata": { "title": "...", "description": "...", "type": "...", "path": "...", "metadata": { ...full frontmatter map... } }
}If the URL omits the extension (/api/docs/sample), the handler tries .md first, then .mdx. 404 if neither exists. The client compiles MDX in the browser (web/frontend/src/lib/mdx.tsx), so the server never renders to HTML.
document.go:UploadDocument. multipart/form-data with field file.
- Parse multipart form (10 MB limit).
- Reject files whose name doesn't end in
.mdor.mdx. - Sanitize the filename via
filepath.Base(no path traversal) and write intodocs/. - Return
{success, filename, message, url}(theurlis/docs/{filename}— a SPA route, not an API route).
.md before public deployment.
document.go:DeleteDocument. Sanitize via filepath.Base, os.Remove. Returns {success, message} or 500.
The catch-all (cmd/server/main.go) hands the request to frontend.Handler("api", "query"). Behaviour described in the web/frontend/embed.go section above.
Loaded by internal/config/config.go. All env vars optional; defaults shown.
| Variable | Default | Used by |
|---|---|---|
PORT |
8080 |
cmd/server/main.go server bind |
DATABASE_PATH |
golinks.db |
database.NewSQLiteDB |
BASE_URL |
http://localhost:8080 |
Returned in /api/links and used in 302 fallback target |
ENVIRONMENT |
development |
Logged on startup; reserved for future env-aware logic |
LOG_LEVEL |
info |
logger.Config.Level |
.env at the repo root is auto-loaded if present (godotenv). See env.example.
POST /api/docsis unauthenticated. Combined with runtime MDX compilation, this is the highest-risk gap in the codebase. Mitigations: gate behind a shared token, require auth, or restrict uploads to.md.- No CSRF protection on
POST /api/linksor/update/. Acceptable for a single-user tool on localhost; revisit if exposed publicly. getUserIDreturns"DefaultUser"unconditionally (handler.go:getUserID). Real auth never landed; theusercolumn inlinktableis a placeholder.
The pkg/, api/, configs/, and test/ directories from typical Go layouts are not present — this repo doesn't need them yet. Add them only when there's a concrete reason (shared utilities consumed by another binary, gRPC API definitions, etc.).
OpenTelemetry, distributed rate-limiting, retry/backoff for external calls, circuit breakers — none are wired today and none should be added speculatively. The current scope is a single-instance tool with one external dependency (SQLite).