Use Google Sheets as a Data Source in Go with Gin
Fetch and serve Google Sheets data from a Go API - typed structs, http.Client, and Gin routes.
Why Go + Sheets?
Google Sheets is a surprisingly capable data store for read-heavy workloads. Product catalogs, pricing tables, feature flags, CMS content - all of it can live in a sheet that non-developers can edit without touching code or a database console. The challenge is exposing that data reliably, with the right shape, at low latency.
Go is a natural fit for this layer. Its standard library http.Client handles HTTP with zero overhead, encoding/json decodes responses into typed structs efficiently, and Gin gives you a production-ready router in under 50 lines. Together, they let you build a microservice that sits between your frontend and GKit's SheetsAPI - adding business logic, caching, or field filtering without touching the sheet itself.
This post walks through exactly that: a Go service that reads a product catalog from Google Sheets via GKit and exposes it through Gin routes.
Setting up the sheet
Create a Google Sheet with the following columns in row 1:
| id | name | category | price | in_stock | updated_at |
|---|---|---|---|---|---|
| 1 | Wireless Mouse | peripherals | 29.99 | true | 2026-06-01 |
| 2 | Mechanical Keyboard | peripherals | 89.00 | true | 2026-06-10 |
| 3 | USB-C Hub | accessories | 44.50 | false | 2026-05-28 |
Connect the sheet to GKit and note your user key and sheet name - you will need both to construct API calls.
The SheetsClient struct
Define a typed Product struct and a SheetsClient that wraps Go's http.Client. Keeping the HTTP client as a struct field lets you reuse TCP connections across requests, which matters under load.
package sheets
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const baseURL = "https://api.gkit.io/api/spreadsheets"
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Price float64 `json:"price"`
InStock bool `json:"in_stock"`
UpdatedAt string `json:"updated_at"`
}
type meta struct {
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type sheetsResponse struct {
Data []Product `json:"data"`
Meta meta `json:"meta"`
}
type SheetsClient struct {
userKey string
sheetName string
apiKey string
http *http.Client
}
func NewClient(userKey, sheetName, apiKey string) *SheetsClient {
return &SheetsClient{
userKey: userKey,
sheetName: sheetName,
apiKey: apiKey,
http: &http.Client{Timeout: 8 * time.Second},
}
}
func (c *SheetsClient) ListProducts(params url.Values) ([]Product, meta, error) {
endpoint := fmt.Sprintf("%s/%s/%s?%s", baseURL, c.userKey, c.sheetName, params.Encode())
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, meta{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, meta{}, err
}
defer resp.Body.Close()
var result sheetsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, meta{}, err
}
return result.Data, result.Meta, nil
}The ListProducts method forwards any url.Values directly to the GKit API, so search, sort, limit, offset, and fields all pass through without extra handling.
Gin routes
Wire up two routes: a list endpoint that forwards query parameters, and a detail endpoint that fetches all products and filters client-side (useful when GKit's search param is not granular enough for your needs).
package main
import (
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"yourmodule/sheets"
)
func main() {
client := sheets.NewClient(
"usr_xxxxxxxxxxxx",
"products",
"sk_live_xxxxxxxxxxxxxxxxxxxx",
)
r := gin.Default()
// GET /products - forward search, sort, limit, offset, fields
r.GET("/products", func(c *gin.Context) {
params := url.Values{}
for _, key := range []string{"search", "sort", "limit", "offset", "fields"} {
if v := c.Query(key); v != "" {
params.Set(key, v)
}
}
products, m, err := client.ListProducts(params)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": products, "meta": m})
})
// GET /products/:id - fetch list, filter by id
r.GET("/products/:id", func(c *gin.Context) {
id := c.Param("id")
products, _, err := client.ListProducts(url.Values{
"search": []string{"id:" + id},
"limit": []string{"1"},
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if len(products) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
return
}
c.JSON(http.StatusOK, products[0])
})
r.Run(":8080")
}The /products/:id route uses the search=id:value param supported by GKit to narrow the sheet query server-side before the response crosses the wire - keeping payloads small even on large sheets.
Adding a simple in-memory cache
Sheet data does not change every second. A sync.Map keyed by the full request URL is enough to avoid redundant network calls on repeated reads:
import "sync"
var cache sync.Map
func cachedListProducts(client *sheets.SheetsClient, params url.Values) ([]sheets.Product, error) {
key := params.Encode()
if v, ok := cache.Load(key); ok {
return v.([]sheets.Product), nil
}
products, _, err := client.ListProducts(params)
if err != nil {
return nil, err
}
cache.Store(key, products)
return products, nil
}For production use, add a TTL by storing a struct with the payload and an expiry timestamp, and evicting stale entries on read. Libraries like patrickmn/go-cache wrap this pattern with minimal overhead if you prefer not to roll it yourself.
Next steps
This service is intentionally thin - it reads from a sheet, types the response, and forwards it. From here you can add middleware for rate limiting, integrate slog for structured logging, or deploy the binary to Fly.io or a Cloudflare Worker via WASM.
The sheet itself stays editable by anyone on your team. Update a price, toggle in_stock, add a row - the API reflects the change on the next request with no deploy required.
Ready to connect your own sheet? Sign up for GKit and get an API key in under two minutes. The free tier covers 10,000 reads per month - more than enough to prototype a product catalog, CMS, or internal tool before you need to think about scaling.