Google Sheets and JavaScript: reading and writing data without the OAuth mess
A practical JavaScript guide to reading and writing Google Sheets data - no service account setup, no OAuth flow, no Google SDK. Just fetch().
Every few weeks someone posts in r/javascript or r/webdev asking why their Google Sheets integration needs a Google Cloud project, a service account, a credentials JSON file, and an SDK just to read a list of products. The top answers usually confirm that yes, that is the official way, and then offer a few workarounds.
This post is that complete reference. All three approaches, honest tradeoffs, and working vanilla JavaScript for every CRUD operation.
Why the official Sheets API feels heavy for simple use cases
The Google Sheets API v4 is solid infrastructure. It is versioned, well-documented, and will be supported indefinitely. The friction is in the setup path, not the API itself.
To read a single sheet you need: a Google Cloud project with the Sheets API enabled, a service account, a downloaded JSON key file, the googleapis npm package, and the sheet shared with the service account email. That is five distinct steps before you write a single line of application code.
For a server-side integration where OAuth is already part of the stack, that overhead is reasonable. For a content site, a team directory, or a quick internal tool, it is disproportionate to the task.
Option 1: The official Sheets API - when it is the right call
Install the client library:
npm install googleapisCreate a service account in Google Cloud Console, download the JSON key, and share your sheet with the service account's email address.
import { google } from "googleapis";
import credentials from "./service-account-key.json" assert { type: "json" };
const auth = new google.auth.GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
});
const sheets = google.sheets({ version: "v4", auth });
async function getRows(spreadsheetId, range) {
const res = await sheets.spreadsheets.values.get({
spreadsheetId,
range, // e.g. "Sheet1!A:D"
});
const [headers, ...rows] = res.data.values;
return rows.map((row) =>
Object.fromEntries(headers.map((h, i) => [h, row[i] ?? ""])),
);
}Use the official API when you need cell-level control (formatting, formulas, named ranges), when you are doing large batch writes, when you are building a server-side service where full OAuth is already wired up, or when the sheet contains data that must never be exposed through a public endpoint.
Option 2: The CSV export trick - what goes wrong
Every published Google Sheet has a CSV export URL:
https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/export?format=csv&gid=SHEET_GID
You can fetch this and parse it yourself. It works during a prototype. Here is why it tends to break in production:
- "Anyone with the link can view" is not the same as published. The export URL returns a login redirect for non-published sheets, not data.
- The URL format is undocumented. It has changed before and there is no guarantee of stability.
- Cells containing commas or newlines will corrupt a naive split-based parser. You need a real CSV library like Papa Parse.
- There is no filtering, sorting, or pagination - you download the entire sheet on every request.
- The
gidparameter changes if the sheet tab is moved. The URL silently breaks.
The r/javascript consensus on the CSV trick is roughly: use it to get something on screen in ten minutes, replace it before you ship. That is accurate.
Option 3: SheetsAPI - plain fetch, no SDK
SheetsAPI puts a REST layer in front of your Google Sheet. Connect your sheet once through the GKit dashboard, and it becomes a CORS-enabled JSON endpoint you can call with a plain fetch from any JavaScript environment.
Free during beta. Open source (MIT). Runs on Cloudflare Workers.
The base URL pattern:
https://sheetsapi.gkit.mreshank.com/api/spreadsheets/{userKey}/{sheetName}
Your userKey comes from the GKit dashboard after connecting a sheet. The sheetName is the tab name.
GET: fetch all rows
const BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
const KEY = "YOUR_USER_KEY";
async function getAll(sheet) {
const res = await fetch(`${BASE}/${KEY}/${sheet}`);
if (!res.ok) throw new Error(`SheetsAPI ${res.status}`);
const { data, meta } = await res.json();
console.log(`${meta.total} rows`, data);
return data;
}
await getAll("Products");
// [{ name: "Widget", price: "12.00", status: "active" }, ...]The response always includes a meta object with total, limit, offset, and sheet fields.
GET with filtering
Pass a search parameter as column:value:
async function getActive(sheet) {
const url = new URL(`${BASE}/${KEY}/${sheet}`);
url.searchParams.set("search", "status:active");
const res = await fetch(url);
const { data } = await res.json();
return data;
}
// Multiple filters: comma-separated
url.searchParams.set("search", "status:active,category:Audio");GET with pagination
async function getPage(sheet, page = 0, limit = 20) {
const url = new URL(`${BASE}/${KEY}/${sheet}`);
url.searchParams.set("limit", limit);
url.searchParams.set("offset", page * limit);
const res = await fetch(url);
const { data, meta } = await res.json();
return {
rows: data,
total: meta.total,
hasMore: meta.offset + data.length < meta.total,
};
}
const page1 = await getPage("Orders", 0, 20);
const page2 = await getPage("Orders", 1, 20);This is one of the patterns that comes up repeatedly in r/webdev - people who want simple server-side pagination without setting up a database. The meta.total value lets you build numbered pagination or an infinite scroll "load more" without a second request.
GET specific columns
Use the fields parameter to reduce payload size:
async function getEmailList(sheet) {
const url = new URL(`${BASE}/${KEY}/${sheet}`);
url.searchParams.set("fields", "name,email");
const res = await fetch(url);
const { data } = await res.json();
return data;
// [{ name: "Alex", email: "alex@example.com" }, ...]
}POST: append a new row
async function addRow(sheet, payload) {
const res = await fetch(`${BASE}/${KEY}/${sheet}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? `POST failed: ${res.status}`);
}
return res.json(); // returns the created row with its assigned row number
}
await addRow("Signups", {
name: "Alex",
email: "alex@example.com",
plan: "free",
joined: new Date().toISOString(),
});The column keys in the payload must match your sheet's header row exactly - case-sensitive.
PUT: update an existing row
Rows are identified by their position number. The row number is returned by the GET response as _row:
async function updateRow(sheet, rowNumber, updates) {
const res = await fetch(`${BASE}/${KEY}/${sheet}/${rowNumber}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? `PUT failed: ${res.status}`);
}
return res.json();
}
// Fetch the row first to get its _row number
const { data } = await fetch(`${BASE}/${KEY}/Signups?search=email:alex@example.com`).then((r) =>
r.json(),
);
if (data.length) {
await updateRow("Signups", data[0]._row, { plan: "pro" });
}DELETE: remove a row
async function deleteRow(sheet, rowNumber) {
const res = await fetch(`${BASE}/${KEY}/${sheet}/${rowNumber}`, {
method: "DELETE",
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? `DELETE failed: ${res.status}`);
}
return res.json(); // { success: true, deleted: rowNumber }
}
await deleteRow("Signups", 14);Error handling pattern
SheetsAPI returns structured JSON errors with an HTTP status code and a message field. A consistent wrapper keeps error handling out of your application logic:
async function sheetsRequest(path, options = {}) {
const url = `${BASE}/${KEY}/${path}`;
const res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
if (!res.ok) {
let message = `SheetsAPI error: ${res.status}`;
try {
const body = await res.json();
if (body.message) message = body.message;
} catch {
// response was not JSON
}
const error = new Error(message);
error.status = res.status;
throw error;
}
return res.json();
}
// Usage
try {
const { data } = await sheetsRequest("Products?search=status:active");
} catch (err) {
if (err.status === 404) {
console.error("Sheet not found - check the sheet name and userKey");
} else if (err.status === 429) {
console.error("Rate limit - back off and retry");
} else {
console.error(err.message);
}
}async/await vs .then()
The examples above all use async/await. The .then() chain is equivalent and both are fine:
// .then() version
fetch(`${BASE}/${KEY}/Products`)
.then((res) => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(({ data }) => console.log(data))
.catch(console.error);async/await is generally easier to read when you need to chain multiple requests - fetching a row before updating it, for example. Either style works with SheetsAPI.
Node.js
The fetch API is available in Node.js 18 and later with no imports or polyfills. Every example in this post runs in Node 18+ exactly as written.
node --version # v18.0.0 or later
node index.js # fetch() just worksIf you are on Node 16 or earlier, install node-fetch or undici and import it. The API surface is identical.
Rate limits and higher throughput
SheetsAPI is free during beta with generous limits for typical use cases. The underlying Google Sheets API has its own quota: roughly 300 read requests per minute per project.
If you hit limits:
- Cache responses at the edge or in memory. Most read patterns - product catalogs, content lists, team directories - do not need sub-second freshness.
- Use the
fieldsparameter to fetch only the columns you need. - For write-heavy workflows (logging, form submissions at scale), the official Sheets API with a service account gives you more control over quota management.
See the SheetsAPI docs for current rate limit details and the pricing page for usage tiers coming out of beta.
Who GKit is for
GKit is a set of developer tools for Google Workspace. SheetsAPI handles the read/write layer for Sheets. Drive Cleaner handles duplicate detection and bulk cleanup for Google Drive. There are 50+ free tools at /tools - formatters, converters, and utilities that run in the browser with no account required.
If you are building something where a non-engineer manages the data in a spreadsheet and you want to keep the frontend thin, the use cases page has worked examples for content sites, event listings, form backends, and internal directories.
One endpoint, plain fetch
The setup that comes up in every r/javascript and r/webdev thread on Sheets integration - Google Cloud project, service account, credentials JSON, SDK install - is the right answer for large server-side integrations. For everything else, it is overhead that does not pay for itself.
SheetsAPI gives you a CRUD endpoint you can call with a plain fetch. No SDK, no OAuth flow, no credential files to rotate. Connect your sheet and start reading data in under two minutes.