Kamaankamaan

Headless CMS for SvelteKit: Setup Guide and API Integration

SvelteKit's load functions are the right place to call a headless CMS. Server-only load keeps API keys off the client, params drive language routing, and ISR or build-time export covers the cache. Here is the minimum wir

Junaid Khalid
Junaid Khalid
May 30, 2026 · 8 min read

SvelteKit ships with a data-loading model that fits headless CMS work better than most frameworks. The split between +page.ts (universal) and +page.server.ts (server-only) tells you where to put the API key. The params object handles language and slug routing without extra middleware. The result is a setup that takes an hour for a developer who has used the framework before and produces a blog that loads fast, ranks cleanly, and stays under one config file.

This guide wires SvelteKit to a headless CMS REST API, end to end. Code is written against Kamaan's REST API as a concrete example, but the pattern transfers to any REST-shaped CMS.

Quick takeaways

  • Use +page.server.ts for CMS fetches. The server load function keeps the API key out of the client bundle, and the data returned to the client never includes the credential.
  • Route the language with params. A folder named [lang] produces a params.lang value the load function reads to fetch the right locale.
  • Define entries() so SvelteKit can prerender the article URLs. The function returns the slug and language combinations to prerender at build, and the build step renders one HTML file per article per language.
  • Kamaan's REST API delivers articles with a language query parameter and returns the body, SEO fields, and locale-prefixed slug already populated. The fetch is one call per page.
  • The same article publishes to /en/blog/, /es/blog/, /de/blog/, /fr/blog/, /it/blog/ with correct hreflang automatically. SvelteKit reads from Kamaan, Kamaan handles the multilingual delivery. SvelteKit's load functions are the only thing you need to understand. Three flavours:

+page.ts runs both server-side at SSR and client-side on navigation. Anything you import here lands in the client bundle. Do not put API keys here.

+page.server.ts runs server-side only. The function executes during SSR, returns JSON to the client for hydration, and runs again as a remote endpoint when the user navigates. API keys, server-only environment variables, and database calls live here.

+layout.server.ts runs server-side for every page that inherits the layout. Use for site-wide context that needs server credentials, such as a global "site config" object pulled from the CMS.

For a blog wired to a headless CMS, every fetch goes in +page.server.ts. There is no scenario where you should be calling the CMS API from +page.ts.

SvelteKit + headless CMS architecture: Kamaan REST API feeds page server load function, which renders page svelte SSR alongside layout params for language routing

Project structure for a multilingual blog

The folder layout that supports /[lang]/blog/[slug]:

src/routes/
  [lang]/
    blog/
      +page.server.ts        # blog index
      +page.svelte
      [slug]/
        +page.server.ts      # single article
        +page.svelte
  +layout.server.ts          # site-wide data
  +layout.svelte

The [lang] folder produces a params.lang value on every load. The [slug] folder gives params.slug. SvelteKit handles the routing without any plugins.

The minimum +page.server.ts for a single article

This is the entire load function for /[lang]/blog/[slug]:

import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { KAMAAN_API, KAMAAN_KEY, KAMAAN_SITE_ID } from '$env/static/private';

export const load: PageServerLoad = async ({ params, fetch }) => {
  const { lang, slug } = params;
  const url = `${KAMAAN_API}/sites/${KAMAAN_SITE_ID}/articles/${slug}?language=${lang}`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${KAMAAN_KEY}` }
  });
  if (!res.ok) throw error(res.status, 'Article not found');
  const article = await res.json();
  return { article };
};

A few things to notice. The env imports use $env/static/private, which is SvelteKit's compile-time check that the variable is only available server-side. The fetch is the SvelteKit-augmented version that handles cookies and cross-request behaviour correctly. The error() helper throws a typed SvelteKit error that SvelteKit's error.html template will render.

The data shape returned from Kamaan's REST API includes title, content, excerpt, meta_title, meta_description, og_image, featured_image_url, slug, language, and an alternates array with the URLs of all other language versions. That alternates array is what you use to render the hreflang link tags in the head.

Rendering the article in +page.svelte

<script lang="ts">
  export let data;
  const { article } = data;
</script>

<svelte:head>
  <title>{article.meta_title}</title>
  <meta name="description" content={article.meta_description} />
  <link rel="canonical" href={article.canonical_url} />
  {#each article.alternates as alt}
    <link rel="alternate" hreflang={alt.language} href={alt.url} />
  {/each}
  <meta property="og:image" content={article.og_image} />
</svelte:head>

<article>
  <h1>{article.title}</h1>
  {@html article.content_html}
</article>

The hreflang loop is the entire multilingual SEO implementation on the SvelteKit side. To syndicate the same blog output, point a free RSS feed generator at the index route and serve the resulting feed alongside these pages. Kamaan emits the alternates array; SvelteKit renders the link tags. There is no need to compute or maintain the cross-references manually.

Prerendering for performance

For a marketing blog, every article should prerender at build. Two adjustments:

// +page.server.ts
export const prerender = true;

export const entries = async () => {
  const res = await fetch(`${KAMAAN_API}/sites/${KAMAAN_SITE_ID}/articles?per_page=1000`, {
    headers: { Authorization: `Bearer ${KAMAAN_KEY}` }
  });
  const { articles } = await res.json();
  return articles.flatMap(a =>
    a.languages.map(lang => ({ lang, slug: a.slug }))
  );
};

The entries() function tells SvelteKit which dynamic routes to prerender at build. SvelteKit then writes one HTML file per article per language to the build output. The build runs once when content changes and the resulting static files are served from a CDN.

For content that updates frequently, switch prerender to false and use the platform's caching layer (Vercel ISR, Cloudflare Workers cache, or your CDN's stale-while-revalidate). The fetch returns the latest content on every cache miss.

The blog index page

The index page lists all articles for a given language. Same pattern, different endpoint:

// /[lang]/blog/+page.server.ts
import type { PageServerLoad } from './$types';
import { KAMAAN_API, KAMAAN_KEY, KAMAAN_SITE_ID } from '$env/static/private';

export const load: PageServerLoad = async ({ params, fetch }) => {
  const res = await fetch(
    `${KAMAAN_API}/sites/${KAMAAN_SITE_ID}/articles?language=${params.lang}&per_page=50`,
    { headers: { Authorization: `Bearer ${KAMAAN_KEY}` } }
  );
  const { articles } = await res.json();
  return { articles };
};

Pagination, filtering by category, and sorting all happen as query parameters on the same endpoint. The REST shape stays uniform across pages.

Why this pattern wins

SvelteKit's server load functions remove most of the surface area where multilingual blogs go wrong. The API key never reaches the client bundle. The hreflang annotations are rendered from server data, not computed at runtime. The build can produce static HTML for every article in every language, which is what Google's crawler rewards. The framework's small bundle and server-side rendering keep Core Web Vitals where they need to be.

Kamaan's Auto-Multilingual Delivery handles the part SvelteKit cannot: producing the translated bodies, slugs, and SEO fields. The CMS publishes the four translated versions; SvelteKit reads them with the same one-line fetch. One config, four languages live, no separate workflows.

Two real workflows

A bootstrapped SaaS founder spins up a SvelteKit blog over a weekend. They wire one +page.server.ts against Kamaan's REST API, deploy to Vercel, and connect their domain. The blog is live in English with the URL structure /blog/[slug]. When they enable Auto-Multilingual Delivery, the same SvelteKit code serves /es/blog/[slug], /de/blog/[slug], /fr/blog/[slug], /it/blog/[slug] from the next build. Total developer time: under two hours.

A small agency runs three client SaaS sites all on SvelteKit. Each client's site is the same Kamaan-fed pattern with a different env file. New articles publish from one Kamaan account and rebuild the three SvelteKit apps via webhook. The agency operator never edits the Svelte code; the CMS does the work.

FAQ

Should I use +page.ts or +page.server.ts for CMS fetches?

Always +page.server.ts. The credentials and the API URL belong on the server. The data returned to the client should already be filtered to what the page renders.

Does SvelteKit have a Kamaan-specific adapter?

No, and it doesn't need one. Kamaan's REST API returns standard JSON. Any SvelteKit fetch works. The pattern is identical to fetching from any REST endpoint.

How do I handle preview content from the CMS?

Add a query parameter to the API call (?status=draft) and gate it behind an env variable check. SvelteKit's preview mode pattern uses cookies to enable draft access only on the preview deployment.

How do I prerender thousands of articles efficiently?

The entries() function returns all combinations to prerender. SvelteKit handles concurrency at build. For very large blogs, switch to ISR or server-side rendering with CDN caching to amortize the build cost.

Does this work with adapter-static for fully static export?

Yes. prerender = true plus entries() lets adapter-static generate the full set of HTML files. Add a fallback rule for languages added after the build and trigger rebuilds when content changes.

What about authenticated routes that need the CMS?

Use a SvelteKit hook (hooks.server.ts) to verify the session before the load runs. The CMS fetch still goes in +page.server.ts, but the load returns 401 if the session is invalid.

Start building with Kamaan

One REST endpoint, every language live

Kamaan gives you one dashboard for all your product blogs, delivered via a clean REST API to any framework. Auto-translated into 99+ languages on every publish. One account covers unlimited sites at $19 a month, flat. First month free.

Start free at kamaan.io

Frequently asked

FAQ · 6 ITEMS
Should I use +page.ts or +page.server.ts for CMS fetches?

Always `+page.server.ts`. The credentials and the API URL belong on the server. The data returned to the client should already be filtered to what the page renders.

Does SvelteKit have a Kamaan-specific adapter?

No, and it doesn't need one. Kamaan's REST API returns standard JSON. Any SvelteKit fetch works. The pattern is identical to fetching from any REST endpoint.

How do I handle preview content from the CMS?

Add a query parameter to the API call (`?status=draft`) and gate it behind an env variable check. SvelteKit's preview mode pattern uses cookies to enable draft access only on the preview deployment.

How do I prerender thousands of articles efficiently?

The `entries()` function returns all combinations to prerender. SvelteKit handles concurrency at build. For very large blogs, switch to ISR or server-side rendering with CDN caching to amortize the build cost.

Does this work with adapter-static for fully static export?

Yes. `prerender = true` plus `entries()` lets adapter-static generate the full set of HTML files. Add a fallback rule for languages added after the build and trigger rebuilds when content changes.

What about authenticated routes that need the CMS?

Use a SvelteKit hook (`hooks.server.ts`) to verify the session before the load runs. The CMS fetch still goes in `+page.server.ts`, but the load returns 401 if the session is invalid.

Junaid Khalid
Written by
Junaid Khalid

Junaid Khalid is the founder of Kamaan, a headless blog CMS that auto-publishes in five languages and lets you manage every product blog from one dashboard.