Blog
How to Build a Shipment Tracking Dashboard via API with Go and HTMX
Building a shipment tracking dashboard via API is one of those projects that sounds ambitious and turns out to be surprisingly straightforward. This guide walks through it using Go, the standard net/http package, and HTMX — from an empty folder to a working local web app that updates without full page reloads.
You’ll end up with a search form where you enter a container number, and a results panel that appears inline — showing live status, current location, ETA, and vessel data pulled directly from the container API. HTMX handles the partial update so no JavaScript framework is needed.
Who this is for
You should know basic Go, writing functions, working with structs, using go get. No prior HTMX experience needed; everything gets explained as we go. If you’ve written any Go web handler before, this will feel immediately familiar.
Project structure
Four files total:
shipment-dashboard/
├── main.go
├── go.mod
└── templates/
├── index.html
└── dashboard.html
No database. The shipment tracking dashboard via API queries JSONCargo on demand each time someone submits the search form. dashboard.html is an HTML partial — HTMX swaps it directly into the page without a full navigation.
Initialise the module and get an API key
mkdir shipment-dashboard && cd shipment-dashboard
go mod init shipment-dashboard
No third-party packages are required — this build uses only the Go standard library (net/http, html/template, encoding/json). Grab a JSONCargo API key at jsoncargo.com/pricing — there’s a free trial so you can build and test before committing. Set it as an environment variable:
# Mac/Linux
export JSONCARGO_API_KEY="your_key_here"
# Windows
set JSONCARGO_API_KEY=your_key_here
Set up main.go & config and data types
Create main.go. The ShippingLine slice populates the carrier dropdown in the search form — each struct holds the API parameter value and its display label. The Container struct maps directly onto the fields returned by the JSONCargo API:
package main
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
)
const baseURL = "https://api.jsoncargo.com/api/v1"
type ShippingLine struct {
Value string
Label string
}
var shippingLines = []ShippingLine{
{"MAERSK", "Maersk"},
{"MSC", "MSC"},
{"CMA_CGM", "CMA CGM"},
{"HAPAG_LLOYD", "Hapag-Lloyd"},
{"EVERGREEN", "Evergreen"},
{"COSCO", "COSCO"},
{"HMM", "HMM"},
{"ONE", "ONE"},
{"ZIM", "ZIM"},
{"YANG_MING", "Yang Ming"},
{"PIL", "PIL"},
}
type Container struct {
ContainerID string `json:"container_id"`
ShippingLineName string `json:"shipping_line_name"`
ContainerType string `json:"container_type"`
ContainerStatus string `json:"container_status"`
LoadingPort string `json:"loading_port"`
DischargingPort string `json:"discharging_port"`
LastLocation string `json:"last_location"`
LastLocationTerminal string `json:"last_location_terminal"`
NextLocation string `json:"next_location"`
ATDOrigin string `json:"atd_origin"`
ETAFinalDestination string `json:"eta_final_destination"`
LastUpdated string `json:"last_updated"`
CurrentVesselName string `json:"current_vessel_name"`
CurrentVoyageNumber string `json:"current_voyage_number"`
BillOfLading string `json:"bill_of_lading"`
}
Write the container lookup function and HTTP handlers
One function calls the JSONCargo container API, one handler serves the search page, one handler returns the HTMX partial. The strings.ToUpper() call prevents 404s from lowercase input — container numbers are always uppercase. The /track handler detects whether the request comes from HTMX via the HX-Request header, then renders only the partial:
nerData(containerNumber, shippingLine string) (*Container, error) {
apiKey := os.Getenv("JSONCARGO_API_KEY")
url := fmt.Sprintf("%s/containers/%s", baseURL, containerNumber)
if shippingLine != "" {
url += "?shipping_line=" + shippingLine
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", apiKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned %d", resp.StatusCode)
}
var result struct {
Data Container `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
var tmpl = template.Must(template.ParseGlob("templates/*.html"))
func indexHandler(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "index.html", map[string]any{
"ShippingLines": shippingLines,
})
}
func trackHandler(w http.ResponseWriter, r *http.Request) {
containerNumber := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("container_number")))
shippingLine := r.URL.Query().Get("shipping_line")
container, err := getContainerData(containerNumber, shippingLine)
if err != nil {
fmt.Fprintf(w, `Container not found. Check the number and try again.
`)
return
}
tmpl.ExecuteTemplate(w, "dashboard.html", container)
}
func main() {
http.HandleFunc("/", indexHandler)
http.HandleFunc("/track", trackHandler)
log.Println("Listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
Build the search page
Create templates/index.html. The key difference from a traditional form is the HTMX attributes: hx-get sends the request to /track, hx-target points to the #result div where the response fragment will be injected, and hx-indicator shows a loading state while the API call is in flight — all with zero JavaScript:
Shipment Tracking Dashboard via API
Shipment Tracking Dashboard
Look up any container via the JSONCargo container API.
Looking up container…
Build the shipment tracking dashboard partial
Create templates/dashboard.html. This is an HTML partial — no <html> or <body> wrapper. HTMX injects the response fragment directly into #result, so the page updates in place. Three cards cover current position, timing, and vessel details:
{{.ContainerID}}
{{.ShippingLineName}} · {{.ContainerType}}
{{.ContainerStatus}}
Origin
{{if .LoadingPort}}{{.LoadingPort}}{{else}}Not available{{end}}
→
Destination
{{if .DischargingPort}}{{.DischargingPort}}{{else}}Not available{{end}}
Current Position
Last location
{{if .LastLocation}}{{.LastLocation}}{{else}}Not available{{end}}
Terminal
{{if .LastLocationTerminal}}{{.LastLocationTerminal}}{{else}}Not available{{end}}
Next stop
{{if .NextLocation}}{{.NextLocation}}{{else}}Not available{{end}}
Timing
Departed origin
{{if .ATDOrigin}}{{.ATDOrigin}}{{else}}Not available{{end}}
ETA at destination
{{if .ETAFinalDestination}}{{.ETAFinalDestination}}{{else}}Not yet available{{end}}
Last updated
{{if .LastUpdated}}{{.LastUpdated}}{{else}}Not available{{end}}
Vessel
Current vessel
{{if .CurrentVesselName}}{{.CurrentVesselName}}{{else}}Not available{{end}}
Voyage number
{{if .CurrentVoyageNumber}}{{.CurrentVoyageNumber}}{{else}}Not available{{end}}
Bill of lading
{{if .BillOfLading}}{{.BillOfLading}}{{else}}Not available{{end}}
Running the shipment tracking dashboard
go run main.go
Open http://localhost:8080. Enter a container number in the format MSCU1234567 (four letters, seven digits) and hit Track. HTMX posts the request to /track and the shipment tracking dashboard partial appears inline in under a second — no page navigation, no JavaScript bundle.
What each section maps to in the container API response
| Dashboard section | API fields used | Go struct field |
|---|---|---|
| Route bar | loading_port, discharging_port | LoadingPort, DischargingPort |
| Current position | last_location, last_location_terminal, next_location | LastLocation, LastLocationTerminal, NextLocation |
| Timing | atd_origin, eta_final_destination, last_updated | ATDOrigin, ETAFinalDestination, LastUpdated |
| Vessel | vessel_name, voyage_number, bill_of_lading | CurrentVesselName, CurrentVoyageNumber, BillOfLading |
Where to take this shipment tracking dashboard next
1. Track multiple containers at once. Accept a comma-separated list in the form, fan out goroutines with sync.WaitGroup to call the API concurrently, and render results as a table. This is what makes the shipment tracking dashboard via API actually useful operationally — and Go’s concurrency makes it trivial.
2. Cache responses. Container status doesn’t change every few minutes. Store each API response in a sync.Map with a timestamp and only re-fetch after a few hours. This stretches your monthly API call budget significantly.
3. Check your usage. Call the /api/v1/api_key/stats endpoint to see how many requests you’ve used against your plan limit before building anything that polls frequently.
4. Add vessel data. JSONCargo’s vessel tracking endpoints let you enrich the dashboard with live position, speed, and navigation status alongside the container data.
5. Push live updates with SSE. Pair HTMX’s hx-trigger="every 5m" with Go’s built-in http.Flusher for server-sent events to keep the dashboard refreshed automatically.