Use Google Sheets as a Backend with Express.js
Fetch, filter, and serve Google Sheets data from a Node.js Express API - no database required.
Express.js and Google Sheets are a surprisingly good pairing for internal tools, prototypes, and read-heavy APIs. You skip the database setup, schema migrations, and infrastructure overhead. Your data lives in a spreadsheet that non-technical teammates can edit directly, and your Express server turns it into a proper REST API in an afternoon.
This tutorial builds a job listings API backed by a Google Sheet, using GKit SheetsAPI as the bridge between your Node server and the spreadsheet.
Prerequisites
- Node.js 18+ (native
fetch- nonode-fetchrequired) - An Express project (
npm install express) - A Google Sheet with a header row
- A GKit account and a
YOUR_USER_KEYfrom the dashboard, plus an API key (sk_...)
1. Set up the Sheet
Create a Google Sheet named Jobs with these column headers in row 1:
| title | company | location | salary | remote | posted_date |
|---|---|---|---|---|---|
| Senior Engineer | Acme Corp | San Francisco, CA | 160000 | true | 2026-06-01 |
| Product Designer | Bright Labs | Remote | 120000 | true | 2026-06-10 |
| Data Analyst | Horizon Co | New York, NY | 95000 | false | 2026-06-15 |
The header row becomes the field names you query against. Keep them lowercase with no spaces - SheetsAPI maps them directly to query parameters.
2. The sheetsClient.js helper
Create lib/sheetsClient.js. This module wraps all SheetsAPI calls so your routes stay clean:
// lib/sheetsClient.js
const BASE_URL = "https://api.gkit.io/api/spreadsheets";
const USER_KEY = process.env.SHEETS_USER_KEY;
const API_KEY = process.env.SHEETS_API_KEY;
if (!USER_KEY || !API_KEY) {
throw new Error("SHEETS_USER_KEY and SHEETS_API_KEY must be set");
}
/**
* Fetch rows from a sheet with optional query params.
* @param {string} sheetName
* @param {Record<string, string | number>} params
* @returns {Promise<{ data: object[], meta: { total: number, limit: number, offset: number } }>}
*/
export async function querySheet(sheetName, params = {}) {
const url = new URL(`${BASE_URL}/${USER_KEY}/${sheetName}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) {
const body = await res.text();
throw new Error(`SheetsAPI ${res.status}: ${body}`);
}
return res.json();
}Supported query parameters:
| Parameter | Example | Description |
|---|---|---|
search | company:Acme Corp | Filter rows by field:value |
sort | posted_date / -salary | Ascending / descending |
limit | 20 | Rows per page (max 500) |
offset | 0 | Pagination offset |
fields | title,company,salary | Return only named columns |
3. Express router with two routes
Create routes/jobs.js:
// routes/jobs.js
import { Router } from "express";
import { querySheet } from "../lib/sheetsClient.js";
const router = Router();
// GET /jobs
// Optional query params: search, sort, limit, offset, remote
router.get("/", async (req, res, next) => {
try {
const { search, sort = "-posted_date", limit = 20, offset = 0, remote } = req.query;
const params = { sort, limit, offset };
if (search) params.search = search;
if (remote !== undefined) params.search = `remote:${remote}`;
const result = await querySheet("Jobs", params);
res.json(result);
} catch (err) {
next(err);
}
});
// GET /jobs/:company
// Returns all listings for a specific company (case-insensitive match via SheetsAPI)
router.get("/:company", async (req, res, next) => {
try {
const { company } = req.params;
const { sort = "-posted_date", limit = 50 } = req.query;
const result = await querySheet("Jobs", {
search: `company:${company}`,
sort,
limit,
});
if (result.data.length === 0) {
return res.status(404).json({ error: `No jobs found for company: ${company}` });
}
res.json(result);
} catch (err) {
next(err);
}
});
export default router;Wire it up in app.js:
// app.js
import express from "express";
import jobsRouter from "./routes/jobs.js";
const app = express();
app.use(express.json());
app.use("/jobs", jobsRouter);
app.use((err, _req, res, _next) => {
console.error(err.message);
res.status(502).json({ error: err.message });
});
app.listen(3000, () => console.log("Listening on http://localhost:3000"));Test the routes:
# All jobs, newest first
curl http://localhost:3000/jobs
# Remote jobs only
curl "http://localhost:3000/jobs?remote=true"
# Jobs at Acme Corp
curl http://localhost:3000/jobs/Acme%20Corp
# Keyword search + limit
curl "http://localhost:3000/jobs?search=title:Engineer&limit=5"4. In-memory cache with TTL
SheetsAPI is fast, but hitting it on every request adds 50–150 ms of latency and burns your rate-limit budget on repeated identical reads. A simple Map-based cache with a 60-second TTL fixes both:
// lib/cache.js
const store = new Map();
/**
* Return a cached value, or call `fn` to populate it.
* @param {string} key
* @param {number} ttlMs Time-to-live in milliseconds
* @param {() => Promise<unknown>} fn
*/
export async function withCache(key, ttlMs, fn) {
const hit = store.get(key);
if (hit && hit.expiresAt > Date.now()) {
return hit.value;
}
const value = await fn();
store.set(key, { value, expiresAt: Date.now() + ttlMs });
return value;
}
/** Manually invalidate a cache key (useful after a write). */
export function invalidate(key) {
store.delete(key);
}Update sheetsClient.js to use it:
// lib/sheetsClient.js (updated)
import { withCache } from "./cache.js";
const CACHE_TTL = 60 * 1000; // 60 seconds
export async function querySheet(sheetName, params = {}) {
const url = new URL(`${BASE_URL}/${USER_KEY}/${sheetName}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
const cacheKey = url.toString();
return withCache(cacheKey, CACHE_TTL, async () => {
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) {
const body = await res.text();
throw new Error(`SheetsAPI ${res.status}: ${body}`);
}
return res.json();
});
}The cache key is the full URL including all query parameters, so /jobs?sort=salary and /jobs?sort=posted_date are cached independently. Entries expire after 60 seconds, after which the next request repopulates the cache transparently.
For production use, bump the TTL for stable data (staff directories, product catalogs) and lower it for frequently-updated sheets. If you need cache eviction on demand - for example after a sheet edit - call invalidate(url) from a webhook handler.
Why this works well
Google Sheets handles the data storage, formatting, and collaboration. SheetsAPI handles authentication, query translation, and pagination. Express handles your routing and business logic. Each layer does exactly one job, and you can swap any of them independently.
The same pattern scales to dozens of internal tools: an HR directory, a content calendar, a feature flag sheet, a price list. The spreadsheet stays the single source of truth, and your API stays thin.
Get started with GKit
Sign up at gkit.io to get your API key and user key. The free tier covers most internal tools and prototypes - no credit card required.