All posts
Blog

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.

5 min read

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 dashboard
  • sheetName — 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:

ParameterWhat it does
search=field:valueSubstring match on a specific field
search_exact=1Require an exact match instead of substring
sort=fieldnameSort ascending by that field
sort=-fieldnameSort descending (note the - prefix)
fields=a,b,cReturn only the listed fields
limitNumber of rows (max 1000)
offsetSkip 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 limit cap 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 →

Share