Server-Side Google Sheets Data in SvelteKit with the GKit API
Load Google Sheets data in SvelteKit server load functions, cache with built-in fetch, and type it with Zod.
SvelteKit's +page.server.ts load functions run entirely on the server - before the page is
rendered and before any JavaScript reaches the browser. That makes them a natural fit for
GKit SheetsAPI: your API key stays private, the data arrives pre-rendered
in the HTML, and SvelteKit's built-in fetch handles deduplication for you. No extra HTTP
library, no onMount, no loading spinner.
This guide builds a job board backed by a Google Sheet - from environment setup to Zod validation and CDN caching.
The sheet
Create a Google Sheet with one row of headers followed by your job data:
| title | company | location | type | salary | posted_date |
|---|---|---|---|---|---|
| Senior Frontend Engineer | Acme Corp | San Francisco, CA | onsite | $160k–$200k | 2026-06-28 |
| Product Designer | Bloom Studio | New York, NY | hybrid | $120k–$150k | 2026-06-27 |
| Backend Engineer | Remote Labs | - | remote | $130k–$170k | 2026-06-29 |
Column headers become the field names in every API response. Connect the sheet in your
GKit dashboard to get your userKey and an API key.
Your endpoint will be:
GET https://api.gkit.io/api/spreadsheets/{userKey}/Jobs
Supported query parameters:
| Parameter | Purpose |
|---|---|
search=field:value | Exact match filter (e.g. search=type:remote) |
sort | Column name, prefix - to sort descending |
limit / offset | Pagination |
fields | Comma-separated list of columns to return |
Environment variables
Add both keys to .env.local (never commit this file):
GKIT_USER_KEY=uk_your_key_here
GKIT_API_KEY=sk_your_key_here
SvelteKit exposes server-only variables through $env/static/private. Importing from this
module in any file under src/routes or src/lib/server prevents the values from ever
reaching a browser bundle - SvelteKit enforces this at build time.
The server load function
// src/routes/jobs/+page.server.ts
import type { PageServerLoad } from "./$types";
import { GKIT_USER_KEY, GKIT_API_KEY } from "$env/static/private";
import { z } from "zod";
// --- Zod schema -----------------------------------------------------------
const JobSchema = z.object({
title: z.string(),
company: z.string(),
location: z.string(),
type: z.enum(["remote", "onsite", "hybrid"]),
salary: z.string(),
posted_date: z.string(),
});
type Job = z.infer<typeof JobSchema>;
const ResponseSchema = z.object({
data: z.array(JobSchema),
meta: z.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
});
// --- Load function --------------------------------------------------------
export const load: PageServerLoad = async ({ fetch, url, setHeaders }) => {
// Forward the visitor's ?type= param to SheetsAPI as a search filter
const type = url.searchParams.get("type");
const params = new URLSearchParams({
sort: "-posted_date",
limit: "50",
});
if (type) {
params.set("search", `type:${type}`);
}
const endpoint = `https://api.gkit.io/api/spreadsheets/${GKIT_USER_KEY}/Jobs?${params}`;
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${GKIT_API_KEY}` },
});
if (!res.ok) {
throw new Error(`GKit API error ${res.status}`);
}
const raw = await res.json();
const { data: jobs, meta } = ResponseSchema.parse(raw);
// Tell the CDN it can cache this response for 60 seconds
setHeaders({ "Cache-Control": "public, max-age=60" });
return { jobs, meta, activeType: type };
};A few things worth calling out:
SvelteKit's fetch, not the global one. The fetch argument injected into load functions
is SvelteKit's enhanced version. It deduplicates concurrent requests to the same URL across
server-side loads and handles relative URLs correctly in SSR. Always use it instead of the
global fetch.
setHeaders for CDN caching. Calling setHeaders inside the load function sets the
response headers for the rendered page. public, max-age=60 tells any CDN (or the browser's
cache) that the page is safe to cache for 60 seconds. For a job board updated a few times a
day, this reduces origin hits dramatically without serving stale data for long.
Zod at the boundary. ResponseSchema.parse(raw) throws a ZodError if the API shape
doesn't match - catching schema drift before it propagates into your components. If the sheet
gains a new column or a value is unexpectedly missing, you'll see an error at the data layer,
not a silent rendering failure.
The page component
<!-- src/routes/jobs/+page.svelte -->
<script lang="ts">
import type { PageData } from "./$types";
export let data: PageData;
const filters = ["all", "remote", "onsite", "hybrid"] as const;
</script>
<svelte:head>
<title>Job Board</title>
</svelte:head>
<header>
<h1>Open Positions <span>({data.meta.total})</span></h1>
<nav class="filters">
{#each filters as filter}
<a
href={filter === "all" ? "/jobs" : `/jobs?type=${filter}`}
class:active={
filter === "all" ? !data.activeType : data.activeType === filter
}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</a>
{/each}
</nav>
</header>
<ul class="job-list">
{#each data.jobs as job}
<li class="job-card">
<div class="job-header">
<h2>{job.title}</h2>
<span class="badge badge--{job.type}">{job.type}</span>
</div>
<p class="company">{job.company}</p>
<p class="meta">{job.location} · {job.salary}</p>
<p class="date">Posted {job.posted_date}</p>
</li>
{/each}
</ul>Because data comes from a server load function, export let data is all you need - Svelte
infers the type from PageData, which is auto-generated from your load function's return type.
No type assertion, no manual interface to maintain.
The filter links change the URL (?type=remote), which triggers SvelteKit to re-run the server
load function with the new search param. The page re-renders with filtered results, URL is
bookmarkable, and no client-side fetch code is needed.
Handling errors gracefully
If the GKit API is unavailable or returns an unexpected shape, the load function above throws.
SvelteKit will render your nearest +error.svelte. Add one alongside the page:
<!-- src/routes/jobs/+error.svelte -->
<script lang="ts">
import { page } from "$app/stores";
</script>
<h1>Could not load jobs</h1>
<p>{$page.error?.message}</p>
<a href="/jobs">Try again</a>For finer control, import error from @sveltejs/kit and throw error(502, "...") with an
explicit HTTP status instead of a plain Error.
What you have now
- A server load function that reads a Google Sheet via GKit, filters by URL param, validates with Zod, and sets CDN cache headers - all in one file.
- A component that receives fully-typed data and renders job cards with no client-side fetching.
- Filter links that drive navigation without any client-side state management.
The Google Sheet is the data source. Content editors add or update rows directly. No redeploy needed - the cached page expires within 60 seconds and the next request fetches the updated data automatically.
Ready to connect your first sheet? Create a free GKit account - your API key is ready in under a minute, and the free tier covers most small projects. The SheetsAPI docs cover every query parameter and authentication option.