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, 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 |
|---|---|---|
| Retrieve articles on the server | Can use private environment variables |
| Universal loading on server and client | Do not import a secret-bearing client |
| 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
Start with an existing SvelteKit 2 and Svelte 5 project.
Create a workspace at genko.me.
Add a list API named
blog, with a text fieldtitleand rich-text fieldbody.Save and publish an article, then issue a key allowed to read
blog.Set
GENKO_API_KEYin the build environment.
npm install @genko-me/sdkThe quickstart 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.
Create a server-only CMS client
// src/lib/server/genko.tsimport { 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:
// src/routes/blog/+page.server.tsimport { 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"], }),});<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:
// src/routes/blog/[id]/+page.server.tsimport { 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 };};<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:
// src/routes/blog/+page.server.tsimport { 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"], }),});// src/routes/blog/[id]/+page.server.tsimport { 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 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 | Requires another runtime environment |
Node server | Use a compatible adapter such as | 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.
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:
// src/routes/preview/[id]/+page.server.tsimport { 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 covers another build-oriented integration. The Nuxt guide provides a server-route approach, and the static-site CMS guide 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
Official sources checked on October 20, 2026.
