Using Google Sheets as a Database in SolidStart
Build a SolidStart application that reads and writes Google Sheets data via the GKit SheetsAPI - with server functions, createAsync, and fine-grained reactivity.
SolidStart is SolidJS's full-stack framework: it gives you file-based routing, server functions with "use server", and createAsync - a data-fetching primitive that wires directly into SolidJS's reactive graph. Google Sheets makes a surprisingly capable backend for content-driven or team-managed data. Pair the two through the GKit SheetsAPI and you get a proper REST layer over your spreadsheet, without writing a backend.
This post covers reads with createAsync, writes with action() and useAction(), and TypeScript types throughout.
The GKit SheetsAPI contract
Every request goes to one base URL:
https://api.gkit.io/api/spreadsheets/{userKey}/{sheetName}
- GET - read rows. Optional query params:
search,sort,order,limit,offset,fields. - POST - append a row. Send a JSON body matching your column names.
- Auth -
Authorization: Bearer sk_...header on every request. - Response shape -
{ data: [...], meta: { total, limit, offset } }.
Environment setup
Create a SolidStart project and add your GKit key to .env:
npx create-solidstart@latest sheets-demo
cd sheets-demo# .env
GKIT_API_KEY=sk_your_secret_key
GKIT_USER_KEY=your_user_key
SolidStart exposes .env values on the server through process.env. They are never serialised into the client bundle, which is exactly what you want for an API key.
TypeScript types
Define the shape of your sheet rows and the API response before writing any fetch logic:
// src/lib/types.ts
export interface ContactRow {
id: string;
name: string;
email: string;
company: string;
}
export interface SheetResponse<T> {
data: T[];
meta: {
total: number;
limit: number;
offset: number;
};
}
export interface SheetParams {
search?: string;
sort?: string;
order?: "asc" | "desc";
limit?: number;
offset?: number;
fields?: string;
}Reading rows with a server function and createAsync
SolidStart's "use server" directive strips a function from the client bundle entirely - it becomes an RPC call. Wrap it with cache from @solidjs/router so concurrent SSR requests with identical arguments share one fetch instead of firing duplicates.
// src/lib/sheets.ts
import { cache } from "@solidjs/router";
import type { ContactRow, SheetParams, SheetResponse } from "./types";
const BASE = `https://api.gkit.io/api/spreadsheets/${process.env.GKIT_USER_KEY}/contacts`;
export const fetchContacts = cache(
async (params: SheetParams = {}): Promise<SheetResponse<ContactRow>> => {
"use server";
const url = new URL(BASE);
if (params.search) url.searchParams.set("search", params.search);
if (params.sort) url.searchParams.set("sort", params.sort);
if (params.order) url.searchParams.set("order", params.order);
if (params.limit) url.searchParams.set("limit", String(params.limit));
if (params.offset) url.searchParams.set("offset", String(params.offset));
if (params.fields) url.searchParams.set("fields", params.fields);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${process.env.GKIT_API_KEY}` },
});
if (!res.ok) throw new Error(`GKit error ${res.status}`);
return res.json();
},
"contacts",
);createAsync in the component consumes this function and re-runs it whenever its reactive inputs change:
// src/routes/contacts.tsx
import { createAsync, createSignal } from "@solidjs/router";
import { For, Show, Suspense } from "solid-js";
import { fetchContacts } from "~/lib/sheets";
const PAGE_SIZE = 20;
export default function ContactsPage() {
const [search, setSearch] = createSignal("");
const [page, setPage] = createSignal(0);
// Recomputed whenever search() or page() changes - createAsync re-fires automatically
const result = createAsync(() =>
fetchContacts({
search: search() ? `name:${search()}` : undefined,
sort: "name",
order: "asc",
limit: PAGE_SIZE,
offset: page() * PAGE_SIZE,
}),
);
return (
<main>
<input
placeholder="Search contacts"
value={search()}
onInput={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
/>
<Suspense fallback={<p>Loading…</p>}>
<Show when={result()} keyed>
{(res) => (
<>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Company</th>
</tr>
</thead>
<tbody>
<For each={res.data}>
{(row) => (
<tr>
<td>{row.name}</td>
<td>{row.email}</td>
<td>{row.company}</td>
</tr>
)}
</For>
</tbody>
</table>
<div>
<button disabled={page() === 0} onClick={() => setPage((p) => p - 1)}>
Previous
</button>
<span>
Page {page() + 1} of {Math.ceil(res.meta.total / PAGE_SIZE)}
</span>
<button
disabled={(page() + 1) * PAGE_SIZE >= res.meta.total}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</>
)}
</Show>
</Suspense>
</main>
);
}createAsync differs from createResource in one important way: it integrates with SolidStart's route preloading system. When you export a preload function from your route, SolidStart will call it on navigation - createAsync picks up the cached result instantly so the component renders without a loading flash.
Writing rows with action and useAction
SolidStart's action() is the write counterpart to server functions. Define it outside the component so it is stable across renders:
// src/lib/actions.ts
import { action, revalidate } from "@solidjs/router";
import type { ContactRow } from "./types";
const BASE = `https://api.gkit.io/api/spreadsheets/${process.env.GKIT_USER_KEY}/contacts`;
export const addContact = action(async (form: FormData) => {
"use server";
const name = String(form.get("name") ?? "");
const email = String(form.get("email") ?? "");
const company = String(form.get("company") ?? "");
if (!name || !email) {
throw new Error("Name and email are required");
}
const res = await fetch(BASE, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
email,
company,
} satisfies Partial<ContactRow>),
});
if (!res.ok) throw new Error(`GKit write error ${res.status}`);
// Invalidate the cached query so the list refetches
await revalidate("contacts");
}, "add-contact");Wire it into a component with useAction:
// inside ContactsPage, or a separate AddContactForm component
import { useAction, useSubmission } from "@solidjs/router";
import { addContact } from "~/lib/actions";
import { Show } from "solid-js";
function AddContactForm() {
const submit = useAction(addContact);
const submission = useSubmission(addContact);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
const form = new FormData(e.currentTarget as HTMLFormElement);
await submit(form);
(e.currentTarget as HTMLFormElement).reset();
}
return (
<form onSubmit={handleSubmit}>
<Show when={submission.error}>
<p role="alert">{submission.error.message}</p>
</Show>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<input name="company" placeholder="Company" />
<button type="submit" disabled={submission.pending}>
{submission.pending ? "Saving…" : "Add contact"}
</button>
</form>
);
}useSubmission gives you pending, error, and result - enough to show loading state and surface validation errors without managing any local state. The revalidate("contacts") call in the action uses the same cache key passed to cache() earlier, so the list refreshes automatically after a successful write.
createAsync vs createResource
Both primitives read async data reactively, but they have different roles in SolidStart:
createAsync | createResource | |
|---|---|---|
| Integrates with route preloading | Yes | No |
Works with cache() | Yes, preferred | Yes, but manual |
| Suspense boundary | Required | Required |
| Re-fetches on signal change | Yes, automatic | Yes, via source argument |
| Best for | Route-level data, SSR | Dynamic client-side fetching |
For route data that should be server-rendered and preloaded on navigation, createAsync with cache() is the right choice. For data that only exists on the client or is fetched in response to user interaction after the page loads, createResource is still appropriate.
What you have built
A SolidStart application with server-side reads from a Google Sheet - API key never reaching the browser, SSR on first load, automatic cache invalidation after writes, and fine-grained reactivity updating only the DOM nodes that actually changed.
The same pattern extends to any sheet in your spreadsheet: change the sheet name in the base URL, update the row type, and the rest of the code is identical.
Get your API key at gkit.io/signup and your first sheet is queryable in under two minutes.