All posts
Blog

Pagination with Google Sheets API: limit, offset, and page numbers

How to implement pagination when reading Google Sheets data through SheetsAPI — using limit and offset parameters with real code examples.

2 min read

Why pagination matters for Sheets data

A Google Sheet with a few hundred rows loads fine in a browser. A sheet with 10,000 rows is a different story — fetching everything at once is slow, burns bandwidth, and makes your UI render a wall of data nobody asked for. Pagination solves this by fetching a small slice at a time.

GKit SheetsAPI has first-class pagination support through two query parameters: limit and offset. Every list response also returns a meta object that tells you the total row count, so you can calculate pages and render navigation without a separate count request.

The limit and offset parameters

ParameterDefaultMaxDescription
limit1001000Number of rows to return
offset0Number of rows to skip (0-based)

A basic paginated request looks like this:

GET https://sheetsapi.gkit.mreshank.com/api/spreadsheets/{userKey}/{sheet}?limit=20&offset=40

That returns rows 41–60 (skip the first 40, return the next 20).

The response always includes a meta block:

{
  "data": [...],
  "meta": {
    "total": 243,
    "limit": 20,
    "offset": 40
  }
}

meta.total is the full row count of the sheet — not just the current page. Use it to calculate total pages and decide whether a "next page" button should be shown.

Calculating total pages

function totalPages(total: number, limit: number): number {
  return Math.ceil(total / limit);
}
 
// total: 243, limit: 20 → 13 pages

To fetch a specific page number (1-based):

const BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
 
async function fetchPage(
  userKey: string,
  sheet: string,
  page: number,
  pageSize = 20
) {
  const offset = (page - 1) * pageSize;
  const url = new URL(`${BASE}/${userKey}/${sheet}`);
  url.searchParams.set("limit", String(pageSize));
  url.searchParams.set("offset", String(offset));
 
  const res = await fetch(url.toString(), {
    headers: { Authorization: `Bearer ${process.env.GKIT_API_KEY}` },
  });
  return res.json() as Promise<{
    data: Record<string, string>[];
    meta: { total: number; limit: number; offset: number };
  }>;
}

Building a page-number UI in React

import { useState, useEffect } from "react";
 
const PAGE_SIZE = 20;
 
export function PaginatedTable({ userKey, sheet }: { userKey: string; sheet: string }) {
  const [page, setPage] = useState(1);
  const [rows, setRows] = useState<Record<string, string>[]>([]);
  const [total, setTotal] = useState(0);
 
  useEffect(() => {
    fetchPage(userKey, sheet, page, PAGE_SIZE).then(({ data, meta }) => {
      setRows(data);
      setTotal(meta.total);
    });
  }, [userKey, sheet, page]);
 
  const pages = Math.ceil(total / PAGE_SIZE);
 
  return (
    <div>
      <table>
        <tbody>
          {rows.map((row, i) => (
            <tr key={i}>
              {Object.values(row).map((cell, j) => <td key={j}>{cell}</td>)}
            </tr>
          ))}
        </tbody>
      </table>
 
      <nav>
        <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
          Previous
        </button>
        <span>Page {page} of {pages}</span>
        <button onClick={() => setPage(p => Math.min(pages, p + 1))} disabled={page === pages}>
          Next
        </button>
      </nav>
    </div>
  );
}

Infinite scroll with offset accumulation

For a feed-style UI, accumulate rows instead of replacing them. Track the current offset and append each new page to the existing list:

const [rows, setRows] = useState<Record<string, string>[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
 
async function loadMore() {
  const { data, meta } = await fetchPage(userKey, sheet, 1, PAGE_SIZE);
  // use raw offset instead
  const url = new URL(`${BASE}/${userKey}/${sheet}`);
  url.searchParams.set("limit", String(PAGE_SIZE));
  url.searchParams.set("offset", String(offset));
 
  const res = await fetch(url.toString(), {
    headers: { Authorization: `Bearer ${process.env.GKIT_API_KEY}` },
  });
  const json = await res.json();
 
  setRows(prev => [...prev, ...json.data]);
  setOffset(prev => prev + PAGE_SIZE);
  setHasMore(offset + PAGE_SIZE < json.meta.total);
}

Wire loadMore to an Intersection Observer on a sentinel element at the bottom of the list and you have a fully functional infinite scroll — no external library needed.

A note on limit max

The maximum limit is 1000. If your sheet has fewer than 1000 rows and you want everything in one request, set limit=1000. For sheets larger than 1000 rows you must paginate — there is no way to fetch all rows in a single call.

What's next

Share