Fetching Google Sheets data in React and Next.js: what actually works
A practical guide to reading and writing Google Sheets data from React and Next.js - the patterns developers actually reach for, without the OAuth overhead.
If you have spent any time in r/reactjs or r/nextjs, you have seen this thread. Someone is building a small project - a team directory, an event list, a product catalog - and they want to pull data from a Google Sheet. The answers that come back span everything from "just use the CSV export URL" to "set up a service account." Most of them are either wrong for the use case or far more work than the problem warrants.
This post covers the four patterns that actually exist, what breaks in each, and when to reach for which one.
Why this keeps coming up
The appeal is obvious. A Google Sheet is already a structured data store that non-engineers can edit in a browser. You do not need to deploy anything, run a migration, or teach a content editor to use a CMS. For read-mostly data - pricing tables, FAQs, team rosters, event schedules - it is genuinely a reasonable choice.
The friction is the official path. Google's Sheets API v4 requires an OAuth flow or a service account JSON key, a server to hold the credentials, and a fair amount of boilerplate to get a simple array of rows back. For a weekend project or a static site, that cost is disproportionate.
So developers reach for shortcuts. Some of those shortcuts work. Some of them worked until Google changed something. Here is a clear-eyed look at all four.
The four approaches
Google Sheets API v4 (official)
The official API is the right answer for large production applications. It gives you full read/write access, respects sheet permissions, and is not going anywhere.
The cost is real: you register a Google Cloud project, enable the Sheets API, create a service account or OAuth credentials, and handle token refresh. In a Next.js app, your credentials live in environment variables on the server; you never expose them to the browser. The fetch logic is a few dozen lines before you get to the data.
When to use it: apps where you need write access at scale, fine-grained permission control, or you are already in the Google Cloud ecosystem.
When to skip it: any project where the overhead of a GCP project and credential rotation is larger than the value of the data you are serving.
CSV export URL trick
Google Sheets exposes a public CSV download URL for any sheet shared with "Anyone with the link":
https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={GID}
You fetch it, parse the CSV, and you have rows. r/webdev surfaces this one constantly because it requires zero setup and works in the browser.
The problems: it is read-only. It does not have any query parameters for filtering or sorting.
The URL format has changed before and Google makes no promises about stability. You are also
parsing raw CSV in a useEffect, which is more fragile than it looks - commas inside quoted
fields, Unicode edge cases, empty trailing rows.
When to use it: quick prototypes where you accept the risk of it breaking and do not need to write data back.
When to skip it: anything you plan to keep running.
Apps Script doGet with JSON.stringify
You write a short Apps Script that doGet-serves your sheet as JSON. It works, and it is
fully under your control.
The problems stack up quickly. Cold starts on the free tier are noticeable - 2 to 5 seconds
is common. CORS requires either a proxy or the HtmlService workaround. Deployment is a
manual step through the script editor. There is no structured query interface, so any
filtering logic lives in the script itself or in your client code.
r/googleappsscript has dozens of threads about intermittent 503s and CORS errors that disappear and reappear with no clear pattern.
When to use it: when you already have Apps Script in your workflow and you need logic that is tightly coupled to the sheet (computed columns, triggered writes, etc.).
When to skip it: when you want a predictable, low-latency REST endpoint you did not have to author.
SheetsAPI REST endpoint
SheetsAPI is a purpose-built layer that turns a shared Google Sheet into a structured REST endpoint. It runs on Cloudflare Workers, so there are no cold starts. It is CORS-enabled, which means you can call it directly from the browser - no proxy required. It is free during beta and open source under MIT.
The endpoint shape:
GET https://sheetsapi.gkit.mreshank.com/api/spreadsheets/{userKey}/{sheetName}
Query parameters cover the cases you actually need:
| Parameter | What it does |
|---|---|
search=field:value | Substring match on a specific field |
search_exact=1 | Require an exact match |
sort=fieldname | Sort ascending |
sort=-fieldname | Sort descending |
fields=a,b,c | Return only listed fields |
limit | Row count (max 1000) |
offset | Skip rows (pair with limit for pagination) |
The rest of this post uses SheetsAPI for the code examples.
React: fetching in a useEffect
For a client-rendered React app, useEffect with useState is the standard pattern. Here
is a complete, production-ready version with loading and error states:
import { useState, useEffect } from "react";
const ENDPOINT = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/TeamDirectory";
type TeamMember = {
name: string;
role: string;
department: string;
};
type SheetsResponse<T> = {
data: T[];
meta: { total: number; limit: number; offset: number };
};
export function TeamDirectory() {
const [members, setMembers] = useState<TeamMember[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`${ENDPOINT}?sort=name&fields=name,role,department`, {
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
const json: SheetsResponse<TeamMember> = await res.json();
setMembers(json.data);
setTotal(json.meta.total);
} catch (err) {
if ((err as Error).name !== "AbortError") {
setError((err as Error).message);
}
} finally {
setLoading(false);
}
}
load();
return () => controller.abort();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Could not load team data: {error}</p>;
return (
<section>
<h2>Team ({total})</h2>
<ul>
{members.map((m) => (
<li key={m.name}>
<strong>{m.name}</strong> - {m.role}, {m.department}
</li>
))}
</ul>
</section>
);
}The AbortController is worth the extra lines. It prevents a state update on an unmounted
component if the user navigates away before the fetch resolves - a common source of React
warnings in development.
Next.js: Server Component (no useEffect needed)
In the App Router, an async Server Component fetches at render time on the server. No client JavaScript, no loading spinner, no hydration. The data is baked into the HTML.
// app/team/page.tsx
type TeamMember = {
name: string;
role: string;
department: string;
};
type SheetsResponse<T> = {
data: T[];
meta: { total: number; limit: number; offset: number };
};
async function getTeam(): Promise<SheetsResponse<TeamMember>> {
const res = await fetch(
"https://sheetsapi.gkit.mreshank.com/api/spreadsheets/" +
process.env.GKIT_USER_KEY +
"/TeamDirectory?sort=name&fields=name,role,department",
{ next: { revalidate: 300 } },
);
if (!res.ok) {
throw new Error(`SheetsAPI error: ${res.status}`);
}
return res.json();
}
export default async function TeamPage() {
const { data: members, meta } = await getTeam();
return (
<main>
<h1>Team ({meta.total})</h1>
<ul>
{members.map((m) => (
<li key={m.name}>
<strong>{m.name}</strong> - {m.role}, {m.department}
</li>
))}
</ul>
</main>
);
}Store GKIT_USER_KEY in .env.local. Because this fetch runs server-side, the key never
appears in a client bundle.
Next.js: Route Handler as a proxy
Sometimes you want to hide the SheetsAPI endpoint - either to add your own auth, transform
the response, or avoid exposing your userKey to client-side network requests. A Route
Handler handles this cleanly:
// app/api/team/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const res = await fetch(
"https://sheetsapi.gkit.mreshank.com/api/spreadsheets/" +
process.env.GKIT_USER_KEY +
"/TeamDirectory?sort=name",
{
headers: {
Authorization: `Bearer ${process.env.GKIT_API_KEY}`,
},
next: { revalidate: 300 },
},
);
if (!res.ok) {
return NextResponse.json({ error: "Failed to load team data" }, { status: 502 });
}
const data = await res.json();
return NextResponse.json(data);
}Your client code then fetches /api/team instead of the SheetsAPI URL directly. You control
what gets exposed and what gets filtered. The GKIT_API_KEY stays on the server.
Writing data: POST from a React form
SheetsAPI supports POST requests to append a new row. This is useful for contact forms, waitlist signups, event registrations - anything where you want submissions to land in a sheet without standing up a database.
import { useState, FormEvent } from "react";
const ENDPOINT = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/Signups";
export function SignupForm() {
const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">("idle");
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("submitting");
const form = e.currentTarget;
const data = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
submitted_at: new Date().toISOString(),
};
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`${res.status}`);
setStatus("done");
} catch {
setStatus("error");
}
}
if (status === "done") return <p>You are on the list.</p>;
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Submitting..." : "Join waitlist"}
</button>
{status === "error" && <p>Something went wrong. Try again.</p>}
</form>
);
}The keys in the JSON body become column values in the sheet. If a column does not exist yet, SheetsAPI ignores it. If a key matches a column header exactly, the value lands in that cell.
Do not use this pattern for high-frequency writes. Google Sheets has per-minute quota limits on the write API. For form submissions at human pace it is fine; for programmatic bulk inserts it is not.
Caching SheetsAPI responses in Next.js
Next.js fetch accepts a next option that controls caching behavior. Three patterns cover
most cases:
Revalidate on a schedule (ISR):
fetch(url, { next: { revalidate: 300 } });
// Serve cached response, refresh in background every 5 minutesNo cache (always fresh):
fetch(url, { cache: "no-store" });
// Fetch on every request - use for data that changes constantlyCache until manually invalidated:
fetch(url, { next: { tags: ["team-data"] } });
// Invalidate with revalidateTag("team-data") from a Server Action or route handlerThe revalidateTag approach is useful when you want a CMS-style editorial workflow: the
sheet is the CMS, and you call revalidateTag from a webhook or a button in an admin panel
to flush the cache on demand.
For most read-mostly use cases, revalidate: 300 is the right starting point. It means the
data is at most 5 minutes stale, and the user never waits on the upstream fetch - they always
get the cached version while the refresh happens in the background.
The pattern that holds up
The approach that comes up least in those r/nextjs and r/reactjs threads - but works best in practice - is to stop trying to talk directly to Google's API from the browser and use a proper REST layer instead.
SheetsAPI is that layer. You connect a sheet once, get a URL, and call it like any other API. CORS is handled. Query parameters replace client-side filtering. POST appends rows. The endpoint runs on Cloudflare Workers with no cold starts.
It is free in beta. The source is MIT-licensed. If you want to self-host it on your own Cloudflare account, you can - the instructions are in the docs.
For the Next.js path: an async Server Component with { next: { revalidate: 300 } } is
the cleanest implementation. No client JavaScript, no loading states, no hydration. The data
arrives as HTML.
For the React path: the useEffect pattern above with an AbortController and proper error
state handles everything a real component needs.
Both are stable. Neither depends on URL formats Google has not promised to keep.
Connect your first sheet and try it →
GKit also includes Drive Cleaner for finding and removing duplicate files in Google Drive, and 50+ free developer tools for common tasks across the Google Workspace ecosystem. About GKit →