Skip to main content
On this page
Updated 6 min read

SvelteKit Starter

Using Adapto CMS with SvelteKit

This guide covers integrating every Adapto CMS content type into a SvelteKit 2 + Svelte 5 project. For SDK method signatures and type definitions, see the SDK Reference.

Prerequisites

  • An Adapto CMS account with an API key (found in the backoffice under Developer Tools → API Keys)
  • Node.js 18+

Quick Start

git clone https://github.com/adaptocms/adapto-sveltekit-client.git
cd adapto-sveltekit-client
npm install
cp .env.example .env

Edit .env and set your keys, then run npm run dev.

Configuration

Environment variables are loaded from SvelteKit's $env/dynamic/private and re-exported from src/config.ts.

// src/config.ts
import { env } from '$env/dynamic/private';

export const API_URL = env.ADAPTO_API_URL ?? '';
export const API_KEY = env.ADAPTO_API_KEY ?? '';
export const IS_DEV = env.DEV === 'true';
export const DEFAULT_LANGUAGE = 'en-US';
export const PAGE_SIZE = 10;

Articles

Listing articles

The summary field is auto-generated from the article's first paragraph — see the SDK Reference for the full IArticle type.

// src/routes/[lang]/articles/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { PAGE_SIZE } from '../../../config';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params }) => {
  const { lang } = params;
  const { items: articles, total, pages: totalPages } = await adapto.articles.list({
    language: lang,
    status: 'published',
    page: 1,
    limit: PAGE_SIZE,
  });
  return { articles, total, totalPages, currentPage: 1 };
};
<!-- src/routes/[lang]/articles/+page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<p>{data.total} articles</p>

{#each data.articles.items as article (article.id)}
  <article>
    <a href="/{data.lang}/articles/{article.slug}">{article.title}</a>
    {#if article.summary}<p>{article.summary}</p>{/if}
    {#if article.published_at}
      <time>{new Date(article.published_at).toLocaleDateString()}</time>
    {/if}
  </article>
{/each}

Article detail

article.content is an HTML string. For articles with embedded media, pass it through hydrateMediaPlacements before rendering — see the SDK Reference.

// src/routes/[lang]/articles/[slug]/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params }) => {
  const { slug } = params;
  try {
    const article = await adapto.articles.getBySlug(slug);
    return { article };
  } catch {
    error(404, 'Article not found');
  }
};
<!-- src/routes/[lang]/articles/[slug]/+page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<svelte:head><title>{data.article.title}</title></svelte:head>

<article>
  <h1>{data.article.title}</h1>
  <p>By {data.article.author}</p>
  <div>{@html data.article.content}</div>
</article>

Pagination

Page 1 lives at /[lang]/articles. Pages 2+ live at /[lang]/articles/page/[pageNum].

// src/routes/[lang]/articles/page/[pageNum]/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { error } from '@sveltejs/kit';
import { PAGE_SIZE } from '../../../../../config';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params }) => {
  const { lang, pageNum } = params;
  const page = parseInt(pageNum, 10);
  if (isNaN(page) || page < 2) error(404, 'Invalid page number');

  const { items: articles, pages: totalPages } = await adapto.articles.list({
    language: lang, status: 'published', page, limit: PAGE_SIZE,
  });
  if (page > totalPages) error(404, 'Page not found');

  return { articles, currentPage: page, totalPages };
};

Categories

See the SDK Reference for the full ICategory type, including parent_id and description.

Listing categories

// src/routes/[lang]/articles/categories/+page.server.ts
export const load: PageServerLoad = async ({ params }) => {
  const { lang } = params;
  const { items: categories } = await adapto.categories.list({ language: lang });
  return { categories: categories.filter((c) => c.parent_id === null) };
};

Category detail with filtered articles

Articles store category membership as an array of category IDs in their categories field. Pass the category's id to the category filter when listing articles.

// src/routes/[lang]/articles/categories/[slug]/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { error } from '@sveltejs/kit';
import { PAGE_SIZE } from '../../../../../config';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params }) => {
  const { lang, slug } = params;
  const category = await adapto.categories.getBySlug(slug).catch(() => null);
  if (!category) error(404, 'Category not found');

  const { items: articles, pages: totalPages } = await adapto.articles.list({
    language: lang,
    status: 'published',
    category: category.id,
    page: 1,
    limit: PAGE_SIZE,
  });
  return { category, articles, totalPages, currentPage: 1 };
};
<script lang="ts">
  let { data } = $props();
</script>

<h1>{data.category.name}</h1>
{#if data.category.description}
  <div>{@html data.category.description}</div>
{/if}
{#each data.articles as article (article.id)}
  <a href="/{data.lang}/articles/{article.slug}">{article.title}</a>
{/each}

Subcategories

const subcategories = await adapto.categories.getSubcategories(category.id);

Pages

CMS-managed pages (Home, About, Contact, etc.) are fetched by slug. See the SDK Reference for the full IPage type including menu_label.

// src/routes/[lang]/[slug]/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params }) => {
  const { slug } = params;
  try {
    const page = await adapto.pages.getBySlug(slug);
    return { page };
  } catch {
    error(404, 'Page not found');
  }
};
<script lang="ts">
  let { data } = $props();
</script>

<svelte:head><title>{data.page.title}</title></svelte:head>

<main>
  <h1>{data.page.title}</h1>
  <div>{@html data.page.content}</div>
</main>

Custom Collections

A collection defines a schema via its fields array. Items store their values in a plain data object keyed by field name. See the SDK Reference for all ICollectionField types.

Collection list

// src/routes/[lang]/[collection_slug]/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { error } from '@sveltejs/kit';
import { PAGE_SIZE } from '../../../config';

export const load = async ({ params }) => {
  const { lang, collection_slug } = params;
  const collection = await adapto.collections.getBySlug(collection_slug).catch(() => null);
  if (!collection) error(404, 'Collection not found');

  const { items, pages: totalPages } = await adapto.collections.listItems(
    collection.id,
    { language: lang, status: 'published', page: 1, limit: PAGE_SIZE },
  );
  return { collection, items, totalPages, currentPage: 1 };
};

Collection item detail

// src/routes/[lang]/[collection_slug]/[item_slug]/+page.server.ts
import { adapto } from '$lib/adapto-sdk';
import { error } from '@sveltejs/kit';

export const load = async ({ params }) => {
  const { collection_slug, item_slug } = params;
  try {
    const collection = await adapto.collections.getBySlug(collection_slug);
    const item = await adapto.collections.getItemBySlug(collection.id, item_slug);
    return { collection, item };
  } catch {
    error(404, 'Item not found');
  }
};
<script lang="ts">
  let { data } = $props();
</script>

<h1>{data.item.title}</h1>

{#each Object.entries(data.item.data) as [fieldName, value] (fieldName)}
  {@const field = data.collection.fields.find((f) => f.name === fieldName)}
  {#if field && value !== null && value !== ''}
    <section>
      <h3>{field.label}</h3>
      {#if field.type === 'rich_text'}
        <div>{@html String(value)}</div>
      {:else if field.type === 'image'}
        <img src={String(value)} alt={field.label} />
      {:else if field.type === 'url' || field.type === 'email'}
        <a href={String(value)}>{String(value)}</a>
      {:else if field.type === 'boolean'}
        <span>{value ? 'Yes' : 'No'}</span>
      {:else if field.type === 'multi_select'}
        {#each value as option}<span>{option}</span>{/each}
      {:else}
        <p>{String(value)}</p>
      {/if}
    </section>
  {/if}
{/each}

Micro Copy

Micro copy entries are short, language-scoped strings managed in the CMS. Load the full dictionary for a language in the [lang] layout so every child route can use it without extra fetches.

// src/routes/[lang]/+layout.server.ts
import { adapto } from '$lib/adapto-sdk';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ params }) => {
  const { lang } = params;
  const [languages, copy] = await Promise.all([
    adapto.languages.list(),
    adapto.microCopy.getDictionary(lang),
  ]);
  return { lang, languages, copy };
};
<!-- Usage in any child +page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<button>{data.copy['btn.submit']}</button>

Multi-language

Every content type has a language field storing the full locale code (e.g. "en-US"). The [lang] URL segment uses the same value. The root route redirects to the first available language.

// src/routes/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { adapto } from '$lib/adapto-sdk';

export const load = async () => {
  const languages = await adapto.languages.list();
  redirect(302, `/${languages[0] ?? 'en-US'}`);
};