UnfoldCMS + SvelteKit: Headless Integration Guide

Wire UnfoldCMS to SvelteKit with load functions, prerendering, and signed publish webhooks

UnfoldCMS + SvelteKit: Headless Integration Guide

SvelteKit's load functions make headless CMS wiring almost boring — fetch JSON on the server, return it, render it. This guide connects UnfoldCMS to SvelteKit end to end: post lists, single post routes, categories, search, prerendering, and automatic updates on publish with a signed webhook. Real code, about 30 minutes, and the honest limits. See also: the best CMS for SvelteKit and the Next.js integration guide.

TL;DR: UnfoldCMS runs on your own PHP host. SvelteKit fetches published content from /api/v1/posts (public, no auth) inside +page.server.js load functions. Render server-side for always-fresh content, or prerender to static HTML. When an editor publishes, UnfoldCMS fires an HMAC-signed post.published webhook at a SvelteKit endpoint or your host's build hook — so the site updates without a manual deploy. No GraphQL, no SDK, just fetch(). The API shipped in the v1 public API launch and is in every tier.

How Do UnfoldCMS and SvelteKit Fit Together?

UnfoldCMS is the content source; SvelteKit is the frontend. They talk over a plain REST API. The CMS stays on your server (it's Laravel — PHP and MySQL); the SvelteKit app deploys anywhere an adapter exists — Vercel, Netlify, Cloudflare, or a Node box.

Three moving parts:

  1. Read — server load functions call GET /api/v1/posts and GET /api/v1/posts/{slug} to pull published content.
  2. Render — SvelteKit renders on the server (SSR) or at build time (prerender). Public content needs no client-side keys.
  3. Refresh — on publish, UnfoldCMS POSTs a signed webhook that either rebuilds your static site or just confirms what SSR already gives you for free.

If you run full SSR, step 3 is optional — every request hits the API fresh. If you prerender, step 3 is what keeps your static pages from going stale.


What You Need Before Starting

  • A running UnfoldCMS install with at least one published post (any tier — the public read API ships in Core). Install steps are in the docs.
  • A SvelteKit 2 project (Svelte 5 syntax shown below; the load functions are identical on Svelte 4).
  • Your CMS base URL, e.g. https://cms.yoursite.com.
  • About 30 minutes. The fetch layer takes 10; the webhook wiring takes 20 if it's your first time.

You do not need an API key to read published content. The public endpoints are open, rate-limited to 60 requests per minute. Tokens are only for writes, which a frontend never does.

Here's the full public read surface you'll be working with:

Endpoint Method Returns
/api/v1/posts GET Paginated list of published posts
/api/v1/posts/{slug} GET Single post (404 for drafts/scheduled)
/api/v1/posts/{slug}/related GET Related posts for a given post
/api/v1/pages/{slug} GET Single published page
/api/v1/categories GET List of categories
/api/v1/categories/{slug}/posts GET Posts in a category
/api/v1/search?q= GET Site search results
/api/v1/menus/{location} GET Menu tree for a location
/api/v1/settings/public GET Public-allowlist settings

Every response is wrapped in a { success, message, data } envelope — unwrap data once in a helper and forget about it.


Step 1: Fetch Posts in a Load Function

Start with a tiny client in $lib. Two SvelteKit-specific details: read the CMS URL from $env/static/private (it never reaches the browser), and accept the fetch that SvelteKit hands to load functions — it dedupes requests during SSR and works in every adapter.

// src/lib/cms.js
import { CMS_URL } from '$env/static/private'; // https://cms.yoursite.com

export async function cms(path, fetcher = fetch) {
  const res = await fetcher(`${CMS_URL}/api/v1${path}`);
  if (!res.ok) throw new Error(`CMS ${path} returned ${res.status}`);
  const json = await res.json();
  return json.data; // unwrap the { success, message, data } envelope
}

Then load the blog index in a server load function. The .server.js suffix matters — it guarantees the code (and your CMS URL) stays on the server:

// src/routes/blog/+page.server.js
import { cms } from '$lib/cms';

export async function load({ fetch, url }) {
  const page = url.searchParams.get('page') ?? '1';
  const { data: posts } = await cms(`/posts?page=${page}`, fetch);
  return { posts };
}

And render it:

<!-- src/routes/blog/+page.svelte -->
<script>
  let { data } = $props();
</script>

<h1>Blog</h1>
<ul>
  {#each data.posts as post}
    <li><a href="/blog/{post.slug}">{post.title}</a></li>
  {/each}
</ul>

That's the whole data layer. No store, no client library, no loading spinners — the HTML arrives rendered.


Step 2: The Single Post Route

Create src/routes/blog/[slug]/+page.server.js. The API returns 404 for drafts and future-scheduled posts, so map a failed fetch to SvelteKit's error() helper and your 404 page handles the rest:

// src/routes/blog/[slug]/+page.server.js
import { error } from '@sveltejs/kit';
import { cms } from '$lib/cms';

export async function load({ params, fetch }) {
  try {
    const { post } = await cms(`/posts/${params.slug}`, fetch);
    return { post };
  } catch {
    error(404, 'Post not found');
  }
}

The body comes back as sanitized HTML — UnfoldCMS runs content through a purifier before serving it — so {@html} is the intended way to render it:

<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
  let { data } = $props();
</script>

<article>
  <h1>{data.post.title}</h1>
  {@html data.post.body}
</article>

Want a "keep reading" block under the article? One extra call to /posts/{slug}/related in the same load function, fired in parallel:

const [{ post }, related] = await Promise.all([
  cms(`/posts/${params.slug}`, fetch),
  cms(`/posts/${params.slug}/related`, fetch),
]);
return { post, related };

Categories and search follow the same pattern — a load function per route, one endpoint each. Category archives live at /api/v1/categories/{slug}/posts; full-site search is a single query param away at /api/v1/search?q=. Both are public, both come wrapped in the same envelope.

Category archive:

// src/routes/blog/category/[slug]/+page.server.js
import { error } from '@sveltejs/kit';
import { cms } from '$lib/cms';

export async function load({ params, fetch }) {
  try {
    const { data: posts } = await cms(`/categories/${params.slug}/posts`, fetch);
    return { posts, category: params.slug };
  } catch {
    error(404, 'Category not found');
  }
}

Search reads the query string and skips the API call when it's empty:

// src/routes/search/+page.server.js
import { cms } from '$lib/cms';

export async function load({ url, fetch }) {
  const q = url.searchParams.get('q') ?? '';
  if (!q.trim()) return { q, results: [] };
  const results = await cms(`/search?q=${encodeURIComponent(q)}`, fetch);
  return { q, results };
}

A plain <form action="/search"> with a text input named q drives this — no client-side JavaScript needed, though you can layer on progressive enhancement with use:enhance later. The same trick works for /api/v1/menus/{location} in a layout load function if you want the CMS to control your nav.

Want to test these endpoints before writing a line of Svelte? Hit them against the live demo — the API is open there too.


Step 4: SSR or Prerender? Pick Your Mode

SvelteKit gives you both per route, and that's the right way to think about it: SSR for things that change often, prerender for things that don't. Set it with one export.

To prerender the blog, add export const prerender = true and an entries() function so the build knows which slugs exist:

// src/routes/blog/[slug]/+page.server.js
export const prerender = true;

export async function entries() {
  const { data: posts } = await cms('/posts');
  return posts.map((post) => ({ slug: post.slug }));
}

Note that entries() runs at build time outside a request, so it uses the global fetch — which is why the cms() helper defaults to it.

How the modes compare:

Mode Freshness Needs a server? Best for
Prerender + rebuild webhook Minutes after publish No Most content sites — recommended
SSR (default) Always fresh Yes Search pages, high-churn content
adapter-vercel ISR Up to N seconds stale Vercel only Blogs with steady cadence

A common split: prerender /blog and /blog/[slug], keep /search SSR (it depends on the query string anyway). If you deploy to Vercel, @sveltejs/adapter-vercel also supports ISR via route config — export const config = { isr: { expiration: 60 } } — which gets you Next.js-style time-based revalidation without a rebuild.


Step 5: Update on Publish With a Signed Webhook

Prerendered pages are stale until the next build — so let the CMS trigger that build. UnfoldCMS ships outgoing HMAC-signed webhooks: on the post.published event it POSTs a signed payload to a URL you register. Subscriptions are manageable from the admin UI or the /api/v1/admin/webhooks endpoint.

If your SvelteKit app runs with SSR enabled (any server adapter), you can host the receiver inside the app as a +server.js endpoint. Verify the signature, then hit your host's deploy hook (Vercel Deploy Hook, Netlify build hook, Cloudflare Pages deploy hook — all plain URLs that trigger a build):

// src/routes/api/cms-webhook/+server.js
import crypto from 'node:crypto';
import { json } from '@sveltejs/kit';
import { CMS_WEBHOOK_SECRET, BUILD_HOOK_URL } from '$env/static/private';

export async function POST({ request }) {
  const raw = await request.text();
  // UnfoldCMS signs with spatie/laravel-webhook-server — header "Signature",
  // value hash_hmac('sha256', rawBody, secret).
  const signature = request.headers.get('signature') ?? '';

  const expected = crypto
    .createHmac('sha256', CMS_WEBHOOK_SECRET)
    .update(raw)
    .digest('hex');

  const ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) return new Response('Bad signature', { status: 401 });

  // Payload: { event, occurred_at, data: { id, title, slug, url, posted_at } }
  await fetch(BUILD_HOOK_URL, { method: 'POST' });
  return json({ triggered: true });
}

Then register it in UnfoldCMS: open the admin webhooks settings, add the endpoint URL (https://yoursite.com/api/cms-webhook), subscribe to post.published, and copy the signing secret into your env as CMS_WEBHOOK_SECRET. The admin has a "Send test" button — use it before trusting the wiring.

One honest caveat: a fully prerendered SvelteKit site has no server to host this endpoint. In that case either register the host's build hook directly in UnfoldCMS (skipping signature verification), or run the relay above as a small standalone serverless function — same code, different home. The Astro guide uses exactly that relay pattern.

Now the loop is closed: editor publishes, UnfoldCMS signs and POSTs, your endpoint verifies and rebuilds, and the static site refreshes within a build cycle — no manual deploy.


Deploying the Pair

The two halves deploy separately, and that's a feature. UnfoldCMS needs PHP and MySQL — any VPS or shared host works; it can't run on Vercel or Cloudflare. The SvelteKit app goes wherever its adapter points:

  1. Verceladapter-vercel, free hobby tier, deploy hooks built in, optional ISR.
  2. Netlifyadapter-netlify, build hooks built in.
  3. Cloudflare Pagesadapter-cloudflare, deploy hooks, generous free tier.
  4. Your own Node serveradapter-node behind Nginx; SSR with zero webhook wiring needed.

Set CMS_URL (and CMS_WEBHOOK_SECRET if you host the receiver) in the platform's environment settings, point the build at your repo, done.


Frequently Asked Questions

Does UnfoldCMS have a GraphQL API for SvelteKit?

No. UnfoldCMS is REST-only. You query endpoints like /api/v1/posts with the fetch() already wired into every SvelteKit load function. For a content site that's simpler than GraphQL — no schema layer, no client library, no codegen.

Is there an official npm package like @unfoldcms/sveltekit?

No SDK exists today. The native fetch() plus the 10-line helper in Step 1 is the whole integration. Fewer dependencies to keep in sync, and nothing breaks when SvelteKit majors bump.

Do I need an API key to read posts?

No. Published posts, pages, categories, search, menus, and public settings are open read endpoints, rate-limited to 60 requests per minute. Auth tokens exist only for write operations, which a frontend never performs.

Can I build a fully static SvelteKit site from UnfoldCMS?

Yes — set prerender = true on every route (or in the root layout) and use adapter-static. You lose the in-app webhook endpoint, so point the UnfoldCMS webhook at your host's build hook or a tiny relay function instead. Content updates then land on the next triggered build.


Where to Go From Here

You now have SvelteKit pulling content from UnfoldCMS, rendering it server-side or statically, and refreshing on publish. If you're still choosing a CMS rather than wiring one you already picked, the CMS for SvelteKit comparison weighs the options honestly. The Next.js guide shows the same pattern with on-demand revalidation, and the Astro guide covers the pure-static variant. The full endpoint list lives in the API docs.

UnfoldCMS is a one-time license, self-hosted, with the full public API in every tier — see pricing. You run your own PHP server, which isn't for everyone, but it means no per-seat SaaS bills and full ownership of your content.

Sources and Notes

  • Endpoint list and response envelope: UnfoldCMS API docs and the public API v1 launch post.
  • Load functions, entries(), and prerendering: SvelteKit docs (kit.svelte.dev).
  • Webhook signing: UnfoldCMS uses spatie/laravel-webhook-server semantics — Signature header, HMAC-SHA256 over the raw body.
  • Code tested against SvelteKit 2 with Svelte 5 runes; load functions are unchanged on Svelte 4.

Related: CMS for SvelteKit · Next.js integration · Astro integration

Comments (0)

Leave a Comment

Please log in to leave a comment.

Don't have an account? Register here

No comments yet. Be the first to share your thoughts!