SvelteKit + Headless CMS: Fetch, Cache, Preview Guide
TL;DR
Wire a headless CMS into SvelteKit with a plain fetch() call inside +page.server.ts, no SDK, no query language. Keep the API key server-only through $env/dynamic/private, pick adapter-static or an SSR adapter depending on how fast content needs to appear, serve images through the CMS's own CDN instead of downloading them at build time, route locales with Paraglide plus the CMS's ?language= param, and gate draft previews behind a secret checked in hooks.server.ts.
SvelteKit already tells you how to fetch data, cache it, and route locales. A load function is fetch() with a place to run. Most CMS integration guides ask you to set that aside for a client library or a schema DSL. This guide skips that and stays inside SvelteKit's own patterns, load functions, $env modules, adapters, the image-CDN advice from SvelteKit's own docs, Paraglide routing, each answered with a REST call and an x-api-key header against Adapto CMS's public API, with no SDK to install and no query language to learn.
Why REST fits SvelteKit better than an SDK
Sanity's SvelteKit docs lead with GROQ, a query language you write client- or server-side to shape exactly the JSON you want back. It's expressive, and it's also a second language layered on top of the one you already know: your load function goes from a fetch call to a query composed in a syntax only one vendor uses. Every other framework you touch either needs its own GROQ client or drops back to REST anyway.
A REST-only CMS skips that trade. /v1/articles returns JSON. /v1/articles/by-slug/{slug} returns one article. There's no client library between your code and the response, so there's nothing to break when SvelteKit ships a new major version or renames an adapter package. REST vs GraphQL for a headless CMS covers the broader case for REST over GraphQL and GROQ-style query languages; this guide sticks to the SvelteKit-specific mechanics.
Two things line up well here. SvelteKit's load functions run on the server by default and pass you a fetch, so a plain REST call is already the native pattern, built into the framework rather than bolted on. And SvelteKit's env-variable system splits public from private at the module level, so a header-based API key like Adapto's x-api-key has an obvious, enforced home: a .server.ts file, never a .svelte component.
Fetching content: +page.server.ts vs +page.ts
SvelteKit gives you two kinds of load function, and picking the right one is most of this integration.
+page.server.ts runs only on the server. It executes once per request during server-side rendering and never re-runs in the browser, even during client-side navigation between routes. That makes it the correct place for anything that touches a private API key or a database.
+page.ts (a "universal" load function) runs on the server for the first request, then runs again in the browser every time a user navigates to that route client-side. Because it's reachable by client code, it can't import anything private. SvelteKit's build tooling enforces this: try to import $env/static/private from a +page.ts file and the build fails before you ever ship it.
For a CMS that authenticates with a header key, +page.server.ts is the default. Start a small wrapper so the auth header lives in one place:
// src/lib/server/adapto.ts
import { ADAPTO_API_KEY, ADAPTO_API_URL } from '$env/static/private';
export async function adaptoFetch(path: string, fetchFn: typeof fetch = fetch) {
const res = await fetchFn(`${ADAPTO_API_URL}${path}`, {
headers: { 'x-api-key': ADAPTO_API_KEY }
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Adapto API error ${res.status}: ${path}`);
}
return res.json();
}A 404 from the API resolves to null instead of throwing, so a by-slug lookup further down can check for a missing article and hand back SvelteKit's own 404 page instead of a generic error. Any other non-2xx status still throws, so a real outage fails the request loudly instead of rendering a blank page.
Placing that file under src/lib/server/ gives SvelteKit itself a reason to enforce the boundary. SvelteKit treats anything inside a server/ directory (or a .server.ts file) as server-only and refuses to bundle it for the client, so even an accidental import from a .svelte component fails the build instead of quietly shipping a key.
A list page fetches directly:
// src/routes/articles/+page.server.ts
import { adaptoFetch } from '$lib/server/adapto';
export async function load({ fetch }) {
const { items: articles } = await adaptoFetch('/v1/articles?status=published', fetch);
return { articles };
}Use the fetch that SvelteKit passes into the load function's arguments instead of the global one. SvelteKit's version resolves relative URLs correctly during SSR and lets one load function's request get picked up and deduplicated by a parent layout's load function if they hit the same URL. For an external API call with an absolute URL like this one, the practical difference is smaller, but using the provided fetch consistently saves you from having to track which load functions need it and which don't.
<!-- src/routes/articles/+page.svelte -->
<script lang="ts">
let { data } = $props();
</script>
<ul>
{#each data.articles as article}
<li><a href={`/articles/${article.slug}`}>{article.title}</a></li>
{/each}
</ul> The response shape is { items, total, page, limit, pages }, so items is what you map over. For a dynamic route, fetch by slug instead of pulling the full list:
// src/routes/articles/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import { adaptoFetch } from '$lib/server/adapto';
export async function load({ params, fetch }) {
const article = await adaptoFetch(`/v1/articles/by-slug/${params.slug}`, fetch);
if (!article) throw error(404, 'Article not found');
return { article };
}/v1/articles/by-slug/{slug} returns the single matching article directly, so there's no items array to unwrap. If a load function needs to re-run on the client without a full page navigation (live-updating pagination, a filter that shouldn't reload the shell), route it through a +server.ts endpoint that calls Adapto server-side, and have a +page.ts universal load call that internal endpoint instead of the CMS directly. The key never leaves the server; the universal load function only talks to your own app.
Env and keys: the four $env modules
SvelteKit splits environment variables along two axes: public vs. private, and static (inlined at build time) vs. dynamic (read at request time). That gives four modules:
| Module | Read at | Reaches the client? |
|---|---|---|
$env/static/private | build time | no |
$env/static/public | build time | yes, if prefixed PUBLIC_ |
$env/dynamic/private | request time | no |
$env/dynamic/public | request time | yes, if prefixed PUBLIC_ |
Only variables prefixed PUBLIC_ are eligible for the public modules at all; everything else is private by default, whether or not anyone meant to expose it. That default matters more than it sounds: an ADAPTO_API_KEY variable is private automatically, with no extra step required to keep it off the client.
The static-vs-dynamic axis decides when the value gets baked in. $env/static/private and $env/static/public are inlined during the Vite build, so their values are fixed at deploy time and any change means a rebuild. $env/dynamic/private and $env/dynamic/public are read from the running process at request time, so a platform that injects environment variables into a running container (most Node, Vercel, or Cloudflare deployments) can rotate a key without a rebuild.
For ADAPTO_API_KEY, $env/dynamic/private is the safer default on any SSR deployment: it keeps the key server-only, and it lets you rotate the key from your hosting platform's dashboard without redeploying the app. On a fully static build (more on that below), there's no running server left after the build finishes, so a "dynamic" read at build time behaves the same as a static one in practice, and $env/static/private works just as well.
// src/lib/server/adapto.ts (dynamic variant)
import { env } from '$env/dynamic/private';
export async function adaptoFetch(path: string, fetchFn: typeof fetch = fetch) {
const res = await fetchFn(`${env.ADAPTO_API_URL}${path}`, {
headers: { 'x-api-key': env.ADAPTO_API_KEY }
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Adapto API error ${res.status}: ${path}`);
}
return res.json();
}Adapters: prerendered content vs. rendered-per-request content
adapter-static turns a SvelteKit app into a folder of static files, the same execution model Astro's default build uses. Set export const prerender = true (globally in the root +layout.ts, or per route) and SvelteKit runs each load function once at build time and serves the output from a CDN. Content published in Adapto only shows up on the site's next rebuild. That's a fine trade for a marketing site or a docs section; it means adding a rebuild step (a webhook from Adapto's CLI-driven publish flow, or a scheduled build) if editors expect content to go live without a developer triggering a deploy.
An SSR adapter, adapter-node, adapter-vercel, or adapter-cloudflare, keeps load functions running per request (or per edge invocation, for platforms that run at the edge). A newly published article appears on the next page load with no rebuild at all, because the fetch to Adapto happens fresh every time. The cost is real too: you're paying for compute per request instead of serving pre-built files, and depending on the adapter, you take on some amount of running-server operational surface, though a managed serverless or edge adapter absorbs most of that for you.
Pick the adapter based on how editors publish. A site where content changes weekly is a good fit for adapter-static plus a webhook-triggered rebuild. A site where an editor expects "hit publish, refresh the page" to just work needs SSR.
Images: SvelteKit's own advice is to reach for a CDN
SvelteKit ships @sveltejs/enhanced-img for images that live in your repo: it processes them at build time and generates responsive, modern-format sizes. That pipeline works because Vite can see the file on disk when the build runs. It can't do the same for a URL that only exists at request time, which is what a CMS-hosted image is. SvelteKit's own image documentation says as much: for remote or CMS-sourced images, use an image CDN instead of asking the build to fetch and reprocess them.
Adapto bundles that CDN rather than leaving you to add one. Every asset is served from media.adaptocms.com, and resizing and format conversion are query parameters on the same URL, no separate service to configure:
https://media.adaptocms.com/uploads/cover.jpg?w=800&h=600&format=webp&quality=100 In a component, build that URL from whatever field the API already returns:
<script lang="ts">
let { data } = $props();
const { article } = data;
</script>
<img
src={`${article.cover_image.url}?w=800&h=450&format=webp&quality=100`}
width="800"
height="450"
alt={article.cover_image.alt}
loading="lazy"
/> The transform runs on Adapto's CDN, on every tier, with no add-on to turn on. There's nothing for @sveltejs/enhanced-img to do here; it's built for assets in your repo, and this asset lives in the CMS.
i18n: Paraglide routing plus Adapto's ?language=
Paraglide is the i18n library the Svelte team points teams toward, installed with npx sv add paraglide from SvelteKit's own CLI. It compiles a message catalog into small, typed functions at build time instead of shipping a runtime JSON bundle, and its SvelteKit integration adds a reroute hook (in src/hooks.ts) plus a server middleware wrapping src/hooks.server.ts. Together those translate a localized URL path like /ro/articles to the underlying route file and set the active locale for that request, so there's no hand-rolled [locale] directory to maintain the way a static-first framework without built-in routing hooks requires.
Inside a load function, read the resolved locale and pass it straight to Adapto's language query parameter:
// src/routes/[[locale]]/articles/+page.server.ts
import { getLocale } from '$lib/paraglide/runtime';
import { adaptoFetch } from '$lib/server/adapto';
export async function load({ fetch }) {
const locale = getLocale();
const { items: articles } = await adaptoFetch(
`/v1/articles?status=published&language=${locale}`,
fetch
);
return { articles, locale };
}Adapto pairs a translation to its source through translation_of_id, a field carried on every translated piece. That's useful past the basic locale filter: to list every translation of a given article regardless of language, filter on the source id directly:
const { items: translations } = await adaptoFetch(
`/v1/articles?translation_of_id=${sourceArticleId}`,
fetch
);That's how a language switcher stays anchored to "the same piece of content" even when each locale has its own slug. Translations are authored together with the source through the adapto CLI (create-translation), since the public REST API is read-only; the endpoint above only reads what's already published. To find out which locales a tenant has enabled before building a switcher, /v1/available-languages?tenant_id= returns the list; the available languages API docs cover the full response shape. There's no per-locale fee and no cap on how many languages a piece can have, so the switcher doesn't need to plan around running out of room.
Preview mode: wiring hooks.server.ts to /v1/articles/preview
SvelteKit has no built-in preview-mode primitive. The pattern most teams land on is a secret checked in hooks.server.ts, since handle runs before every request and can set state that later load functions read through event.locals.
TypeScript needs to know locals.preview exists before any of the code below type-checks. Declare it once, in src/app.d.ts:
// src/app.d.ts
declare global {
namespace App {
interface Locals {
preview: boolean;
}
}
}
export {};// src/hooks.server.ts
import { env } from '$env/dynamic/private';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const secret = event.url.searchParams.get('preview');
if (secret && secret === env.PREVIEW_SECRET) {
event.cookies.set('preview_mode', 'true', { path: '/', httpOnly: true });
}
event.locals.preview = event.cookies.get('preview_mode') === 'true';
return resolve(event);
};An editor working in Adapto opens a link with ?preview=<secret> appended. handle checks the secret against a private env var, sets a cookie on match, and every later request on that browser carries locals.preview = true.
/v1/articles/preview takes the same base query parameters as the list endpoint (page, limit, field, order, language) and returns a paginated list of draft articles in the same { items, total, page, limit, pages } shape as everything else. It doesn't take a slug filter, so finding one specific draft means pulling the list and matching it client-side:
// src/routes/articles/[slug]/+page.server.ts
import { adaptoFetch } from '$lib/server/adapto';
import { error } from '@sveltejs/kit';
export const prerender = false;
export async function load({ params, fetch, locals }) {
if (locals.preview) {
const drafts = await adaptoFetch('/v1/articles/preview?limit=100', fetch);
const draft = drafts.items.find((item) => item.slug === params.slug);
if (!draft) throw error(404, 'Draft not found');
return { article: draft, preview: true };
}
const article = await adaptoFetch(`/v1/articles/by-slug/${params.slug}`, fetch);
if (!article) throw error(404, 'Article not found');
return { article, preview: false };
}A visitor without the preview cookie falls straight through to /v1/articles/by-slug/{slug}, which only ever returns published content. export const prerender = false matters if the rest of the site runs on adapter-static: preview depends on a cookie read at request time, which a static build has no way to produce, so the preview-capable route has to opt out of prerendering and run through an SSR path even if everything else stays static.
The honest tradeoff: self-host once vs. pay monthly
Not every SvelteKit CMS pitch argues from fetch patterns and load functions. UnfoldCMS makes a different case: self-host once, stop paying a subscription, positioned as the anti-subscription option. That argument holds up on its own terms. A self-hosted CMS with no vendor invoice has no recurring bill once it's deployed and running.
It also hands you the database to run and patch yourself, backups included. None of that shows up as a monthly line item, so self-hosting reads as free next to a subscription. It costs engineering hours instead of dollars, and those hours carry a real cost whether or not anyone bills for them.
The choice comes down to what a team already runs. A team that operates infrastructure for other reasons absorbs a self-hosted CMS at close to zero marginal ops cost. A team where the CMS would be the first server anyone maintains pays for that "free" tier in engineering time instead of a subscription line.
Deploy and next steps
Match the adapter to how content gets published. adapter-static plus a rebuild webhook fits a site where content moves on a normal editorial cadence rather than by the minute. adapter-node, adapter-vercel, or adapter-cloudflare fits a team that wants "hit publish, refresh, see it" with no rebuild step, at the cost of running (or paying a platform to run) a server on every request.
Adapto ships an official SvelteKit starter with this pattern already wired: the server-only fetch wrapper, the env split, image CDN URLs, Paraglide routing, and preview mode connected end to end. Clone that before assembling the pieces above by hand. Get the content model right before writing a single route: the data models docs cover how types and fields work, and settling that shape early avoids re-templating every page once it's live.
If SvelteKit itself is still an open question against Astro or Next.js, SvelteKit CMS covers that decision directly. For the deeper case on why a REST API beats a query language or a GraphQL-only schema for this kind of integration, REST vs GraphQL for a headless CMS has the full argument, and our Astro build guide walks the same REST pattern against Astro's build-time model for anyone comparing frameworks side by side. Current tiers, all flat-rate with unlimited contributors and no metered overages, are published at Adapto's flat pricing.
FAQ
What's the best headless CMS for SvelteKit?
The criteria that matter for SvelteKit specifically: a plain REST API, since load functions already use fetch() and a GraphQL-only API or a proprietary query language like GROQ buys nothing extra, and infrastructure that respects SvelteKit's own server boundary: an API key that stays fully server-side through $env/dynamic/private or $env/static/private, plus a bundled media CDN so remote images don't need a separate service. Adapto CMS matches that shape: REST-first, no SDK, a server-only key by construction, media served from media.adaptocms.com. The criteria matter more than any single vendor's name.
How do I fetch CMS content in a SvelteKit load function?
Call fetch() inside a +page.server.ts load function, using the fetch passed into the load function's arguments rather than the global one. +page.server.ts runs only on the server, so it's the right place for anything needing a private API key. Return the fetched data from load and read it in +page.svelte through the page's data prop. Reserve +page.ts (universal load) for data that doesn't require a secret, since it also re-runs in the browser on client-side navigation.
How do I keep a CMS API key off the client in SvelteKit?
Import it from $env/dynamic/private or $env/static/private, never from a PUBLIC_-prefixed variable, and only inside modules that run on the server: +page.server.ts, +layout.server.ts, hooks.server.ts, or a module under src/lib/server/. SvelteKit's build fails if a client-reachable file tries to import a private env module, so the mistake surfaces at build time instead of showing up later in a shipped bundle.
How do I preview draft content in SvelteKit?
Check a secret query parameter in hooks.server.ts and set a cookie when it matches; read that cookie in later load functions via event.locals. With that flag set, load functions call /v1/articles/preview instead of /v1/articles/by-slug/{slug}, then match the requested slug against the returned list of drafts, so the same route renders a draft for an editor with the cookie and the published version for everyone else. Mark preview-dependent routes as non-prerenderable, since preview depends on per-request state that a static build can't produce.