Start free. Scale as your blog grows.Get started

Developer documentation

SvelteKit

Load content in server load functions, behind a cache you can invalidate by tag.

SvelteKit has a module system that makes it hard to leak a secret by accident: anything under src/lib/server simply refuses to be imported into client code. What it does not give you is a data cache — so this guide builds a small one, which is what the official starter does too.

Start from the official starter

contioreach/sveltekit-starter-contioreach

A complete blog: a tagged cache with stale-while-revalidate, a publish webhook, category archives, pagination, a table of contents built from the article body, SEO metadata, JSON-LD, a sitemap and robots.txt. MIT licensed.

Terminal
npx degit contioreach/sveltekit-starter-contioreach my-blog
cd my-blog
npm install
cp .env.example .env
# add your API key to .env, then:
npm run dev

The rest of this page builds the same integration from scratch, so you can add ContioReach to an application you already have. The code matches the starter, so you can read either and recognise the other.

1. Environment variables

.env
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secret
PUBLIC_SITE_URL=http://localhost:5173

Read them through $env/dynamic/private, in one place. If you ever import that module from code that runs in the browser, the build fails with an error naming the file — which is the behaviour you want from a secret.

src/lib/server/config.js
import { env } from "$env/dynamic/private";

/* A missing variable fails loudly instead of quietly shipping a site that
   401s against the CMS. */
function required(name) {
  const value = env[name];

  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}. See .env.example.`
    );
  }

  return value;
}

export const cmsApiUrl = () => required("CMS_API_URL");
export const cmsApiKey = () => required("CMS_API_KEY");
export const revalidationSecret = () => required("REVALIDATION_SECRET");

Dynamic rather than static: the built server reads its values from the real environment at boot, which is what a container or a PaaS actually provides. $env/static/private bakes them in at build time — fine when you build and deploy together, awkward when the same image is promoted between environments.

The $env/*/public counterparts are the ones that reach the browser. Nothing under those names should be a credential.

2. A cache with tags

This is the one piece SvelteKit does not hand you. Next.js has revalidateTag, Astro has a tagged response cache, Nitro has cached function groups. Here it is about sixty lines — and worth writing, because it is what lets a publish webhook drop exactly the affected content.

The semantics to aim for: a value is fresh for maxAge seconds, then served stale for up to swr seconds while a single refresh runs behind the request, so no visitor ever waits on the CMS.

src/lib/server/cache.js
const store = new Map();

/* Tracks the refresh in flight for a key, so a burst of requests hitting a
   stale entry triggers one CMS call rather than one each. */
const inFlight = new Map();

const now = () => Date.now() / 1000;

export function invalidate(tags) {
  const wanted = new Set(Array.isArray(tags) ? tags : [tags]);
  let dropped = 0;

  for (const [key, entry] of store) {
    if (entry.tags.some((tag) => wanted.has(tag))) {
      store.delete(key);
      dropped += 1;
    }
  }

  return dropped;
}

/**
 * @param key      include every argument that changes the result
 * @param options  { tags, maxAge, swr }
 * @param load     fetches the value on a miss
 */
export async function cached(key, { tags = [], maxAge = 3600, swr = 86400 }, load) {
  const entry = store.get(key);
  const age = entry ? now() - entry.storedAt : Infinity;

  if (entry && age <= maxAge) return entry.value;

  if (entry && age <= maxAge + swr) {
    // Stale but usable: refresh behind this request and serve what we have.
    if (!inFlight.has(key)) {
      const refresh = load()
        .then((value) => {
          store.set(key, { value, tags, storedAt: now() });
          return value;
        })
        .catch((error) => {
          // Keep serving the stale value rather than propagating a CMS blip.
          console.error(`Background revalidation failed for ${key}:`, error);
        })
        .finally(() => inFlight.delete(key));

      inFlight.set(key, refresh);
    }

    return entry.value;
  }

  /* Cold or fully expired: the caller has to wait. Sharing the in-flight
     promise keeps a thundering herd on a cold key down to one CMS call. */
  if (inFlight.has(key)) return inFlight.get(key);

  const pending = load()
    .then((value) => {
      store.set(key, { value, tags, storedAt: now() });
      return value;
    })
    .finally(() => inFlight.delete(key));

  inFlight.set(key, pending);
  return pending;
}

This is per-process and in-memory — honest for a single instance, and for the CDN-fronted deployments most blogs use. Run several instances and a webhook clears only the one it lands on; move the store to Redis before you scale out.

3. The content client

Put it under src/lib/server. SvelteKit will not let client code import anything from that directory, so the API key cannot reach the browser by accident — the boundary is enforced rather than remembered.

src/lib/server/cms.js
import { cached } from "./cache.js";
import { cmsApiKey, cmsApiUrl } from "./config.js";

async function apiRequest(endpoint) {
  const response = await fetch(`${cmsApiUrl()}${endpoint}`, {
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": cmsApiKey()
    }
  });

  if (!response.ok) {
    throw new Error(`API request failed: ${response.status}`);
  }

  const data = await response.json();

  // A response can arrive with HTTP 200 and success: false.
  if (!data.success) {
    throw new Error(data.error?.message || "API request failed");
  }

  return data;
}

function query(params) {
  const search = new URLSearchParams();

  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null || value === "") continue;
    search.append(key, Array.isArray(value) ? value.join(",") : String(value));
  }

  const qs = search.toString();
  return qs ? `?${qs}` : "";
}

const read = (key, tags, endpoint) =>
  cached(key, { tags, maxAge: 3600 }, () => apiRequest(endpoint));

export function getBlogs(params = {}) {
  const qs = query({
    page: params.page,
    limit: params.limit,
    category: params.category,
    search: params.search,
    minimal: params.minimal
  });

  return read(`blogs:list:${qs}`, ["blogs"], "/v1/blogs" + qs);
}

// minimal=false so the detail page gets the full HTML body.
export function getBlogBySlug(slug) {
  const qs = query({ slug, minimal: "false" });

  // Two tags: one to drop every listing, one to drop just this article.
  return read(`blogs:by-slug:${slug}`, ["blogs", `blog-${slug}`], "/v1/blogs" + qs);
}

export function getCategories(params = {}) {
  const qs = query({ limit: params.limit ?? 100 });

  return read(`categories:list:${qs}`, ["categories"], "/v1/categories" + qs);
}

export async function getPost(slug) {
  const response = await getBlogBySlug(slug);

  // A slug lookup returns one object, not an array.
  const post = Array.isArray(response.data) ? response.data[0] : response.data;

  return post || null;
}

4. The listing page

Promise.allSettled so a CMS outage renders an empty state rather than taking the page down.

src/routes/blog/+page.server.js
import { getBlogs, getCategories } from "$lib/server/cms.js";

export async function load({ url }) {
  const page = Math.max(1, Number(url.searchParams.get("page")) || 1);

  const [postsResult, categoriesResult] = await Promise.allSettled([
    getBlogs({ page, limit: 12, minimal: "true" }),
    getCategories()
  ]);

  return {
    posts: postsResult.status === "fulfilled" ? postsResult.value.data : [],
    meta: postsResult.status === "fulfilled" ? postsResult.value.meta : null,
    categories:
      categoriesResult.status === "fulfilled" ? categoriesResult.value.data : []
  };
}
src/routes/blog/+page.svelte
<script>
  let { data } = $props();
</script>

<main>
  <h1>Blog</h1>

  <ul>
    {#each data.posts as post (post.id)}
      <li>
        <a href="/blog/{post.slug}">{post.title}</a>
        <p>{post.excerpt}</p>
        <time datetime={post.publishedAt}>
          {new Date(post.publishedAt).toLocaleDateString()}
        </time>
      </li>
    {/each}
  </ul>

  {#if data.meta?.hasPrevPage}
    <a href="/blog?page={data.meta.page - 1}">Previous</a>
  {/if}
  {#if data.meta?.hasNextPage}
    <a href="/blog?page={data.meta.page + 1}">Next</a>
  {/if}
</main>

5. The article page

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

export async function load({ params }) {
  const post = await getPost(params.slug);

  if (!post) {
    error(404, "Blog post not found");
  }

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

<svelte:head>
  <title>{data.post.title}</title>
  <meta name="description" content={data.post.description} />
  <meta property="og:title" content={data.post.title} />
  <meta property="og:description" content={data.post.description} />
  {#if data.post.coverImage}
    <meta property="og:image" content={data.post.coverImage} />
  {/if}
  <meta property="og:type" content="article" />
</svelte:head>

<article>
  <h1>{data.post.title}</h1>
  {#if data.post.readingTime}<span>{data.post.readingTime} min read</span>{/if}
  {#if data.post.authors?.[0]}<span>By {data.post.authors[0].name}</span>{/if}

  <!-- Trusted HTML from your own workspace. -->
  {@html data.post.content}
</article>

{@html} inserts markup without escaping it. Correct for an article body your own authors wrote; never point it at user-submitted content.

6. Publish instantly with a webhook

This is what the tags in step 2 were for. No CDN purge API, no host-specific setup — the cache is yours, so dropping the right entries is one function call.

src/routes/api/revalidate/all/+server.js
import { json } from "@sveltejs/kit";
import { invalidate } from "$lib/server/cache.js";
import { revalidationSecret } from "$lib/server/config.js";

export async function POST({ request }) {
  const body = await request.json().catch(() => ({}));
  const secret = body?.secret || request.headers.get("x-api-key");

  if (secret !== revalidationSecret()) {
    return json({ error: "Invalid token" }, { status: 401 });
  }

  const tags = ["blogs", "categories"];

  /* When the webhook names a post, its own tag goes too — the listings are
     dropped by the shared tags either way. */
  if (body?.post?.slug) tags.push(`blog-${body.post.slug}`);

  const entries = invalidate(tags);

  return json({ success: true, revalidated: { tags, entries } });
}

Register it

  1. 1In your workspace, open Developer Settings → Webhook Integration
  2. 2Enter https://your-site.com/api/revalidate/all as the URL
  3. 3Generate a secret and save it as REVALIDATION_SECRET in your host's environment
  4. 4Publish a post and confirm it appears without a redeploy

Test it locally

Terminal
curl -X POST http://localhost:5173/api/revalidate/all \
  -H "x-api-key: $REVALIDATION_SECRET" \
  -H "content-type: application/json" \
  -d '{"post":{"slug":"hello-world"}}'

An unknown secret returns 401, so the route is safe to leave publicly reachable — which it has to be for ContioReach to call it.

7. Cache headers on top

The cache above stops your API allowance being spent on every visit. Response headers go a step further and let a CDN answer without touching your server at all.

src/routes/blog/+page.server.js
export async function load({ url, setHeaders }) {
  // ...

  setHeaders({
    "cache-control": "public, max-age=60, s-maxage=3600, stale-while-revalidate=86400"
  });
}

A CDN holding a page for an hour will not notice your webhook. Either keep s-maxage short and let the in-process cache do the work, or add a CDN purge to the webhook as well — otherwise the two caches disagree and authors see stale pages they cannot explain.

8. Prerendering articles

To build articles as static files instead, turn on prerendering and tell SvelteKit which slugs exist. Without entries it can only find the pages your build-time links point at.

src/routes/blog/[slug]/+page.server.js
import { getBlogs } from "$lib/server/cms.js";

export const prerender = true;

export async function entries() {
  const slugs = [];
  let page = 1;
  let hasNextPage = true;

  // limit is capped at 100, so walk the pages.
  while (hasNextPage) {
    const { data, meta } = await getBlogs({ page, limit: 100, minimal: "true" });
    slugs.push(...data.map((post) => ({ slug: post.slug })));
    hasNextPage = meta.hasNextPage;
    page += 1;
  }

  return slugs;
}

Prerendered pages cannot read url.searchParams, so keep a paginated listing server-rendered and prerender only the articles. Prerendering and the webhook also solve the same problem from opposite ends — one builds ahead of time, the other keeps a running server fresh. Pick whichever matches how often your authors publish.

9. Deploy

  1. 1Add CMS_API_URL, CMS_API_KEY, and REVALIDATION_SECRET to your host
  2. 2Install the adapter for your host, if you are not using adapter-auto
  3. 3Deploy, then add the /api/revalidate/all URL as your publish webhook