# SvelteKit Headless CMS Guide: Server Loads, Prerendering, and Adapters

Published: 2026-10-20 | Updated: 2026-10-20 | URL: https://blog.genko.me/en/sveltekit-headless-cms

> Use genko in SvelteKit 2 with server load functions. Build article pages, prerender dynamic routes, choose an adapter, and keep draft previews private.

To use a headless CMS with SvelteKit, fetch articles inside a `+page.server.ts` load function and return only the data the page needs. Public articles can also be prerendered, while draft previews need a deployment that can process requests on a server.

This article is written by the genko.me team. It targets **SvelteKit 2 and Svelte 5**, matching the [genko integration guide](https://docs.genko.me/en/frameworks/sveltekit/getting-started), and was checked on October 20, 2026. Current framework documentation also contains SvelteKit 3 configuration, so match adapter instructions to your installed major version.

## Which SvelteKit load function should fetch CMS content?

| Location | Responsibility | Private CMS credentials |
| --- | --- | --- |
| `+page.server.ts` | Retrieve articles on the server | Can use private environment variables |
| `+page.ts` | Universal loading on server and client | Do not import a secret-bearing client |
| `+page.svelte` | Render the returned article data | Receive content without the credential |

A server load can prepare article content without making the browser contact the CMS directly. Its returned values still reach the browser, however, so do not return the API key or the complete client configuration alongside a post.

## Prepare the SvelteKit project and blog API

1. Start with an existing SvelteKit 2 and Svelte 5 project.
2. Create a workspace at [genko.me](https://genko.me/).
3. Add a list API named `blog`, with a text field `title` and rich-text field `body`.
4. Save and publish an article, then issue a key allowed to read `blog`.
5. Set `GENKO_API_KEY` in the build environment.

```bash
npm install @genko-me/sdk
```

The [quickstart](https://docs.genko.me/en/getting-started) covers the CMS setup. This article uses the SDK directly. For display components and their signed-pagination helpers, follow the separate `@genko-me/svelte` setup in the [integration guide](https://docs.genko.me/en/frameworks/sveltekit/getting-started).

## Create a server-only CMS client

```ts
// src/lib/server/genko.ts
import { createGenko, listApi } from "@genko-me/sdk";
import { GENKO_API_KEY } from "$env/static/private";

type Blog = { title: string; body: string };
export const client = createGenko({
  workspace: "your-workspace",
  apiKey: GENKO_API_KEY,
  apis: {
    blog: listApi<Blog>({
      fields: [
        { id: "title", type: "text" },
        { id: "body", type: "richtext" },
      ],
    }),
  },
}).apis;
```

Replace `your-workspace` with your workspace identifier. The schema describes fields that must already exist in the CMS; TypeScript does not create them automatically.

The `$lib/server` location prevents accidental use from browser-side page code. This example reads the key through `$env/static/private` so that the same client can also participate in prerendering. Changing the key requires a rebuild, and server build artifacts containing it should be treated as sensitive.

For an SSR-only application, the integration guide uses `$env/dynamic/private` to read runtime credentials instead. Dynamic environment variables cannot be used during prerendering. If you later adopt the Svelte pagination helpers, note that their signing code also reads `process.env.GENKO_API_KEY`; that requirement is separate from the direct SDK calls shown here.

## Load and display the article index

Create this server file and the corresponding `src/routes/blog/+page.svelte` component:

```ts
// src/routes/blog/+page.server.ts
import { client } from "$lib/server/genko";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async () => ({
  posts: await client.blog.getAll({
    orders: "-publishedAt",
    fields: ["id", "title"],
  }),
});
```

```svelte
<script lang="ts">
  import type { PageData } from "./$types";
  let { data }: { data: PageData } = $props();
</script>

<svelte:head><title>Blog</title></svelte:head>
<h1>Blog</h1>
<ul>
  {#each data.posts as post (post.id)}
    <li><a href={`/blog/${post.id}`}>{post.title}</a></li>
  {/each}
</ul>
{#if data.posts.length === 0}<p>No published posts.</p>{/if}
```

`getAll()` collects the complete published archive using internal pagination. For a small blog, displaying that archive is a reasonable starting point. With more articles, use `list()` with explicit limits and offsets to build a paginated index.

Selecting only the identifier and title avoids passing every article body to a page that does not display it. The Svelte component receives `data` through Svelte 5's `$props()` and never imports the credential-bearing CMS client.

Keep failure handling distinct from the empty state. An empty published archive is valid; a failed authenticated request should remain visible as a failure rather than being silently converted to an empty array.

## Create SvelteKit article detail pages and real 404s

Create the server file below and put its component in `src/routes/blog/[id]/+page.svelte`:

```ts
// src/routes/blog/[id]/+page.server.ts
import { error } from "@sveltejs/kit";
import { client } from "$lib/server/genko";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ params }) => {
  const post = await client.blog.getOrNull(params.id);
  if (!post) error(404, "Post not found");
  return { post };
};
```

```svelte
<script lang="ts">
  import type { PageData } from "./$types";
  let { data }: { data: PageData } = $props();
</script>

<svelte:head><title>{data.post.title}</title></svelte:head>
<article>
  <h1>{data.post.title}</h1>
  <div>{@html data.post.body}</div>
</article>
```

The SDK's `getOrNull()` converts a 404 into `null`, while other retrieval failures remain errors. A missing article then becomes an actual page-level 404, rather than a successful response containing a “not found” paragraph.

The rich-text body delivered by genko is sanitized when saved. The `{@html ...}` block renders that HTML. It is not a general sanitizer for arbitrary HTML obtained from another source.

Test navigation between two detail pages as well as opening a detail URL directly. The component should follow the new page data instead of retaining a separate local copy of the first article.

## Prerender CMS routes with entries

To freeze public articles at build time, replace the two server files with these versions:

```ts
// src/routes/blog/+page.server.ts
import { client } from "$lib/server/genko";
import type { PageServerLoad } from "./$types";

export const prerender = true;
export const load: PageServerLoad = async () => ({
  posts: await client.blog.getAll({
    orders: "-publishedAt",
    fields: ["id", "title"],
  }),
});
```

```ts
// src/routes/blog/[id]/+page.server.ts
import { error } from "@sveltejs/kit";
import { client } from "$lib/server/genko";
import type { EntryGenerator, PageServerLoad } from "./$types";

export const prerender = true;
export const entries: EntryGenerator = async () => {
  const posts = await client.blog.getAll({ fields: ["id"] });
  return posts.map(post => ({ id: post.id }));
};
export const load: PageServerLoad = async ({ params }) => {
  const post = await client.blog.getOrNull(params.id);
  if (!post) error(404, "Post not found");
  return { post };
};
```

The `entries()` function enumerates article identifiers for the dynamic route. That avoids relying on the first archive page to reveal every article that needs an output file. An article does not have to be linked from that first page to appear in the explicit route set.

A generated archive is still a snapshot. Set up a [webhook](https://docs.genko.me/en/guides/webhooks) receiver that starts a build, or provide a manual rebuild procedure. Include deletion and publication ending in your acceptance checks so that obsolete pages do not remain deployed after they disappear from the CMS.

For this implementation, the build must be able to reach the CMS and read the private key. Decide how failed builds are reported and keep the previous successful deployment available rather than replacing it with incomplete output.

## Choose adapter-static or a server adapter

| Deployment | Adapter decision | Draft preview consequence |
| --- | --- | --- |
| Fully static host | Use `adapter-static` if every route can be generated | Requires another runtime environment |
| Node server | Use a compatible adapter such as `adapter-node` | Can retain a dynamic preview route |
| Other serverless platform | Check the platform adapter and package dependencies | Verify runtime compatibility |

An adapter determines where the finished application can run. In SvelteKit 2, configure `adapter-static` in `svelte.config.js`, preserving existing preprocessing and other project settings. Follow the versioned [SvelteKit 2 static-generation guide](https://github.com/sveltejs/kit/blob/%40sveltejs%2Fkit%402.49.1/documentation/docs/25-build-and-deploy/50-adapter-static.md).

For a completely static build, routes outside the blog also need a valid generation strategy. Turning off SSR to produce an empty client shell is not a substitute for generating the article body into HTML.

## Keep draft previews separate from public routes

On a deployment that retains a server, public articles can be prerendered while `/preview/[id]` runs at request time:

```ts
// src/routes/preview/[id]/+page.server.ts
import { error } from "@sveltejs/kit";
import { client } from "$lib/server/genko";
import type { PageServerLoad } from "./$types";

export const prerender = false;
export const load: PageServerLoad = async ({ params, url, setHeaders }) => {
  setHeaders({
    "cache-control": "private, no-store",
    "x-robots-tag": "noindex, nofollow",
    "referrer-policy": "no-referrer",
  });
  const draftKey = url.searchParams.get("draftKey");
  if (!draftKey) error(400, "Missing draft key");
  const post = await client.blog.getOrNull(params.id, { draftKey });
  if (!post) error(404, "Draft not found");
  return { post };
};
```

Create `src/routes/preview/[id]/+page.svelte` using the same rendering code as the detail component above. Set the CMS preview template to `https://your-site.example/preview/{id}?draftKey={key}`. The CMS fills in those placeholders when opening a saved draft.

The public load function never reads query parameters, while the preview function handles `draftKey` explicitly. This keeps per-request draft retrieval out of the public static generation path.

A deployment using only `adapter-static` cannot include this dynamic server route. Either provide a separate preview environment or choose an adapter that can retain server routes alongside generated public pages.

This SDK implementation does not automatically supply a preview toolbar or exit button. Add an editorial notice if needed and link back to the public page when ending preview mode. Keep preview URLs out of canonical links and sharing controls, and ensure a CDN does not override the private caching policy.

## Verify the blog before handing it to editors

Run `npm run check` and `npm run build`, then inspect the app using the runtime expected by its adapter. Confirm that every published article has a generated page where applicable, a missing identifier produces 404, and moving between articles updates the display.

Test publication, editing, deletion, and publication ending separately. For the preview environment, confirm that a saved draft is visible while the public page still shows its published content. Inspect server and CDN cache settings as part of that workflow.

The [Astro blog guide](https://blog.genko.me/en/astro-blog-cms) covers another build-oriented integration. The [Nuxt guide](https://blog.genko.me/en/nuxt-headless-cms) provides a server-route approach, and the [static-site CMS guide](https://blog.genko.me/en/static-site-news-cms) focuses on editorial update workflows.

## FAQ

### Can I use a private CMS key in +page.ts?

Put secret-bearing retrieval in `+page.server.ts` because a universal load also runs in the browser. Return only the article data required for rendering.

### Can a SvelteKit CMS blog be fully static?

Yes, when published articles can be retrieved during the build and their routes enumerated. Adding or removing articles then requires a new build and deployment.

### Can adapter-static run draft preview requests?

It cannot run a server route that retrieves a draft for each request. Use a separate preview environment or a server-capable adapter.

### Does rotating the API key require a rebuild?

It does with this `$env/static/private` example. Runtime environment variables are an alternative for SSR-only retrieval, but they cannot be used during prerendering.

## Sources

- [genko SvelteKit integration](https://docs.genko.me/en/frameworks/sveltekit/getting-started)
- [genko SvelteKit tutorial](https://docs.genko.me/en/frameworks/sveltekit/tutorial)
- [genko SDK data fetching](https://docs.genko.me/en/sdk/fetching)
- [genko webhooks](https://docs.genko.me/en/guides/webhooks)
- [SvelteKit loading data](https://svelte.dev/docs/kit/load)
- [SvelteKit $env/static/private](https://svelte.dev/docs/kit/$env-static-private)
- [Official SvelteKit 2.49.1 source: Page options](https://github.com/sveltejs/kit/blob/%40sveltejs%2Fkit%402.49.1/documentation/docs/20-core-concepts/40-page-options.md)
- [Official SvelteKit 2.49.1 source: Static site generation](https://github.com/sveltejs/kit/blob/%40sveltejs%2Fkit%402.49.1/documentation/docs/25-build-and-deploy/50-adapter-static.md)

Official sources checked on October 20, 2026.
