TypeScript Client Helper

A typed fetch wrapper for SheetsAPI — copy-paste into any TypeScript or Next.js project to get autocomplete and type safety over your Sheets data.

Why a client helper

Raw fetch works fine for one-off requests. But the moment you have more than a handful of endpoints you end up repeating URL construction, forgetting to stringify numeric params, and catching errors by eyeballing network tabs.

A thin typed wrapper fixes all three issues at once:

  • Autocomplete on query paramssort, search, limit, fields are properties on a typed ListOptions interface, not ad-hoc strings scattered through your codebase.
  • Domain model types flow end-to-endlist<Product>(...) returns SheetsResponse<Product>, so data[0].price is typed with no casts needed.
  • One place to change the base URL or auth header — swap from dev to production by changing a single env var.

The client code

Create lib/sheets-client.ts (or src/lib/sheets-client.ts) and paste the following:

// lib/sheets-client.ts
const SHEETS_BASE =
  process.env.NEXT_PUBLIC_SHEETS_BASE ?? "https://sheetsapi.gkit.mreshank.com/api";
 
interface ListOptions {
  limit?: number;        // max 1000
  offset?: number;
  search?: string;       // "field:value"
  searchExact?: boolean;
  sort?: string;         // "fieldname" or "-fieldname" for descending
  fields?: string[];
  format?: "json" | "csv" | "tsv" | "xml";
}
 
interface SheetsResponse<T> {
  data: T[];
  meta: { total: number; limit: number; offset: number };
}
 
export function createSheetsClient(userKey: string, apiKey?: string) {
  const headers: HeadersInit = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
 
  async function list<T>(sheetName: string, options: ListOptions = {}): Promise<SheetsResponse<T>> {
    const params = new URLSearchParams();
    if (options.limit != null)    params.set("limit", String(options.limit));
    if (options.offset != null)   params.set("offset", String(options.offset));
    if (options.search)           params.set("search", options.search);
    if (options.searchExact)      params.set("search_exact", "1");
    if (options.sort)             params.set("sort", options.sort);
    if (options.fields?.length)   params.set("fields", options.fields.join(","));
    if (options.format)           params.set("format", options.format);
    const url = `${SHEETS_BASE}/spreadsheets/${userKey}/${sheetName}?${params}`;
    const res = await fetch(url, { headers });
    if (!res.ok) throw new Error(`SheetsAPI ${res.status}: ${await res.text()}`);
    return res.json();
  }
 
  async function getRow<T>(sheetName: string, row: number): Promise<T> {
    const url = `${SHEETS_BASE}/spreadsheets/${userKey}/${sheetName}/${row}`;
    const res = await fetch(url, { headers });
    if (!res.ok) throw new Error(`SheetsAPI ${res.status}`);
    return res.json();
  }
 
  async function createRow<T>(sheetName: string, data: Partial<T>): Promise<T> {
    const url = `${SHEETS_BASE}/spreadsheets/${userKey}/${sheetName}`;
    const res = await fetch(url, {
      method: "POST",
      headers: { ...headers, "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error(`SheetsAPI ${res.status}`);
    return res.json();
  }
 
  async function updateRow<T>(sheetName: string, row: number, data: Partial<T>): Promise<T> {
    const url = `${SHEETS_BASE}/spreadsheets/${userKey}/${sheetName}/${row}`;
    const res = await fetch(url, {
      method: "PUT",
      headers: { ...headers, "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error(`SheetsAPI ${res.status}`);
    return res.json();
  }
 
  async function deleteRow(sheetName: string, row: number): Promise<void> {
    const url = `${SHEETS_BASE}/spreadsheets/${userKey}/${sheetName}/${row}`;
    const res = await fetch(url, { method: "DELETE", headers });
    if (!res.ok) throw new Error(`SheetsAPI ${res.status}`);
  }
 
  return { list, getRow, createRow, updateRow, deleteRow };
}

Setup

1. Place the file

Drop sheets-client.ts anywhere TypeScript can resolve it. In a Next.js App Router project, lib/ or src/lib/ works well.

2. Add the env var

# .env.local
NEXT_PUBLIC_SHEETS_BASE=https://sheetsapi.gkit.mreshank.com/api
GKIT_USER_KEY=your_user_key
GKIT_API_KEY=your_api_key   # only required for private sheets

NEXT_PUBLIC_SHEETS_BASE is exposed to the browser so you can call it from client components. GKIT_USER_KEY and GKIT_API_KEY are server-only; never prefix sensitive keys with NEXT_PUBLIC_.

3. Instantiate the client once

// lib/gkit.ts
import { createSheetsClient } from "./sheets-client";
 
export const gkit = createSheetsClient(
  process.env.GKIT_USER_KEY!,
  process.env.GKIT_API_KEY,   // omit for public sheets
);

Import gkit wherever you need it.


Usage examples

Define a domain type for your sheet's columns:

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

List rows with filters

import { gkit } from "@/lib/gkit";
 
const { data, meta } = await gkit.list<Product>("Products", {
  limit: 20,
  sort: "-price",          // descending by price
  search: "category:Electronics",
  fields: ["id", "name", "price"],
});
 
console.log(`Showing ${data.length} of ${meta.total}`);

Fetch a single row by index

Row indices are 1-based and correspond to spreadsheet rows (excluding the header):

const product = await gkit.getRow<Product>("Products", 3);
console.log(product.name); // fully typed

Create a new row

const created = await gkit.createRow<Product>("Products", {
  name: "Mechanical Keyboard",
  price: 129,
  category: "Electronics",
  inStock: true,
});

Access a private sheet with an API key

If your sheet is private, pass apiKey when creating the client. The helper automatically sends Authorization: Bearer <key> on every request:

const privateClient = createSheetsClient(
  process.env.GKIT_USER_KEY!,
  process.env.GKIT_API_KEY!,
);

Next.js Server Component example

Because the client is just fetch, it plugs directly into React Server Components. Use Next.js's next fetch option to control caching:

// app/products/page.tsx
import { createSheetsClient } from "@/lib/sheets-client";
 
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}
 
async function getProducts() {
  const gkit = createSheetsClient(process.env.GKIT_USER_KEY!);
  // Revalidate every 60 seconds — stale-while-revalidate via Next.js cache
  const { data } = await gkit.list<Product>("Products", { limit: 50, sort: "name" });
  return data;
}
 
export default async function ProductsPage() {
  const products = await getProducts();
 
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}

To opt into ISR with a 60-second revalidation window, export a route segment config:

// app/products/page.tsx  (add alongside the component)
export const revalidate = 60;

Or pass { next: { revalidate: 60 } } directly to the underlying fetch call if you need per-request control — you can extend the list method to accept a fetchOptions parameter for exactly that purpose.


Environment variables

VariableRequiredExposed to browserDescription
NEXT_PUBLIC_SHEETS_BASENoYesBase URL for SheetsAPI. Defaults to https://sheetsapi.gkit.mreshank.com/api. Override to point at a staging instance.
GKIT_USER_KEYYesNoYour SheetsAPI user key, visible in your dashboard. Identifies which spreadsheets to read.
GKIT_API_KEYOnly for private sheetsNoBearer token for private sheets. Keep this server-side only — never prefix with NEXT_PUBLIC_.

Error handling

Every method throws a plain Error with the HTTP status code and response body when the request fails. Wrap calls in try/catch or use a utility like Result types if you prefer not to throw:

try {
  const { data } = await gkit.list<Product>("Products");
} catch (err) {
  // err.message === "SheetsAPI 403: unauthorized"
  console.error(err);
}

For production apps, pair this with your error boundary or a logging service like Sentry to surface API failures without crashing the page.