Google Sheets API in Node.js: CRUD without OAuth - SheetDB

How to read and write Google Sheets with Node.js

Connecting a Node.js app to Google Sheets with Google's own tools takes a while: create a Google Cloud project, enable the Sheets API, create a service account, download a JSON key, share the spreadsheet with the service account's e-mail address, then learn the A1 ranges and value arrays of googleapis or the google-spreadsheet package. For many projects that is more setup than the feature itself.

This guide does it with SheetDB, which turns a spreadsheet into a JSON REST API. Rows come back as objects keyed by the column names in the first row, and every operation is one HTTP call you make with the fetch() built into Node.js 18 and newer. No googleapis, no OAuth, no service account and no npm dependencies. You will read rows, search them, add, update and delete rows, handle errors and rate limits, protect the API, and put it all together in a small Express server.

Quick start: paste this into sheets.mjs and run node sheets.mjs. It reads a real spreadsheet right now.

// sheets.mjs – Node.js 18+, no packages needed. Run: node sheets.mjs
const API_URL = 'https://sheetdb.io/api/v1/58f61be4dda40';

const rows = await fetch(API_URL).then((response) => response.json());

console.log(rows);
// [
//   { id: '1', name: 'Tom', age: '15', comment: '' },
//   { id: '2', name: 'Alex', age: '24', comment: '' },
//   { id: '3', name: 'John', age: '51', comment: '' },
//   { id: '4', name: 'Steve', age: '22', comment: 'special' },
//   { id: '5', name: 'James', age: '19', comment: '' }
// ]

All examples on this page use this demo spreadsheet, available as the API https://sheetdb.io/api/v1/58f61be4dda40. Its first tab has the columns id, name, age and comment; the second tab, Sheet2, has player, score and comment. The demo resets every 15 minutes, so you can run the write examples too.

Step 1: create an API for your spreadsheet

  1. Make sure the first row of the sheet holds column names, such as id, name, email. They become the keys of the JSON objects. Short lowercase names without spaces are easiest to use in code and URLs.
  2. Sign in to SheetDB with the Google account that can edit the spreadsheet, click Create new API and paste the spreadsheet URL.
  3. Copy the API URL, https://sheetdb.io/api/v1/<your id>, and store it in an environment variable such as SHEETDB_URL. The spreadsheet stays private; SheetDB reads and writes it with your Google account.

Open the API URL in a browser: you should see your rows as a JSON array. That is everything you need on the Google side.

Step 2: add a small fetch() helper

Every call below goes through one function that builds the URL, sends JSON, parses the response and throws on errors. Copy it into sheetdb.mjs in your project. It has no dependencies: fetch() is global in Node.js 18+, Bun and Deno. On platforms without process.env, such as some edge runtimes, pass the API URL in from your platform's configuration instead.

// sheetdb.mjs – a tiny helper used by the rest of the examples
const API_URL = process.env.SHEETDB_URL ?? 'https://sheetdb.io/api/v1/58f61be4dda40';

export async function sheetdb(path = '', { method = 'GET', body } = {}) {
  const response = await fetch(API_URL + path, {
    method,
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  const payload = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new Error(`SheetDB ${response.status}: ${payload.error ?? response.statusText}`);
  }

  return payload;
}

Checking response.ok matters: fetch() only rejects on network failures, so without the check a 404 or a 429 would look like a successful response. SheetDB returns errors as JSON with an error key, which the helper puts into the exception message.

Read rows from Google Sheets in Node.js

A GET on the API URL returns every row of the first tab. Query parameters shape the result on the API side, so you do not download rows only to throw them away:

import { sheetdb } from './sheetdb.mjs';

// All rows of the first tab
const people = await sheetdb();

// Two oldest people: sort and limit on the API side
const oldest = await sheetdb('?sort_by=age&sort_order=desc&limit=2');
// [{ id: '3', name: 'John', age: '51', ... }, { id: '2', name: 'Alex', age: '24', ... }]

// Pagination: skip 2 rows, return the next 2
const page2 = await sheetdb('?limit=2&offset=2');

// Real numbers instead of strings for the listed columns
const typed = await sheetdb('?cast_numbers=age&limit=1');
// [{ id: '1', name: 'Tom', age: 15, comment: '' }]

// Another tab of the same spreadsheet
const players = await sheetdb('?sheet=Sheet2');
// [{ player: 'Smith', score: '41', comment: '' }, ...]
  • limit and offset paginate.
  • sort_by and sort_order (asc, desc or random) sort.
  • sheet picks a tab by name; without it the first tab is used.
  • cast_numbers turns the listed columns into JSON numbers. Without it every value is a string, exactly as Google returns it.

The full list of parameters is in the read docs.

Search rows (filter by column)

/search returns rows that match all conditions, /search_or rows that match any of them. Conditions are column=value query parameters, and values support * wildcards, ! for negation and >, <, >=, <= comparisons. Matching is case-insensitive by default.

import { sheetdb } from './sheetdb.mjs';

// AND: every condition must match
const params = new URLSearchParams({ age: '>18', comment: 'special' });
const special = await sheetdb('/search?' + params);
// [{ id: '4', name: 'Steve', age: '22', comment: 'special' }]

// OR: any condition may match
const either = await sheetdb('/search_or?' + new URLSearchParams({ age: '<16', comment: 'special' }));
// Tom (15) and Steve ("special")

// Wildcard, and one object instead of an array
const steve = await sheetdb('/search?name=Ste*&single_object=true');
// { id: '4', name: 'Steve', age: '22', comment: 'special' }

Build the query with URLSearchParams rather than string concatenation, so values with spaces, & or # are encoded correctly. A search with no matches returns an empty array, not an error. Reference: search docs.

Add rows

Send a POST with a data array; each object becomes one row appended to the bottom of the tab. Keys must match the column names exactly (they are case-sensitive), and keys that match no column are ignored.

import { sheetdb } from './sheetdb.mjs';

const result = await sheetdb('', {
  method: 'POST',
  body: {
    data: [
      { id: 'INCREMENT', name: 'Mark', age: 35, comment: 'new customer' },
      { id: 'INCREMENT', name: 'Anna', age: 28, comment: 'DATETIME' },
    ],
  },
});

console.log(result); // { created: 2 }

Three placeholders save you code: INCREMENT fills in the highest number in the column plus one, DATETIME the current date and time, and TIMESTAMP a Unix timestamp. Insert many rows in one request rather than looping over single inserts: it is faster, and it counts as one request against your quota. Add ?sheet=Sheet2 to the URL to write to another tab. Reference: create docs.

Update rows

The URL names the rows to change as /{column}/{value}; the body holds only the columns you want to change. Everything else in the row stays as it is.

import { sheetdb } from './sheetdb.mjs';

// UPDATE ... SET age = 36 WHERE name = 'Mark'
const result = await sheetdb('/name/' + encodeURIComponent('Mark'), {
  method: 'PATCH',
  body: { data: { age: 36 } },
});

console.log(result); // { updated: 1 }

Every matching row is updated, so update by a unique column such as id when you mean one row. The value in the path accepts the same syntax as search, which makes bulk updates like /status/pending one call. If nothing matches, the API answers 404 with {"error": "No results found"}. To update many different rows in one request, see batch_update in the update docs.

Delete rows

Deleting uses the same /{column}/{value} path with the DELETE method. The rows are removed from the sheet and the rows below move up.

import { sheetdb } from './sheetdb.mjs';

// DELETE ... WHERE name = 'Mark'
const result = await sheetdb('/name/' + encodeURIComponent('Mark'), { method: 'DELETE' });

console.log(result); // { deleted: 1 }

Be careful with wildcards here: /name/* deletes every row that has a name. In production, switch off the Delete (DELETE) permission in the API settings unless your app really deletes. Reference: delete docs.

Error handling and rate limits

The status codes you will meet most often:

  • 200 / 201: success; a POST returns {"created": n}, a PATCH {"updated": n}, a DELETE {"deleted": n}.
  • 400: the request has no data or it is not valid JSON.
  • 401 / 403: missing credentials on a protected API, or the operation is disabled in the API permissions.
  • 404: wrong API id, a tab that does not exist, or no row matched an update or delete.
  • 429: too many requests in a short time, either to your API or to the spreadsheet on Google's side.

A 429 or a 5xx is usually temporary, so retry it with a growing delay. Do not retry 4xx errors: they will fail the same way again.

import { sheetdb } from './sheetdb.mjs';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function withRetry(request, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await request();
    } catch (error) {
      const retryable = /SheetDB (429|5\d\d)/.test(error.message);

      if (!retryable || attempt >= attempts) {
        throw error;
      }

      await sleep(1000 * 2 ** attempt); // 2 s, 4 s, ...
    }
  }
}

const rows = await withRetry(() => sheetdb('/search?comment=special'));

The best protection against rate limits is fewer requests: SheetDB caches read responses for 15 seconds by default (adjustable in the API settings, and cleared by every write made through the API), so polling more often than that only returns the same data. Cache rarely changing data in your own process or in Redis, batch inserts, and read once per request rather than once per item. Current numbers are on the limits page and the error messages are listed in the errors docs.

Authentication: keep the API private

A new API is open to anyone who knows its URL. For a backend integration, turn on HTTP Basic Auth or a Bearer token in the API settings and add the matching header to the helper:

// Bearer token (API settings → Bearer token)
headers: {
  Accept: 'application/json',
  'Content-Type': 'application/json',
  Authorization: `Bearer ${process.env.SHEETDB_TOKEN}`,
},

// or HTTP Basic Auth (API settings → HTTP Basic Auth)
Authorization: 'Basic ' + Buffer.from(`${process.env.SHEETDB_LOGIN}:${process.env.SHEETDB_PASSWORD}`).toString('base64'),

Keep the credentials in environment variables, never in front-end code or the repository. In the same settings, disable the permissions your app does not need, for example leave only Read (GET) and Search (GET) for a service that only reads. The authentication docs describe both methods.

Full example: an Express API backed by Google Sheets

Putting the pieces together: a small Express server that lists and searches people, adds a person with validation, and updates a row by id. Your front end talks to your server, and only the server knows the SheetDB URL and credentials.

// server.mjs – npm install express, then: SHEETDB_URL=https://sheetdb.io/api/v1/58f61be4dda40 node server.mjs
import express from 'express';
import { sheetdb } from './sheetdb.mjs';

const app = express();
app.use(express.json());

// GET /people?name=Ste* – list or search people
app.get('/people', async (req, res, next) => {
  try {
    const query = req.query.name ? '/search?' + new URLSearchParams({ name: req.query.name }) : '';
    res.json(await sheetdb(query));
  } catch (error) {
    next(error);
  }
});

// POST /people { "name": "Test User", "age": 35 } – add a row with the next id
app.post('/people', async (req, res, next) => {
  try {
    const { name, age } = req.body;

    if (!name || !Number.isInteger(age)) {
      return res.status(422).json({ error: 'name and an integer age are required' });
    }

    await sheetdb('', { method: 'POST', body: { data: [{ id: 'INCREMENT', name, age }] } });
    res.status(201).json({ created: 1 });
  } catch (error) {
    next(error);
  }
});

// PATCH /people/4 { "comment": "vip" } – update one row by id
app.patch('/people/:id', async (req, res, next) => {
  try {
    res.json(await sheetdb('/id/' + encodeURIComponent(req.params.id), {
      method: 'PATCH',
      body: { data: { comment: String(req.body.comment ?? '') } },
    }));
  } catch (error) {
    if (error.message.startsWith('SheetDB 404')) {
      return res.status(404).json({ error: 'Person not found' });
    }
    next(error);
  }
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

Try it with curl localhost:3000/people?name=Ste* or curl -X POST localhost:3000/people -H "Content-Type: application/json" -d '{"name":"Test User","age":35}', then look at the spreadsheet: the new row is there. The same structure works in Fastify, Next.js route handlers or a serverless function, since it only needs fetch().

Using the sheetdb-node package

If you prefer a client object over raw HTTP calls, or you are stuck on a Node.js version without fetch(), there is the open source sheetdb-node package. Install it with npm install sheetdb-node:

import sheetdb from 'sheetdb-node';

const client = sheetdb({ address: '58f61be4dda40' });
// With HTTP Basic Auth: sheetdb({ address: '...', auth_login: '...', auth_password: '...' })

// read() resolves with the response body as a JSON string
const people = JSON.parse(await client.read({ limit: 2 }));
const special = JSON.parse(await client.read({ search: { comment: 'special' } }));

await client.create({ id: 'INCREMENT', name: 'William', age: 25 });

// Update every row of Sheet2 where player is Smith
await client.update(
  'player', // column name
  'Smith', // value to search for
  { score: 99, comment: 'Updated' }, // new values
  'Sheet2' // tab
);

await client.delete('name', 'William');

Two differences from the helper above: read() resolves with the raw response body, a JSON string, so you call JSON.parse() yourself; and the promise resolves even when the API returns an error status, so check the parsed body for an error key. The package supports HTTP Basic Auth through auth_login and auth_password. For new projects on Node.js 18+ the plain fetch() helper is the simpler choice.

SheetDB vs googleapis and google-spreadsheet

SheetDB + fetch()googleapis / google-spreadsheet
SetupSign in, paste the sheet URLCloud project, Sheets API, service account, JSON key, share the sheet
DependenciesNone on Node.js 18+npm packages plus Google auth libraries
Data shapeArray of objects keyed by column nameArrays of cell values and A1 ranges (google-spreadsheet adds row objects)
Search, sort, paginateQuery parameters on the APILoad rows and filter in your code
CachingBuilt in, 15 s by defaultBuild your own
Formatting, charts, sheet structureRows, columns and tabs onlyFull access to every Sheets feature
CostFree plan, then paid by requestsFree, within Google's quotas

A longer comparison, including pricing and quotas, is on the SheetDB vs Google Sheets API page. Choose Google's API when you need formatting, formulas management, charts or other spreadsheet features beyond rows. Choose SheetDB when the sheet is a table of records and you want to read and write it with as little setup as possible.

Limitations

  • Not a high-traffic database. Google throttles how often a spreadsheet can be read and written. The cache absorbs read traffic, but thousands of writes per minute need a real database.
  • Large tabs are slower. Google returns the whole tab before filtering, so response time grows with the number of rows. Archive old rows to another tab or spreadsheet.
  • No transactions or relations. Two clients updating the same row at once both succeed, and the last write wins. Use an id column and keep related data in separate tabs.
  • Strings by default. Values arrive as strings unless you use cast_numbers; dates arrive in the format of the sheet.

FAQ

Do I need the googleapis package or a service account to use Google Sheets in Node.js?

No. With Google's own API you create a Google Cloud project, enable the Sheets API, create a service account, download its JSON key and share the spreadsheet with the service account's e-mail address. With SheetDB you sign in with Google once, paste the spreadsheet URL and get an HTTPS endpoint. Your Node.js code only calls that URL with fetch(), so there are no Google credentials in your project at all.

Which Node.js version do I need?

Node.js 18 or newer, where fetch() is built in, runs every example on this page without installing anything. The examples use ES modules (.mjs files or "type": "module" in package.json) for top-level await. They work the same in TypeScript, Bun and Deno; on a runtime without process.env, put the API URL into the helper from your platform's configuration. On an older Node.js version use the sheetdb-node package described above.

How fast is reading a Google Sheet from Node.js?

SheetDB caches read responses for 15 seconds by default, so repeated reads are answered from the cache without calling Google. A request that has to reach Google takes longer and grows with the size of the tab, because Google returns the whole tab before SheetDB filters it. Keep tabs small, use limit and /search, and cache in your own app if the data rarely changes. The cache docs explain how to change the duration.

Can I call the same API from the browser instead of Node.js?

Yes, the API sends CORS headers, so the same fetch() calls work in front-end JavaScript. The difference is that everything in the browser is public: the API URL and any credentials. Read public data from the browser if you like, but keep writes, Basic Auth and Bearer tokens on the Node.js side. For read-only pages see how to display Google Sheets data on a website.

Why are numbers returned as strings?

Google Sheets returns formatted cell values, so by default every value in the JSON is a string, for example "age": "22". Add cast_numbers=age (a comma-separated list of columns) to a read or search request and those columns come back as JSON numbers. Writes accept both strings and numbers.

Is the SheetDB API free for Node.js projects?

There is a free plan with 2 APIs and 500 requests a month, no credit card required, which is enough to build and test an integration. Every HTTP call counts as one request whether it returns one row or a thousand, so batch inserts into one POST and cache reads in your app to stay within the quota.

Want to use the sheet as a database?

With read, search, create, update and delete covered, a spreadsheet can act as the database behind your Node.js app. See how to use Google Sheets as a database for sheet structure tips and the limits to keep in mind before you rely on it in production. The same API works from PHP, from a plain HTML form and from no-code tools such as n8n and Make.

Connect your spreadsheet to Node.js

Sign in with Google, paste the spreadsheet URL and call it with fetch(). Free plan, no credit card, no Google Cloud setup.

Create free API

Have a question?

Ask about the API or your sheet. Developers answer, usually the same day.

Necessary cookies keep SheetDB working. With your permission we also use chat, analytics and marketing cookies. You can change your choice anytime in Cookie settings. Cookies Policy