
Why We Dumped Swagger UI for Scalar in Our Go Backend
A few weeks ago, at 4:45 PM on a Friday (because of course it was Friday), our frontend engineer dropped a message on Slack:
“Hey, why is the API playground on staging trying to send requests to localhost:8080? Did someone leave their dev server running on a laptop somewhere?"
Silence in the channel…
That single innocent question exposed a dirty secret: our API documentation was broken, outdated, and living in an alternate universe.
Like almost every Go developer over the last five years, we had faithfully followed the holy Go ritual:
- Write magic comments above handler functions like a medieval monk copying manuscripts (// @param user_id path string true "User ID").
- Run swag init and pray we didn't miss a comma.
- Mount Swagger UI on /swagger/*.
- Congratulate ourselves on being “documentation champions.”
It worked initially. But as our backend grew, that ritual turned into a comedy of errors.
The Three Pain Points That Drove Us Crazy
Before we threw Swagger UI out the window, we were dealing with three daily frustrations:
1. The “Doc Drift” Trap
Your annotations live as comments above HTTP handlers. You refactor a Go struct inside a domain package, but forget to update the 15 lines of magic comments floating above your handler function.
Result? Your docs claim the endpoint returns a plain string, but your Go code returns a nested JSON object. Your frontend team spends two hours debugging phantom errors while you sip coffee completely unaware.
2. The 2015-Era UI Experience
Let’s be honest: Swagger UI looks like it was designed when Internet Explorer 8 was still a thing. Testing JWT-authenticated endpoints, reading nested JSON error schemas, and searching across 30+ endpoints felt like navigating a government tax portal.
3. The Hardcoded Host Nightmare
Our generated spec had host: "localhost:8080" baked inside it. When QA opened the docs on staging (https://staging-api.example.com/docs) to test a release, every click on "Try it out" silently fired HTTP calls to localhost:8080.
Cue 30 minutes of classic “It works on my machine!” debates.
Enter Scalar: Single Source of Truth + Zero Build Overhead
We decided we didn’t want another 50MB npm build step in our CI pipeline, nor did we want complex static asset embedding in our Go binary. We wanted something dead simple, ridiculously sleek, and impossible to break.
That’s when we dumped Swagger UI and switched to Scalar.
Instead of scattering annotations across 40 Go files, we made a radical move: a single api/openapi/openapi.yaml file as the one true source of truth.
go-backend/
├── api/
│ └── openapi/
│ └── openapi.yaml <-- One True Source of Truth
├── internal/
│ └── delivery/
│ └── http/
│ ├── handler/
│ │ ├── docs.go <-- Serves Scalar HTML & openapi.yaml
│ │ └── docs_test.go <-- Contract & security tests
│ └── router.go <-- Environment guardrails
Scalar runs as a modern, zero-build web component (@scalar/api-reference) straight from a CDN. No Node.js, no Webpack, no build step.
All our Go backend needs to do is serve a 15-line HTML shell and the raw YAML file in internal/delivery/http/handler/docs.go:
package handler
...
type DocsHandler struct {
openAPIPath string
}
func NewDocsHandler(openAPIPath string) *DocsHandler {
if openAPIPath == "" {
openAPIPath = "api/openapi/openapi.yaml"
}
return &DocsHandler{openAPIPath: openAPIPath}
}
// RenderUI serves the Scalar API reference HTML shell
func (h *DocsHandler) RenderUI(c fiber.Ctx) error {
html := `<!doctype html>
<html>
<head>
<title>API Reference</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<!-- Scalar CDN script integration -->
<` + `script id="api-reference" data-url="/docs/openapi.yaml"></` + `script>
<` + `script src="<https://cdn.jsdelivr.net/npm/@scalar/api-reference>"></` + `script>
</body>
</html>`
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
return c.SendString(html)
}
// ServeSpec serves the raw OpenAPI YAML file
func (h *DocsHandler) ServeSpec(c fiber.Ctx) error {
content, err := os.ReadFile(h.openAPIPath)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "OpenAPI specification not found",
})
}
c.Set(fiber.HeaderContentType, "application/yaml")
return c.Send(content)
}
Notice data-url="/docs/openapi.yaml": the browser automatically fetches the contract relative to whatever host served the page.
The Secret Sauce: Relative Routing (url: /api)
The real magic that cured our staging headaches was replacing full domain URLs in openapi.yaml with a single relative path:
openapi: 3.0.3
info:
title: Go Service API
version: 1.0.0
servers:
- url: /api
description: Same-origin API server
By defining url: /api, Scalar's HTTP client attaches test requests directly to whichever domain is currently in the address bar:
No environment variables, no YAML templating hackery, and zero CORS errors on staging. It just works because the browser already knows where it is!
Keeping Production Locked Down (404 for Unexpected Visitors)
As much as we love interactive API playgrounds, we don’t want bored internet scanners getting a free interactive map of our internal endpoints in production.
In internal/delivery/http/router.go, we locked the /docs route behind an environment check:
func RegisterRoutes(app *fiber.App, cfg *config.Config, docsHandler *handler.DocsHandler) {
api := app.Group("/api")
// ... register auth, user, referee routes ...
// Security Guardrail: Mount /docs only in dev, test, and staging.
// In production, /docs returns a polite 404 Not Found.
if !isProduction(cfg.AppEnv) {
docsGroup := app.Group("/docs")
docsGroup.Get("", docsHandler.RenderUI)
docsGroup.Get("/openapi.yaml", docsHandler.ServeSpec)
}
}
func isProduction(env string) bool {
return env == "production" || env == "prod"
}
If an automated vulnerability scanner hits GET /docs on production, Fiber responds with a standard 404 Not Found. Move along, nothing to see here!
Verifying It All in 5 Milliseconds
To make sure nobody accidentally breaks our routing or leaks docs to production during a late-night refactor, we wrote a quick unit test (internal/delivery/http/handler/docs_test.go):
func TestDocsOpenAPISpec_UsesRelativeAPIServer(t *testing.T) {
app := fiber.New()
docsHandler := handler.NewDocsHandler("../../../../api/openapi/openapi.yaml")
app.Get("/docs/openapi.yaml", docsHandler.ServeSpec)
req := httptest.NewRequest(http.MethodGet, "/docs/openapi.yaml", nil)
resp, err := app.Test(req)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
}
buf := make([]byte, 1024)
n, _ := resp.Body.Read(buf)
content := string(buf[:n])
if !strings.Contains(content, "url: /api") {
t.Errorf("expected openapi spec to define relative server 'url: /api', got:\n%s", content)
}
}
$ go test ./internal/delivery/http/handler -v -run "TestDocs"
=== RUN TestDocsRoutes_NonProduction_Serves200
--- PASS: TestDocsRoutes_NonProduction_Serves200 (0.00s)
=== RUN TestDocsOpenAPISpec_UsesRelativeAPIServer
--- PASS: TestDocsOpenAPISpec_UsesRelativeAPIServer (0.00s)
PASS
ok go-backend/internal/delivery/http/handler 0.004s
Moral of the Story
Switching from Swagger UI to Scalar delivered three huge wins for our team:
- Zero Doc Drift : PRs review openapi.yaml directly—no more hidden annotation bugs inside Go handler comments.
- Zero Build Overhead : Scalar runs in the browser via CDN web component. No npm dependencies or binary bloat.
- Happier Frontend Devs : Staging playgrounds actually work, and the UI feels like a modern developer tool built in this decade.
If your team is still wrestling with broken Swagger playgrounds or 2015-era UI, dumping Swagger for Scalar is easily one of the highest-ROI Developer Experience (DX) wins you can score in an afternoon.

Top comments (0)