SvelteKit Integration Guide
UnfoldCMS works as a headless backend for SvelteKit. Same fetch() pattern as Next.js and Astro — no SvelteKit-specific package required.
Setup
# .env
PUBLIC_CMS_URL=https://your-cms.com
UNFOLDCMS_WEBHOOK_SECRET=your-webhook-secret
Blog Index
// src/routes/blog/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch(`${process.env.PUBLIC_CMS_URL}/api/v1/posts?per_page=20`);
if (!res.ok) throw new Error('Failed to load posts');
const { data: posts, pagination } = await res.json();
return { posts, pagination };
};
<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
export let data;
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post}
<li>
<a href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</a>
</li>
{/each}
</ul>
Single Post
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => {
const res = await fetch(`${process.env.PUBLIC_CMS_URL}/api/v1/posts/${params.slug}`);
if (res.status === 404) throw error(404, 'Post not found');
if (!res.ok) throw error(500, 'CMS error');
const { data: post } = await res.json();
return { post };
};
Webhook Revalidation
SvelteKit pages cached by your reverse proxy (Vercel, Netlify, Cloudflare) can be invalidated via webhook:
// src/routes/api/revalidate/+server.ts
import { json, error } from '@sveltejs/kit';
import crypto from 'node:crypto';
export async function POST({ request }) {
const raw = await request.text();
const sig = request.headers.get('signature') || '';
const expected = crypto
.createHmac('sha256', process.env.UNFOLDCMS_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
throw error(401, 'Invalid signature');
}
const { event, data } = JSON.parse(raw);
// Trigger CDN cache invalidation for the affected URL
// (provider-specific — example for Vercel)
if (event === 'post.published' || event === 'post.updated') {
await fetch(`https://api.vercel.com/v1/integrations/deploy/${process.env.VERCEL_HOOK_ID}`, {
method: 'POST',
});
}
return json({ ok: true });
}
Register the webhook in UnfoldCMS pointing at https://your-site.com/api/revalidate.
Layout with Site Settings
// src/routes/+layout.server.ts
export const load = async ({ fetch }) => {
const res = await fetch(`${process.env.PUBLIC_CMS_URL}/api/v1/settings/public`);
const { data: settings } = await res.json();
return { settings };
};
<!-- src/routes/+layout.svelte -->
<script lang="ts">
export let data;
</script>
<header>
<img src={data.settings['general.logo_url']} alt={data.settings['general.site_name']} />
<h1>{data.settings['general.site_name']}</h1>
</header>
<slot />
Categories
// src/routes/category/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
export const load = async ({ fetch, params }) => {
const [catRes, postsRes] = await Promise.all([
fetch(`${process.env.PUBLIC_CMS_URL}/api/v1/categories/${params.slug}`),
fetch(`${process.env.PUBLIC_CMS_URL}/api/v1/categories/${params.slug}/posts`),
]);
if (catRes.status === 404) throw error(404, 'Category not found');
const { data: category } = await catRes.json();
const { data: posts, pagination } = await postsRes.json();
return { category, posts, pagination };
};
Search
// src/routes/search/+page.server.ts
export const load = async ({ fetch, url }) => {
const q = url.searchParams.get('q') || '';
if (q.length < 2) return { q, results: null };
const res = await fetch(
`${process.env.PUBLIC_CMS_URL}/api/v1/search?q=${encodeURIComponent(q)}`
);
const { data: results } = await res.json();
return { q, results };
};
Common Pitfalls
- Use the framework
fetch, not globalfetch— passfetchfromloadparameters so SvelteKit handles caching and SSR. - Always set
Accept: application/jsonon auth endpoints to ensure JSON 401s instead of HTML redirects. - Image URLs are absolute. Don't prepend the CMS URL —
featured_image.largeis complete. - Don't commit admin tokens. Use
$env/dynamic/privatefor any token that ships inloadfunctions.