Google Sheets as a Database in Cloudflare Workers
Fetch and write to Google Sheets from a Cloudflare Worker using SheetsAPI - edge caching with KV, streaming responses, and scheduled Cron Triggers.
Cloudflare Workers run at the edge - milliseconds from your users - with no cold starts and a generous free tier. Pairing a Worker with SheetsAPI gives you a read/write Google Sheets backend that can serve thousands of requests per second without a traditional server.
Prerequisites
- A Cloudflare account and
npm create cloudflare@latest - Wrangler CLI:
npm install -g wrangler - A Google Sheet with a header row
- A SheetsAPI account and a
YOUR_USER_KEYfrom the dashboard
1. Create the Worker project
npm create cloudflare@latest sheets-worker -- --type hello-world --lang ts
cd sheets-worker2. Store secrets with Wrangler
Never hard-code keys. Use Wrangler secrets instead:
wrangler secret put SHEETS_USER_KEY
wrangler secret put SHEETS_API_KEYAnd declare the type bindings in wrangler.toml:
[vars]
SHEETS_BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets"Add the KV namespace for caching:
wrangler kv namespace create CACHE
# Paste the returned id into wrangler.toml[[kv_namespaces]]
binding = "CACHE"
id = "<paste-id-here>"3. Type the Worker environment
// src/types.ts
export interface Env {
SHEETS_USER_KEY: string;
SHEETS_API_KEY: string;
SHEETS_BASE: string;
CACHE: KVNamespace;
}4. Shared fetch helper
// src/sheets.ts
import type { Env } from "./types";
export interface SheetRow {
[key: string]: string;
}
export interface SheetMeta {
total: number;
limit: number;
offset: number;
}
export interface SheetResponse<T> {
data: T[];
meta: SheetMeta;
}
export async function getSheet<T = SheetRow>(
env: Env,
sheet: string,
params: Record<string, string | number> = {},
): Promise<SheetResponse<T>> {
const url = new URL(`${env.SHEETS_BASE}/${env.SHEETS_USER_KEY}/${sheet}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${env.SHEETS_API_KEY}` },
});
if (!resp.ok) throw new Error(`SheetsAPI ${resp.status}: ${await resp.text()}`);
return resp.json() as Promise<SheetResponse<T>>;
}
export async function postRow(env: Env, sheet: string, row: SheetRow): Promise<SheetRow> {
const resp = await fetch(`${env.SHEETS_BASE}/${env.SHEETS_USER_KEY}/${sheet}`, {
method: "POST",
headers: {
Authorization: `Bearer ${env.SHEETS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(row),
});
if (!resp.ok) throw new Error(`SheetsAPI ${resp.status}: ${await resp.text()}`);
return resp.json() as Promise<SheetRow>;
}5. Worker with KV caching
// src/index.ts
import type { Env } from "./types";
import { getSheet, postRow } from "./sheets";
const CACHE_TTL = 60; // seconds
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const sheet = url.searchParams.get("sheet") ?? "Products";
const search = url.searchParams.get("search") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "1");
const limit = 20;
const offset = (page - 1) * limit;
// ── POST - write a new row ──────────────────────────────────────────
if (request.method === "POST") {
const body = await request.json<Record<string, string>>();
const row = await postRow(env, sheet, body);
// Invalidate cached list
await env.CACHE.delete(`list:${sheet}`);
return Response.json(row, { status: 201 });
}
// ── GET - serve from KV cache when possible ─────────────────────────
const cacheKey = `list:${sheet}:${search ?? ""}:${page}`;
const cached = await env.CACHE.get(cacheKey);
if (cached) {
return new Response(cached, {
headers: {
"Content-Type": "application/json",
"X-Cache": "HIT",
"Cache-Control": `public, max-age=${CACHE_TTL}`,
},
});
}
const result = await getSheet(env, sheet, {
limit,
offset,
...(search ? { search } : {}),
});
const body = JSON.stringify(result);
await env.CACHE.put(cacheKey, body, { expirationTtl: CACHE_TTL });
return new Response(body, {
headers: {
"Content-Type": "application/json",
"X-Cache": "MISS",
"Cache-Control": `public, max-age=${CACHE_TTL}`,
},
});
},
};6. Streaming a large sheet
Workers support streaming responses. For large sheets, pipe data as it arrives from SheetsAPI rather than buffering it:
async function streamSheet(env: Env, sheet: string): Promise<Response> {
const url = new URL(`${env.SHEETS_BASE}/${env.SHEETS_USER_KEY}/${sheet}`);
url.searchParams.set("limit", "500");
const upstream = await fetch(url, {
headers: { Authorization: `Bearer ${env.SHEETS_API_KEY}` },
});
// Pipe the upstream body directly - zero buffering in the Worker
return new Response(upstream.body, {
headers: {
"Content-Type": "application/json",
"Transfer-Encoding": "chunked",
"Access-Control-Allow-Origin": "*",
},
});
}7. Cron Trigger - scheduled sheet sync
Schedule a Worker to pull sheet data and push it somewhere else (Slack, a database, an email) on a fixed cadence:
# wrangler.toml
[triggers]
crons = ["0 * * * *"] # every hour// src/index.ts (add scheduled export)
import type { Env } from "./types";
import { getSheet } from "./sheets";
export default {
async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(syncInventory(env));
},
};
async function syncInventory(env: Env) {
const { data } = await getSheet(env, "Inventory");
const lowStock = data.filter((row) => parseInt(row.quantity ?? "0") < 10);
if (lowStock.length === 0) return;
await fetch("https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `Low stock alert: ${lowStock.map((r) => r.name).join(", ")}`,
}),
});
}8. CORS and preflight
For browser clients calling the Worker directly:
function corsHeaders(): Headers {
const h = new Headers();
h.set("Access-Control-Allow-Origin", "*");
h.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
h.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
return h;
}
// In the fetch handler, before routing:
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders() });
}Deployment
wrangler deployWrangler uploads the Worker to Cloudflare's edge network. The URL follows the pattern:
https://sheets-worker.<your-subdomain>.workers.dev.
Point a custom domain via the Cloudflare dashboard under Workers → Triggers → Custom Domains.
Architecture summary
| Layer | Tool |
|---|---|
| Edge runtime | Cloudflare Workers (TypeScript) |
| Data source | SheetsAPI (Google Sheets REST) |
| Cache | Cloudflare KV (60 s TTL) |
| Secrets | Wrangler secrets |
| Scheduling | Cron Triggers |
| Streaming | Workers Streams API |
Cloudflare Workers + SheetsAPI is one of the lightest stacks for a globally distributed read/write API backed by a spreadsheet - no servers, no databases, just edge compute and a Google Sheet.