Read and write Google Sheets data with Remix loaders and actions
Use SheetsAPI in Remix v2 loaders for server-side reads and actions for writes - full CRUD without a separate backend.
Remix's loader/action split is a natural fit for a read/write API like SheetsAPI. Loaders run on the server before the page renders, so your API key never touches the browser. Actions handle form submissions and mutations. Together, they give you full CRUD against a Google Sheet with almost no boilerplate.
The SheetsAPI contract
Every request goes to one base URL:
https://api.sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
- GET - read rows. Optional query params:
search=field:value,sort=field:asc,limit,offset,fields(comma-separated columns to return). - POST - append a new row. Send a JSON body with the column values.
- Auth -
Authorization: Bearer sk_...header on every request. - Response shape -
{ data: [...], meta: { total, limit, offset } }.
Your sk_... key stays in an environment variable. Remix loaders run on the server, so the key is never serialised into the client bundle.
Reading rows in a loader
Create a route at app/routes/contacts.tsx. The loader fetches the first page of contacts and passes the result to the component via useLoaderData.
// app/routes/contacts.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
const BASE = "https://api.sheetsapi.io/api/spreadsheets/YOUR_USER_KEY/contacts";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const limit = 20;
const offset = (page - 1) * limit;
const search = url.searchParams.get("q");
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (search) params.set("search", `name:${search}`);
const res = await fetch(`${BASE}?${params}`, {
headers: { Authorization: `Bearer ${process.env.SHEETS_API_KEY}` },
});
if (!res.ok) throw new Response("Failed to load contacts", { status: 502 });
const { data, meta } = await res.json();
return json({ contacts: data, meta, page });
}
export default function Contacts() {
const { contacts, meta, page } = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
return (
<main>
<input
placeholder="Search by name…"
onChange={(e) =>
setSearchParams((p) => {
p.set("q", e.target.value);
p.delete("page");
return p;
})
}
/>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{contacts.map((c: any) => (
<tr key={c.id}>
<td>{c.name}</td>
<td>{c.email}</td>
</tr>
))}
</tbody>
</table>
<div>
{page > 1 && (
<button
onClick={() =>
setSearchParams((p) => {
p.set("page", String(page - 1));
return p;
})
}
>
Previous
</button>
)}
{meta.offset + meta.limit < meta.total && (
<button
onClick={() =>
setSearchParams((p) => {
p.set("page", String(page + 1));
return p;
})
}
>
Next
</button>
)}
<span>{meta.total} total</span>
</div>
</main>
);
}Pagination is URL-driven: changing the page param triggers a full loader re-run. Bookmark, share, or browser-back - it all works without any client state.
Writing rows in an action
Remix actions handle POST, PUT, PATCH, and DELETE form submissions. Add an action export to the same file:
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const name = String(form.get("name") ?? "");
const email = String(form.get("email") ?? "");
if (!name || !email) {
return json({ error: "Name and email are required" }, { status: 422 });
}
const res = await fetch(BASE, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SHEETS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name, email }),
});
if (!res.ok) throw new Response("Write failed", { status: 502 });
return redirect("/contacts");
}And the form in the component - a plain HTML <form> using Remix's <Form> component:
import { Form, useActionData } from "@remix-run/react";
// inside the component:
const actionData = useActionData<typeof action>();
<Form method="post">
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Add contact</button>
</Form>;Because this is a real <form> with method="post", it works even with JavaScript disabled - Remix's progressive enhancement in practice.
Optimistic UI with useFetcher
For inline updates without full-page navigation, reach for useFetcher. It lets you post to any route's action and track the submission state:
import { useFetcher } from "@remix-run/react";
function AddContactCard() {
const fetcher = useFetcher<typeof action>();
const saving = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action="/contacts">
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
<button type="submit" disabled={saving}>
{saving ? "Saving…" : "Add"}
</button>
</fetcher.Form>
);
}fetcher.state cycles through "idle" → "submitting" → "loading" → "idle". You can render optimistic rows immediately by reading fetcher.formData before the action resolves - no extra state management needed.
Loader vs action vs fetcher
| Loader | Action | Fetcher | |
|---|---|---|---|
| When it runs | Before render (GET) | On form submit (POST/PUT/DELETE) | On demand, any time |
| Triggers re-render | Yes, via useLoaderData | Yes, revalidates loaders after | No full navigation |
| Good for | Reading, pagination, search | Creating, updating, deleting | Inline mutations, optimistic UI |
| API key exposure | Server-only | Server-only | Server-only (runs in action) |
Putting it together
The pattern is consistent across every sheet in your spreadsheet:
- Add a loader that reads from
GET /api/spreadsheets/YOUR_USER_KEY/{sheet}with any filters or pagination from the URL. - Add an action that posts to the same URL for writes.
- Use
<Form>for standard navigation,useFetcherfor in-place updates.
Your API key stays in process.env on the server. The client only ever sees the serialised JSON that json() returns. No extra backend, no separate API route, no leaking credentials.