Using Google Sheets as a Backend for Next.js Apps
Step-by-step guide to fetching, filtering, and displaying Google Sheets data in a Next.js app using GKit SheetsAPI — no backend code required.
Google Sheets is already familiar to every non-engineer on your team. Rows and columns,
editable in a browser, no deployment required. With SheetsAPI you
can expose any tab as a JSON REST endpoint and pull that data directly into a Next.js Server
Component — no API routes, no database setup, no useEffect.
This works surprisingly well for a specific class of app: product catalogs, team directories, event listings, FAQ pages, pricing tables — anything where the data is small, mostly read, and edited by humans. This guide walks through the full setup.
Prerequisites
- A Next.js 13+ app using the App Router
- A GKit account (free while SheetsAPI is in beta)
- A Google Sheet with data you want to display
Connecting your Sheet to GKit
After signing in to GKit, go to SheetsAPI and add your Google Sheet. GKit asks you to share the sheet with its service account — a one-time step — and then assigns you two identifiers:
userKey— your GKit account key, shown in your dashboardsheetName— the exact name of the tab you want to expose (e.g.Products)
The first row of your tab becomes the field names in every JSON response. A sheet with columns name, price, category returns rows shaped as { name: "...", price: "...", category: "..." }.
Once connected, the endpoint is:
GET https://sheetsapi.gkit.mreshank.com/api/spreadsheets/{userKey}/{sheetName}
You can test it in the browser or with curl before touching any Next.js code:
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_KEY/Products"The response looks like:
{
"data": [
{ "name": "Wireless Headphones", "price": "79", "category": "Audio" },
{ "name": "USB-C Hub", "price": "45", "category": "Accessories" }
],
"meta": {
"total": 24,
"limit": 100,
"offset": 0
}
}Fetching data in a Server Component
The App Router's Server Components fetch at render time — no client JavaScript, no hydration cost. Here is a minimal page that loads your sheet data:
// app/products/page.tsx
const SHEETS_BASE = "https://sheetsapi.gkit.mreshank.com/api";
type Product = {
name: string;
price: string;
category: string;
};
type SheetsResponse<T> = {
data: T[];
meta: { total: number; limit: number; offset: number };
};
async function getProducts(): Promise<SheetsResponse<Product>> {
const res = await fetch(
`${SHEETS_BASE}/spreadsheets/${process.env.GKIT_USER_KEY}/Products`,
{ next: { revalidate: 60 } } // ISR: re-fetch at most once per minute
);
if (!res.ok) {
throw new Error(`SheetsAPI error: ${res.status}`);
}
return res.json();
}
async function ProductsPage() {
const { data: products, meta } = await getProducts();
return (
<main>
<h1>Products ({meta.total})</h1>
<ul>
{products.map((p) => (
<li key={p.name}>
{p.name} — ${p.price}
</li>
))}
</ul>
</main>
);
}Store your userKey in .env.local as GKIT_USER_KEY so it never appears in client bundles.
The next: { revalidate: 60 } option tells Next.js to serve a cached response and quietly
refresh it in the background every 60 seconds — a good default for content that changes a
few times a day.
Filtering, sorting, and selecting fields
SheetsAPI accepts query parameters that let you push work to the API instead of filtering in JavaScript. The full set:
| Parameter | What it does |
|---|---|
search=field:value | Substring match on a specific field |
search_exact=1 | Require an exact match instead of substring |
sort=fieldname | Sort ascending by that field |
sort=-fieldname | Sort descending (note the - prefix) |
fields=a,b,c | Return only the listed fields |
limit | Number of rows (max 1000) |
offset | Skip this many rows (use with limit for pagination) |
Here is a typed helper that composes these parameters from a plain options object:
// lib/sheets.ts
const SHEETS_BASE = "https://sheetsapi.gkit.mreshank.com/api";
type SheetQueryOptions = {
search?: string; // "field:value"
searchExact?: boolean;
sort?: string; // "fieldname" or "-fieldname"
fields?: string[];
limit?: number;
offset?: number;
};
type SheetsResponse<T> = {
data: T[];
meta: { total: number; limit: number; offset: number };
};
async function querySheet<T>(
sheetName: string,
options: SheetQueryOptions = {}
): Promise<SheetsResponse<T>> {
const params = new URLSearchParams();
if (options.search) params.set("search", options.search);
if (options.searchExact) params.set("search_exact", "1");
if (options.sort) params.set("sort", options.sort);
if (options.fields?.length) params.set("fields", options.fields.join(","));
if (options.limit != null) params.set("limit", String(options.limit));
if (options.offset != null) params.set("offset", String(options.offset));
const url = `${SHEETS_BASE}/spreadsheets/${process.env.GKIT_USER_KEY}/${sheetName}?${params}`;
const res = await fetch(url, { next: { revalidate: 60 } });
if (!res.ok) throw new Error(`SheetsAPI ${res.status}: ${url}`);
return res.json();
}Using it in a page:
// app/products/page.tsx
// import { querySheet } from "@/lib/sheets";
type Product = { name: string; price: string; category: string };
async function ProductsPage({
searchParams,
}: {
searchParams: { category?: string; sort?: string };
}) {
const { data: products, meta } = await querySheet<Product>("Products", {
search: searchParams.category
? `category:${searchParams.category}`
: undefined,
sort: searchParams.sort ?? "-price",
fields: ["name", "price", "category"],
limit: 50,
});
return (
<main>
<p>{meta.total} products found</p>
{/* render products */}
</main>
);
}Because this is a Server Component, the searchParams come from the URL and the filtered fetch happens on the server — the client receives HTML, not a JSON payload it has to filter itself.
Rendering the data in a table
A simple, accessible table component:
// components/ProductTable.tsx
type Product = { name: string; price: string; category: string };
function ProductTable({ products }: { products: Product[] }) {
if (products.length === 0) {
return <p>No products match your search. Try a different category.</p>;
}
return (
<div style={{ overflowX: "auto" }}>
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Category</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.name}>
<td>{p.name}</td>
<td>{p.category}</td>
<td>${p.price}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Wrapping the table in a scrollable container keeps it usable on narrow screens without breaking the page layout.
Optional: locking down with an API key
By default SheetsAPI endpoints are public — anyone with the URL can read the data. That is
fine for a public catalog. For anything that should stay private (an internal directory,
draft content, pricing data), create an API key in the GKit dashboard and include it as a
Bearer token:
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.GKIT_API_KEY}`,
},
next: { revalidate: 60 },
});Add GKIT_API_KEY to .env.local and to your deployment environment variables (Vercel, Railway, etc.). Because the fetch runs server-side, the key is never exposed in the browser.
When not to use Sheets as a backend
Sheets-as-a-backend is not appropriate for every situation. Be honest with yourself about the fit:
- High write volume. The Google Sheets API has per-minute write quotas. If users are submitting forms, creating records, or triggering updates frequently, you will hit rate limits. Use a real database.
- Relational data. Sheets has no joins. If your data naturally lives in multiple related tables, the query params above will not save you — the mismatch will surface in every feature.
- Transactions. There is no atomicity. Two concurrent writes can race. Do not store anything where partial writes cause data corruption (orders, payments, inventory counts).
- Authentication or secrets. Never store passwords, tokens, or personal data in a sheet. Even a private sheet connected through an API key is not an appropriate credential store.
- More than ~10,000 rows. Performance degrades, the
limitcap of 1,000 becomes awkward, and you will spend more time working around the tool than working with it.
If you are starting a project that is going to grow, Sheets is a fast way to get a working data layer on day one. Just plan your migration path before you need it.
Conclusion
The pattern here — public Google Sheet, SheetsAPI REST layer, Next.js Server Component with
fetch — covers a real category of projects: content-driven sites where a non-engineer
needs to update the data, prototypes that need a working backend before the real one is
built, internal tools where the audience is small and the data is simple.
The full query API (search, sort, fields, limit, offset) means you are not stuck
loading every row on every request. And because the fetch happens in a Server Component,
there is no client JavaScript involved at all — just rendered HTML with fresh data baked in
at request time or on a revalidation schedule you control.
SheetsAPI is free during beta. Connect your first sheet →