Dashboard from Your Go API (net/http, Gin, Chi)

A Go net/http handler encoding a dashboard struct beside the KPI widget it renders.

Define a struct, tag the fields, encode it from a handler. That's a dashboard widget.

No node_modules in your repo, no build step, no second service to deploy — the endpoint lives in the binary you already ship.

The reality

Go developers hit this problem earlier than most, because the ecosystem gives you no comfortable way out.

You have a fast service and no frontend story. So the options are: render html/template and hand-roll SVG, add a JavaScript chart library and now you have a JavaScript build in a Go repo, or stand up a separate frontend app and maintain two deployments to show six numbers.

Plenty of teams take the fourth option — put the numbers in Prometheus and look at them in Grafana. That's the right answer for infrastructure metrics, and this page is not going to argue otherwise. It gets awkward when the number isn't a metric: MRR, signups this week, the top ten accounts by usage. Modelling business facts as time-series counters to make them visible is a workaround, and it feels like one.

Writing a handler that returns the number is not a workaround. It's just a handler.

Your data stays in your Go services

dashboardbase never asks for database credentials and never stores your data. It calls your endpoint over HTTPS and renders the response.

Your sql.DB, your connection string, your service account, your internal gRPC clients — all of it stays inside your own infrastructure. Nothing on our side can reach your database, because nothing on our side knows how.

Your existing middleware chain still applies. If a query is already scoped by tenant from context, the dashboard handler is scoped the same way.

Define Go endpoints

The struct is the contract, which is the part that feels natural here. A KPI widget:

{
  "title": "Daily active users",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/dau-details"
    }
  ],
  "data": {
    "header": {
      "title": "2,543",
      "subtitle": "vs last month",
      "badge": {
        "text": "+12%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}
{
  "title": "Daily active users",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/dau-details"
    }
  ],
  "data": {
    "header": {
      "title": "2,543",
      "subtitle": "vs last month",
      "badge": {
        "text": "+12%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}
{
  "title": "Daily active users",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/dau-details"
    }
  ],
  "data": {
    "header": {
      "title": "2,543",
      "subtitle": "vs last month",
      "badge": {
        "text": "+12%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}

And the whole program:

package main

import (
	"encoding/json"
	"crypto/subtle"
	"fmt"
	"log"
	"net/http"
	"os"
)

type Badge struct {
	Text  string `json:"text"`
	Icon  string `json:"icon,omitempty"`
	Color string `json:"color,omitempty"`
}

type Header struct {
	Title    string `json:"title"`
	Subtitle string `json:"subtitle,omitempty"`
	Badge    *Badge `json:"badge,omitempty"`
}

type Action struct {
	Title string `json:"title"`
	Type  string `json:"type"`
	URL   string `json:"url"`
}

type KpiData struct {
	Header Header `json:"header"`
}

type Widget struct {
	Title   string   `json:"title"`
	Actions []Action `json:"actions,omitempty"`
	Data    KpiData  `json:"data"`
}

var secret = os.Getenv("DASHBOARDBASE_ENDPOINT_SECRET")

func requireSecret(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		got := r.Header.Get("x-dashboardbase-secret")
		if subtle.ConstantTimeCompare([]byte(got), []byte(secret)) != 1 {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func activeUsers(w http.ResponseWriter, r *http.Request) {
	count, delta := dailyActiveUsers(r.Context())

	widget := Widget{
		Title: "Daily active users",
		Actions: []Action{
			{Title: "View Details", Type: "link", URL: "https://example.com/dau-details"},
		},
		Data: KpiData{
			Header: Header{
				Title:    fmt.Sprintf("%d", count),
				Subtitle: "vs last month",
				Badge: &Badge{
					Text:  fmt.Sprintf("%+d%%", delta),
					Icon:  arrow(delta),
					Color: semanticColor(delta),
				},
			},
		},
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(widget)
}

func main() {
	// Fail at boot rather than serving an unprotected endpoint.
	if secret == "" {
		log.Fatal("DASHBOARDBASE_ENDPOINT_SECRET is not set")
	}

	http.HandleFunc("/dashboard/active-users", requireSecret(activeUsers))
	log.Fatal(http.ListenAndServe(":8080", nil))
}
package main

import (
	"encoding/json"
	"crypto/subtle"
	"fmt"
	"log"
	"net/http"
	"os"
)

type Badge struct {
	Text  string `json:"text"`
	Icon  string `json:"icon,omitempty"`
	Color string `json:"color,omitempty"`
}

type Header struct {
	Title    string `json:"title"`
	Subtitle string `json:"subtitle,omitempty"`
	Badge    *Badge `json:"badge,omitempty"`
}

type Action struct {
	Title string `json:"title"`
	Type  string `json:"type"`
	URL   string `json:"url"`
}

type KpiData struct {
	Header Header `json:"header"`
}

type Widget struct {
	Title   string   `json:"title"`
	Actions []Action `json:"actions,omitempty"`
	Data    KpiData  `json:"data"`
}

var secret = os.Getenv("DASHBOARDBASE_ENDPOINT_SECRET")

func requireSecret(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		got := r.Header.Get("x-dashboardbase-secret")
		if subtle.ConstantTimeCompare([]byte(got), []byte(secret)) != 1 {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func activeUsers(w http.ResponseWriter, r *http.Request) {
	count, delta := dailyActiveUsers(r.Context())

	widget := Widget{
		Title: "Daily active users",
		Actions: []Action{
			{Title: "View Details", Type: "link", URL: "https://example.com/dau-details"},
		},
		Data: KpiData{
			Header: Header{
				Title:    fmt.Sprintf("%d", count),
				Subtitle: "vs last month",
				Badge: &Badge{
					Text:  fmt.Sprintf("%+d%%", delta),
					Icon:  arrow(delta),
					Color: semanticColor(delta),
				},
			},
		},
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(widget)
}

func main() {
	// Fail at boot rather than serving an unprotected endpoint.
	if secret == "" {
		log.Fatal("DASHBOARDBASE_ENDPOINT_SECRET is not set")
	}

	http.HandleFunc("/dashboard/active-users", requireSecret(activeUsers))
	log.Fatal(http.ListenAndServe(":8080", nil))
}
package main

import (
	"encoding/json"
	"crypto/subtle"
	"fmt"
	"log"
	"net/http"
	"os"
)

type Badge struct {
	Text  string `json:"text"`
	Icon  string `json:"icon,omitempty"`
	Color string `json:"color,omitempty"`
}

type Header struct {
	Title    string `json:"title"`
	Subtitle string `json:"subtitle,omitempty"`
	Badge    *Badge `json:"badge,omitempty"`
}

type Action struct {
	Title string `json:"title"`
	Type  string `json:"type"`
	URL   string `json:"url"`
}

type KpiData struct {
	Header Header `json:"header"`
}

type Widget struct {
	Title   string   `json:"title"`
	Actions []Action `json:"actions,omitempty"`
	Data    KpiData  `json:"data"`
}

var secret = os.Getenv("DASHBOARDBASE_ENDPOINT_SECRET")

func requireSecret(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		got := r.Header.Get("x-dashboardbase-secret")
		if subtle.ConstantTimeCompare([]byte(got), []byte(secret)) != 1 {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func activeUsers(w http.ResponseWriter, r *http.Request) {
	count, delta := dailyActiveUsers(r.Context())

	widget := Widget{
		Title: "Daily active users",
		Actions: []Action{
			{Title: "View Details", Type: "link", URL: "https://example.com/dau-details"},
		},
		Data: KpiData{
			Header: Header{
				Title:    fmt.Sprintf("%d", count),
				Subtitle: "vs last month",
				Badge: &Badge{
					Text:  fmt.Sprintf("%+d%%", delta),
					Icon:  arrow(delta),
					Color: semanticColor(delta),
				},
			},
		},
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(widget)
}

func main() {
	// Fail at boot rather than serving an unprotected endpoint.
	if secret == "" {
		log.Fatal("DASHBOARDBASE_ENDPOINT_SECRET is not set")
	}

	http.HandleFunc("/dashboard/active-users", requireSecret(activeUsers))
	log.Fatal(http.ListenAndServe(":8080", nil))
}

That compiles into the binary you already deploy. Paste the URL into a KPI widget and it renders.

omitempty is doing real work in those tags: actions, badge and alert are all optional in the contract, and leaving them out is the same as not having them.

The x-dashboardbase-secret header is the workspace endpoint secret. Every workspace has one and dashboardbase sends it on every request to your endpoints automatically — nothing to wire up beyond the comparison above. It authenticates us to you; it is not an API key for calling dashboardbase. Rotating it keeps the old value valid for 24 hours.

Gin and Chi are the same struct with a different router:

r.GET("/dashboard/active-users", func(c *gin.Context) {
	c.JSON(http.StatusOK, buildActiveUsersWidget(c.Request.Context()))
})
r.GET("/dashboard/active-users", func(c *gin.Context) {
	c.JSON(http.StatusOK, buildActiveUsersWidget(c.Request.Context()))
})
r.GET("/dashboard/active-users", func(c *gin.Context) {
	c.JSON(http.StatusOK, buildActiveUsersWidget(c.Request.Context()))
})

Charts follow the same envelope with Labels []string and a Datasets slice inside Data, where each point is a struct with a Value field.

Generate Go endpoints faster

  • The dashboardbase Skill is free and open source. It teaches Claude Code, Cursor or any skills-capable agent the full contract, so the agent writes the structs and tags correctly rather than approximating them.

  • In-app prompt generation produces a copy-pasteable prompt for whatever AI tool you already use — per widget, or one for a whole dashboard.

Both hand you a reviewable draft you run yourself; the prompt is generated in your browser and nothing executes on our side.

Then paste the response into the endpoint validator to confirm the widget renders it. No account needed.

Where this isn't the right fit

  • Infrastructure metrics. Goroutine counts, GC pauses, request latency by pod — Prometheus and Grafana already do this properly. Keep them.

  • Exploration. There's no query builder. If the question changes daily, use a BI tool.

  • Write actions. Read-only by design. Approving, editing and triggering belong in an internal-tools product.

  • The dashboard is your product. Customer-facing analytics belongs in your own frontend.

Ship one handler and see

Add the handler above to a service you already run, paste the URL into a widget, and judge it from something real. The whole board reads on your phone in the native iOS and Android apps.

Worth reading next: API-first dashboards for the mental model, and how to build a dashboard if you're weighing this against the alternatives.