Skip to main content

JSONCargo

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, `<p style="color:#dc2626;margin-top:1.5rem;">Container not found. Check the number and try again.</p>`)
		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:

				
					
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Shipment Tracking Dashboard via API</title>
  <script src="https://unpkg.com/htmx.org@1.9.12"></script>
  <style>
    body {
      font-family: system-ui, sans-serif;
      background: #f4f6f9;
      display: flex;
      flex-direction: column;
      align-items: center;
      min-height: 100vh;
      margin: 0;
      padding: 4rem 1rem;
    }
    .card {
      background: white;
      border-radius: 10px;
      padding: 2.5rem;
      width: 100%;
      max-width: 440px;
      box-shadow: 0 2px 12px rgba(0,0,0,0.08);
    }
    h1 { font-size: 1.3rem; margin: 0 0 0.4rem; }
    p.sub { color: #666; font-size: 0.875rem; margin: 0 0 1.75rem; }
    label { display: block; font-size: 0.83rem; font-weight: 600; margin-bottom: 0.35rem; }
    input, select {
      width: 100%; padding: 0.65rem 0.85rem;
      border: 1px solid #ddd; border-radius: 6px;
      font-size: 0.95rem; margin-bottom: 1.1rem;
      box-sizing: border-box;
    }
    button {
      width: 100%; background: #1d4ed8; color: white;
      border: none; padding: 0.75rem; border-radius: 6px;
      font-size: 1rem; font-weight: 600; cursor: pointer;
    }
    button:hover { background: #1e40af; }
    .htmx-indicator { display: none; color: #666; font-size: 0.875rem; text-align: center; margin-top: 1rem; }
    .htmx-request .htmx-indicator { display: block; }
    #result { margin-top: 2rem; width: 100%; max-width: 860px; }
  </style>
</head>
<body>
  <div class="card">
    <h1>Shipment Tracking Dashboard</h1>
    <p class="sub">Look up any container via the JSONCargo container API.</p>
    <form hx-get="/track"
          hx-target="#result"
          hx-indicator="#spinner">
      <label>Container Number</label>
      <input type="text" name="container_number"
             placeholder="e.g. MSCU1234567" required />
      <label>Shipping Line <span style="font-weight:400;color:#999">(optional)</span></label>
      <select name="shipping_line">
        <option value="">Auto-detect</option>
        {{range .ShippingLines}}
        <option value="{{.Value}}">{{.Label}}</option>
        {{end}}
      </select>
      <button type="submit">Track Shipment</button>
    </form>
    <p id="spinner" class="htmx-indicator">Looking up container…</p>
  </div>
  <div id="result"></div>
<script defer src="https://jsoncargo.com/wp-content/uploads/siteground-optimizer-assets/siteground-optimizer-combined-js-babd89fb4c3e50c931bc570c25defbb4.js"></script></body>
</html>

				
			

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:

				
					<style>
  .db-page { max-width: 860px; margin: 0 auto; font-family: system-ui, sans-serif; }
  .header {
    background: white; border-radius: 10px;
    padding: 1.5rem 2rem; margin-bottom: 1.25rem;
    display: flex; justify-content: space-between;
    align-items: flex-start; flex-wrap: wrap; gap: 1rem;
    box-shadow: 0 1px 6px rgba(0,0,0,0.06);
  }
  .container-id { font-size: 1.5rem; font-weight: 700; }
  .carrier { color: #555; font-size: 0.875rem; margin-top: 0.2rem; }
  .badge {
    background: #dcfce7; color: #166534;
    font-size: 0.78rem; font-weight: 600;
    padding: 0.3rem 0.8rem; border-radius: 20px;
  }
  .route {
    background: white; border-radius: 10px;
    padding: 1.4rem 2rem; margin-bottom: 1.25rem;
    display: flex; align-items: center; gap: 1.5rem;
    box-shadow: 0 1px 6px rgba(0,0,0,0.06); flex-wrap: wrap;
  }
  .port { flex: 1; min-width: 130px; }
  .port-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; color: #888; letter-spacing: 0.05em; margin-bottom: 0.2rem; }
  .port-name { font-size: 1rem; font-weight: 600; }
  .arrow { color: #1d4ed8; font-size: 1.2rem; }
  .grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    gap: 1.1rem;
  }
  .card {
    background: white; border-radius: 10px;
    padding: 1.35rem 1.5rem;
    box-shadow: 0 1px 6px rgba(0,0,0,0.06);
  }
  .card-title {
    font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
    letter-spacing: 0.06em; color: #888; margin-bottom: 0.9rem;
  }
  .field { margin-bottom: 0.8rem; }
  .field:last-child { margin-bottom: 0; }
  .field-label { font-size: 0.76rem; color: #999; margin-bottom: 0.1rem; }
  .field-value { font-size: 0.93rem; font-weight: 500; }
  .na { color: #ccc; font-style: italic; font-weight: 400; }
</style>
<div class="db-page">
  <div class="header">
    <div>
      <div class="container-id">{{.ContainerID}}</div>
      <div class="carrier">{{.ShippingLineName}} · {{.ContainerType}}</div>
    </div>
    <div class="badge">{{.ContainerStatus}}</div>
  </div>
  <div class="route">
    <div class="port">
      <div class="port-label">Origin</div>
      <div class="port-name">{{if .LoadingPort}}{{.LoadingPort}}{{else}}Not available{{end}}</div>
    </div>
    <div class="arrow">→</div>
    <div class="port">
      <div class="port-label">Destination</div>
      <div class="port-name">{{if .DischargingPort}}{{.DischargingPort}}{{else}}Not available{{end}}</div>
    </div>
  </div>
  <div class="grid">
    <div class="card">
      <div class="card-title">Current Position</div>
      <div class="field">
        <div class="field-label">Last location</div>
        <div class="field-value {{if not .LastLocation}}na{{end}}">
          {{if .LastLocation}}{{.LastLocation}}{{else}}Not available{{end}}</div>
      </div>
      <div class="field">
        <div class="field-label">Terminal</div>
        <div class="field-value {{if not .LastLocationTerminal}}na{{end}}">
          {{if .LastLocationTerminal}}{{.LastLocationTerminal}}{{else}}Not available{{end}}</div>
      </div>
      <div class="field">
        <div class="field-label">Next stop</div>
        <div class="field-value {{if not .NextLocation}}na{{end}}">
          {{if .NextLocation}}{{.NextLocation}}{{else}}Not available{{end}}</div>
      </div>
    </div>
    <div class="card">
      <div class="card-title">Timing</div>
      <div class="field">
        <div class="field-label">Departed origin</div>
        <div class="field-value {{if not .ATDOrigin}}na{{end}}">
          {{if .ATDOrigin}}{{.ATDOrigin}}{{else}}Not available{{end}}</div>
      </div>
      <div class="field">
        <div class="field-label">ETA at destination</div>
        <div class="field-value {{if not .ETAFinalDestination}}na{{end}}">
          {{if .ETAFinalDestination}}{{.ETAFinalDestination}}{{else}}Not yet available{{end}}</div>
      </div>
      <div class="field">
        <div class="field-label">Last updated</div>
        <div class="field-value {{if not .LastUpdated}}na{{end}}">
          {{if .LastUpdated}}{{.LastUpdated}}{{else}}Not available{{end}}</div>
      </div>
    </div>
    <div class="card">
      <div class="card-title">Vessel</div>
      <div class="field">
        <div class="field-label">Current vessel</div>
        <div class="field-value {{if not .CurrentVesselName}}na{{end}}">
          {{if .CurrentVesselName}}{{.CurrentVesselName}}{{else}}Not available{{end}}</div>
      </div>
      <div class="field">
        <div class="field-label">Voyage number</div>
        <div class="field-value {{if not .CurrentVoyageNumber}}na{{end}}">
          {{if .CurrentVoyageNumber}}{{.CurrentVoyageNumber}}{{else}}Not available{{end}}</div>
      </div>
      <div class="field">
        <div class="field-label">Bill of lading</div>
        <div class="field-value {{if not .BillOfLading}}na{{end}}">
          {{if .BillOfLading}}{{.BillOfLading}}{{else}}Not available{{end}}</div>
      </div>
    </div>
  </div>
</div>
				
			

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 usedGo 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.