Using Google Sheets as a REST API in Remix
A practical guide to integrating SheetsAPI with Remix (React Router v7) - covering loaders, actions, typed responses, caching, and error boundaries.
If you've ever wanted to use a Google Sheet as a lightweight data store without standing up a full database, SheetsAPI gives you a clean REST interface to do exactly that. This post walks through integrating it with Remix (React Router v7), covering loaders, actions, typed responses, caching, and error boundaries.
What SheetsAPI Gives You
SheetsAPI exposes your Google Sheets over HTTP with a simple contract:
- Base URL:
https://sheetsapi.io/api/spreadsheets/{userKey}/{sheetName} - GET - read rows, with optional
?limit,?offset, and?filter[column]=valuequery params - POST - append a new row by sending a JSON body
- Auth -
Authorization: Bearer sk_...header on every request - Response shape -
{ data: [...], meta: { total, limit, offset } }
That's enough to build real features: product catalogs, waitlists, feedback forms, CMS-backed pages.
Setting Up the Client
Create a thin wrapper so the API key and base URL live in one place.
// app/lib/sheetsapi.server.ts
const BASE_URL = "https://sheetsapi.io/api/spreadsheets";
const USER_KEY = process.env.SHEETSAPI_KEY!; // sk_...
export interface SheetRow {
[key: string]: string | number | boolean | null;
}
export interface SheetResponse<T extends SheetRow> {
data: T[];
meta: {
total: number;
limit: number;
offset: number;
};
}
interface GetOptions {
limit?: number;
offset?: number;
filter?: Record<string, string>;
}
export async function getRows<T extends SheetRow>(
sheetName: string,
options: GetOptions = {},
): Promise<SheetResponse<T>> {
const url = new URL(`${BASE_URL}/${USER_KEY}/${sheetName}`);
if (options.limit !== undefined) url.searchParams.set("limit", String(options.limit));
if (options.offset !== undefined) url.searchParams.set("offset", String(options.offset));
if (options.filter) {
for (const [col, val] of Object.entries(options.filter)) {
url.searchParams.set(`filter[${col}]`, val);
}
}
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${USER_KEY}` },
});
if (!res.ok) {
throw new Error(`SheetsAPI error: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<SheetResponse<T>>;
}
export async function appendRow<T extends SheetRow>(sheetName: string, row: T): Promise<void> {
const res = await fetch(`${BASE_URL}/${USER_KEY}/${sheetName}`, {
method: "POST",
headers: {
Authorization: `Bearer ${USER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(row),
});
if (!res.ok) {
throw new Error(`SheetsAPI error: ${res.status} ${res.statusText}`);
}
}The .server.ts suffix ensures this module never ships to the browser. Your API key stays on the server.
Reading Data in a Loader
Loaders run on the server before the route renders. Pull your sheet data here and return it typed.
// app/routes/products.tsx
import { data } from "react-router";
import type { Route } from "./+types/products";
import { useLoaderData } from "react-router";
import { getRows } from "~/lib/sheetsapi.server";
interface Product {
id: string;
name: string;
price: string;
category: string;
inStock: string;
}
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const category = url.searchParams.get("category") ?? undefined;
const page = Number(url.searchParams.get("page") ?? "1");
const limit = 20;
const offset = (page - 1) * limit;
const result = await getRows<Product>("products", {
limit,
offset,
filter: category ? { category } : undefined,
});
return data(result, {
headers: {
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
},
});
}
export default function Products() {
const { data: products, meta } = useLoaderData<typeof loader>();
return (
<main>
<h1>Products</h1>
<p>{meta.total} items</p>
<ul>
{products.map((p) => (
<li key={p.id}>
<strong>{p.name}</strong> - ${p.price}
</li>
))}
</ul>
</main>
);
}React Router v7 uses data() in place of the older json() helper. The Route.LoaderArgs type is generated from your route tree - no manual typing of request needed. The Cache-Control header tells CDNs and browsers to serve cached responses for 60 seconds, then revalidate in the background for up to 5 minutes. For mostly-static sheet data this drastically cuts the number of API calls you make.
Writing Data in an Action
Actions handle form submissions and mutations. Use them to append rows via POST.
// app/routes/waitlist.tsx
import { data, redirect } from "react-router";
import type { Route } from "./+types/waitlist";
import { useActionData, Form } from "react-router";
import { appendRow } from "~/lib/sheetsapi.server";
interface ActionData {
error?: string;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = formData.get("email");
const name = formData.get("name");
if (typeof email !== "string" || !email.includes("@")) {
return data<ActionData>(
{ error: "A valid email address is required." },
{ status: 400 }
);
}
try {
await appendRow("waitlist", {
email,
name: typeof name === "string" ? name : "",
joinedAt: new Date().toISOString(),
});
} catch (err) {
console.error(err);
return data<ActionData>(
{ error: "Could not save your signup. Please try again." },
{ status: 502 }
);
}
return redirect("/waitlist/thanks");
}
export default function Waitlist() {
const actionData = useActionData<typeof action>();
return (
<main>
<h1>Join the Waitlist</h1>
{actionData?.error && (
<p role="alert" style={{ color: "red" }}>
{actionData.error}
</p>
)}
<Form method="post">
<label>
Name
<input name="name" type="text" />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<button type="submit">Sign up</button>
</Form>
</main>
);
}<Form> from React Router works with progressive enhancement - the action runs on the server, and useActionData gives you the response without any manual fetch wiring on the client. The form works even with JavaScript disabled.
Error Boundaries
If SheetsAPI is unreachable or returns a non-2xx response, your loader will throw. React Router v7 catches this at the route level with an ErrorBoundary export.
// app/routes/products.tsx (add to same file)
import { useRouteError, isRouteErrorResponse } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<main>
<h1>{error.status} - {error.statusText}</h1>
<p>Could not load products. Try again shortly.</p>
</main>
);
}
return (
<main>
<h1>Something went wrong</h1>
<p>An unexpected error occurred while fetching data.</p>
</main>
);
}This keeps errors scoped to the route that failed. The rest of your app - nav, sidebar, other routes - keeps rendering normally. For the appendRow case in the action, the error is caught and returned as ActionData rather than thrown, so the form stays visible with the error message inline.
Pagination with the meta Field
SheetsAPI always returns meta.total, which makes cursor-free pagination straightforward.
// app/routes/products.tsx - add inside the component
export default function Products() {
const { data: products, meta } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const currentPage = Number(searchParams.get("page") ?? "1");
const totalPages = Math.ceil(meta.total / meta.limit);
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<nav>
{currentPage > 1 && <Link to={`?page=${currentPage - 1}`}>Previous</Link>}
<span>
{" "}
Page {currentPage} of {totalPages}{" "}
</span>
{currentPage < totalPages && <Link to={`?page=${currentPage + 1}`}>Next</Link>}
</nav>
</main>
);
}Page state lives in the URL. The loader re-runs with the new ?page= param, applies the correct offset, and returns the right slice. Browser history, shareable links, and back-button behavior all work with zero client-side state management.
Caching Strategy
The right caching approach depends on how fresh your data needs to be:
- Static content (pricing, FAQ):
Cache-Control: public, max-age=3600- cache for an hour at the edge - Semi-dynamic (product catalog):
Cache-Control: public, max-age=60, stale-while-revalidate=300- fresh within 5 minutes - User-specific data:
Cache-Control: private, max-age=0, must-revalidate- no shared caching
For forms and actions, never set a cache header on the response - React Router handles this correctly by default.
If you're deploying to Cloudflare Pages or Vercel, the stale-while-revalidate directive is respected at the edge, so most requests never hit your server at all.
Environment Setup
Add your key to .env:
SHEETSAPI_KEY=sk_your_key_here
Reference it in app/lib/sheetsapi.server.ts via process.env.SHEETSAPI_KEY. On Cloudflare Workers or Pages, use platform secrets (wrangler secret put SHEETSAPI_KEY) and access them via context.cloudflare.env.SHEETSAPI_KEY passed into the loader through the Route.LoaderArgs context.
What You Get
With about 60 lines of server code you have a typed, cacheable, progressively enhanced integration between a Google Sheet and a Remix app. The sheet stays editable by non-engineers. The loader/action pattern keeps API credentials off the client. Error boundaries prevent a failing data source from taking down the whole page.
SheetsAPI works well for content that editors manage directly in Sheets - waitlists, product listings, event schedules, changelog entries - and React Router v7's server-first model is a natural fit for it.
Ready to connect your first Sheet? Sign up for SheetsAPI and get an API key in under two minutes.