Use Google Sheets as a CMS with Svelte 5
Learn how to use Google Sheets as a lightweight CMS for your Svelte 5 app using GKit SheetsAPI - fetch products, filter by category, and ship without a database.
Why Google Sheets makes sense as a CMS
Not every project needs a database. If you're building a product catalog, a small blog, a team directory, or an event listing, Google Sheets gives you a familiar editing interface that non-technical collaborators can actually use - no CMS login, no admin panel, no data migrations when requirements change.
The missing piece has always been the API layer. Google Sheets' native API requires OAuth, service accounts, and enough boilerplate to make the whole idea feel not worth it. GKit SheetsAPI solves this: you get a clean REST endpoint over any spreadsheet you connect, with filtering, sorting, and pagination built in. Combined with Svelte 5's runes-based reactivity, you can wire up a fully dynamic, data-driven page in under 100 lines.
Setting up your sheet
Start with a Google Sheet that has column headers in the first row. For this tutorial we'll build a product catalog. Set up your columns like this:
| id | name | category | price | in_stock | description |
|---|---|---|---|---|---|
| 1 | Wool Throw Blanket | home | 49.00 | true | Chunky knit, 100% merino |
| 2 | Ceramic Pour-Over | kitchen | 34.00 | true | 600ml, matte black glaze |
| 3 | Linen Desk Pad | office | 28.00 | false | 90×40cm, natural linen |
Once your sheet is ready, connect it in the GKit dashboard. You'll get a userKey (your account identifier) and you'll reference the sheet by its tab name - in this case products.
Your base URL will be:
https://api.gkit.io/api/spreadsheets/{userKey}/products
Fetching data on load
Svelte 5 introduces runes - a new reactivity primitive that replaces the $: syntax and writable stores. $state declares reactive state, $effect runs side effects when dependencies change, and $derived computes values from other state.
Here's a component that fetches the full product list when it mounts:
<script>
const API_BASE = 'https://api.gkit.io/api/spreadsheets/uk_abc123/products';
const API_KEY = import.meta.env.VITE_GKIT_API_KEY;
let products = $state([]);
let meta = $state({ total: 0, limit: 50, offset: 0 });
let loading = $state(true);
let error = $state(null);
async function fetchProducts(params = {}) {
loading = true;
error = null;
const url = new URL(API_BASE);
for (const [key, value] of Object.entries(params)) {
if (value !== '' && value !== null) url.searchParams.set(key, value);
}
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` }
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const json = await res.json();
products = json.data;
meta = json.meta;
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
$effect(() => {
fetchProducts({ limit: 50, sort: 'name' });
});
</script>
{#if loading}
<p>Loading products…</p>
{:else if error}
<p class="error">{error}</p>
{:else}
<p>{meta.total} products</p>
{#each products as product (product.id)}
<div class="product-card">
<h3>{product.name}</h3>
<p>{product.category} - ${product.price}</p>
<span class:in-stock={product.in_stock === 'true'}>
{product.in_stock === 'true' ? 'In stock' : 'Out of stock'}
</span>
</div>
{/each}
{/if}The $effect block runs once after the component mounts. Because fetchProducts is called inside $effect, Svelte tracks it as a side effect - not reactive state - so it fires exactly once unless you add reactive dependencies inside the effect body.
Adding search and filter
The SheetsAPI search parameter accepts a field:value format for exact matches. The fields parameter limits which columns are returned - useful for list views where you don't need every column.
Let's add a category filter and a text search that both trigger a re-fetch reactively:
<script>
const API_BASE = 'https://api.gkit.io/api/spreadsheets/uk_abc123/products';
const API_KEY = import.meta.env.VITE_GKIT_API_KEY;
let products = $state([]);
let meta = $state({ total: 0, limit: 20, offset: 0 });
let loading = $state(false);
let selectedCategory = $state('');
let currentPage = $state(0);
const PAGE_SIZE = 20;
const offset = $derived(currentPage * PAGE_SIZE);
async function fetchProducts() {
loading = true;
const url = new URL(API_BASE);
url.searchParams.set('limit', PAGE_SIZE);
url.searchParams.set('offset', offset);
url.searchParams.set('sort', 'name');
url.searchParams.set('fields', 'id,name,category,price,in_stock');
if (selectedCategory) {
url.searchParams.set('search', `category:${selectedCategory}`);
}
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` }
});
const json = await res.json();
products = json.data;
meta = json.meta;
loading = false;
}
$effect(() => {
// Re-runs whenever selectedCategory or currentPage changes
selectedCategory;
currentPage;
fetchProducts();
});
function setCategory(cat) {
selectedCategory = cat;
currentPage = 0; // reset to first page on filter change
}
</script>
<div class="filters">
<button onclick={() => setCategory('')}>All</button>
<button onclick={() => setCategory('home')}>Home</button>
<button onclick={() => setCategory('kitchen')}>Kitchen</button>
<button onclick={() => setCategory('office')}>Office</button>
</div>
{#if loading}
<p>Loading…</p>
{:else}
<div class="grid">
{#each products as product (product.id)}
<div class="card">
<strong>{product.name}</strong>
<span>${product.price}</span>
</div>
{/each}
</div>
<div class="pagination">
<button disabled={currentPage === 0} onclick={() => currentPage--}>
Previous
</button>
<span>Page {currentPage + 1} of {Math.ceil(meta.total / PAGE_SIZE)}</span>
<button
disabled={offset + PAGE_SIZE >= meta.total}
onclick={() => currentPage++}
>
Next
</button>
</div>
{/if}Reading selectedCategory and currentPage inside the $effect body is what makes Svelte track them as dependencies. When either value changes, the effect re-runs and a fresh request goes out.
A note on API keys in the browser
The example above uses import.meta.env.VITE_GKIT_API_KEY to expose the key to the browser. This is fine for read-only public data - a product catalog or a public blog - where the sheet content is not sensitive. If your sheet contains private data, make the API call from a SvelteKit server route (+page.server.ts or a +server.ts endpoint) so the key never leaves the server.
What you just built
In under 100 lines you have a Svelte 5 component that:
- Fetches paginated data from a Google Sheet on mount
- Re-fetches reactively when the category filter changes
- Resets to page one when a new filter is applied
- Uses
$derivedto keep the offset calculation in sync automatically
The content team edits the spreadsheet. The app stays up to date. No CMS, no database, no deployment needed when the product list changes.
Ready to connect your first sheet? Create a free GKit account - you get your API key in under a minute, and the free tier covers most small projects. If you hit questions along the way, the SheetsAPI docs cover every query parameter in detail.