Integrate Melt UI's headless, accessible Svelte components with Strapi v5's content management API. This guide covers setup, server-side data fetching, and building a practical content browser with tabs, accordions, and dialogs, so editors can publish updates without frontend changes

Melt UI is a headless, unstyled component library for Svelte. Rather than shipping pre-styled components, it provides builder functions like createAccordion, createDialog, and createTabs that return element properties and reactive state stores. You bind these to standard HTML elements using the use:melt directive, and Melt UI automatically applies ARIA attributes, keyboard navigation, and focus management under the hood.
The builder pattern looks like this:
import { createDialog } from '@melt-ui/svelte';
const {
elements: { trigger, overlay, content, title, close },
states: { open }
} = createDialog();Each builder returns elements (stores you bind to HTML elements), states (reactive values you can read or control), and configuration options. This architecture means zero styling opinions ship with the library — you bring your own CSS, Tailwind classes, or design tokens.
For developers building branded interfaces where pixel-perfect custom designs are non-negotiable, this approach avoids the "override everything" problem common with pre-styled libraries.
Melt UI supports three state management modes: uncontrolled (component manages its own state), controlled (you provide external stores), and interceptable (state changes pass through validation functions before applying). Every component implements WAI-ARIA best practices by default.
Pairing a headless UI library with a headless CMS creates a stack where both layers stay decoupled, giving you maximum flexibility without architectural trade-offs.
attributes objects, making it straightforward to pass CMS data directly into Melt UI component states.+page.server.js load functions keep Strapi API tokens on the server, while Melt UI handles client-side interactivity without exposing credentials.Before starting, confirm you have the following installed and configured:
Scaffold a new SvelteKit application:
npm create svelte@latest meltui-strapi-app
cd meltui-strapi-app
npm installSelect the "Skeleton project" template during setup. Choose TypeScript if you want type safety for Strapi responses (recommended, and what this guide uses).
The quickest path is the automated CLI:
npx @melt-ui/cli@latest initThis installs @melt-ui/svelte and optionally the @melt-ui/pp preprocessor. If you prefer manual installation:
npm install @melt-ui/svelteTo enable the optional preprocessor (which provides syntactic shortcuts), add it to your svelte.config.js:
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { preprocessMeltUI } from '@melt-ui/pp';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: [vitePreprocess(), preprocessMeltUI()],
kit: {
adapter: adapter()
}
};
export default config;If you don't already have a Strapi instance running, create one:
npx create-strapi-app@latest my-strapi --quickstartOnce the Admin Panel loads, create a collection type called Article with these fields:
title (Text)slug (UID, attached to title)excerpt (Text)content (Rich Text)category (Enumeration — e.g., "Getting Started", "Advanced", "Tutorials")Add a few entries and publish them. Then create an API token in the Strapi v5 admin panel (Settings → Global settings → API Tokens) using the Custom token type, and configure its permissions so it has read-only access to only the Article content type.
Create a .env file in your SvelteKit project root. Server-only secrets should omit the VITE_ prefix to prevent client-side exposure:
# .env
VITE_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_hereAccess these in server-side load functions through $env/static/private:
// src/lib/server/strapi.ts
import { STRAPI_URL, STRAPI_API_TOKEN } from '$env/static/private';
export async function fetchFromStrapi(endpoint: string, params?: URLSearchParams) {
const url = params
? `${STRAPI_URL}/api/${endpoint}?${params}`
: `${STRAPI_URL}/api/${endpoint}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${STRAPI_API_TOKEN}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const message = errorData?.error?.message || response.statusText;
throw new Error(`Strapi API error (${response.status}): ${message}`);
}
return response.json();
}This utility centralizes authentication and error handling for all Strapi requests.
Strapi v5 flattens its REST API responses — fields appear directly on the data object rather than nested inside attributes:
// src/lib/types/strapi.ts
export interface StrapiCollectionResponse<T> {
data: T[];
meta?: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiSingleResponse<T> {
data: T;
meta?: Record<string, unknown>;
}
export interface Article {
id: number;
documentId: string;
title: string;
slug: string;
excerpt: string;
content: string;
category: string;
createdAt: string;
publishedAt: string;
}Note the documentId field — Strapi v5 uses a 24-character string identifier that stays stable across drafts, published versions, and localized content, replacing the numeric id as the primary lookup key.
Create a route that loads articles from Strapi:
// src/routes/articles/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchFromStrapi } from '$lib/server/strapi';
import type { StrapiCollectionResponse, Article } from '$lib/types/strapi';
export const load: PageServerLoad = async () => {
try {
const params = new URLSearchParams({
'sort[0]': 'publishedAt:desc',
'pagination[pageSize]': '25'
});
const result: StrapiCollectionResponse<Article> = await fetchFromStrapi('articles', params);
return {
articles: result.data,
pagination: result.meta?.pagination
};
} catch (err) {
throw error(503, 'Unable to load articles from Strapi');
}
};Now wire the Strapi data into Melt UI components. Here's a page that uses Tabs to organize articles by category:
<!-- src/routes/articles/+page.svelte -->
<script lang="ts">
import { createTabs } from '@melt-ui/svelte';
import type { PageData } from './$types';
export let data: PageData;
// Group articles by category
const categories = [...new Set(data.articles.map((a) => a.category))];
const {
elements: { root, list, trigger, content },
states: { value }
} = createTabs({
defaultValue: categories[0] || 'all'
});
</script>
<h1>Knowledge Base</h1>
<div use:melt={$root}>
<div use:melt={$list} class="tab-list" aria-label="Article categories">
{#each categories as category}
<button use:melt={$trigger(category)} class="tab-trigger">
{category}
</button>
{/each}
</div>
{#each categories as category}
<div use:melt={$content(category)} class="tab-content">
{#each data.articles.filter((a) => a.category === category) as article}
<article class="article-card">
<h3>{article.title}</h3>
<p>{article.excerpt}</p>
<time datetime={article.publishedAt}>
{new Date(article.publishedAt).toLocaleDateString()}
</time>
</article>
{/each}
</div>
{/each}
</div>Let's build a more complete project: a knowledge base where content editors manage articles in Strapi, and the frontend renders them inside accessible Melt UI components — tabbed categories, expandable accordion sections, and dialog-based article previews.
In your Strapi Admin Panel, configure the Article collection type with these fields if you haven't already:
| Field Name | Type | Notes |
|---|---|---|
title | Text | Required |
slug | UID | Attached to title |
excerpt | Text | Short summary for card previews |
content | Rich Text | Full article body |
category | Enumeration | Values: "Getting Started", "Guides", "API" |
Create five or six articles spread across the categories and publish them. You can manage this directly in Strapi's Content Manager.
The server load function fetches all published articles with filtering and sorting:
// src/routes/knowledge-base/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchFromStrapi } from '$lib/server/strapi';
import type { StrapiCollectionResponse, Article } from '$lib/types/strapi';
export const load: PageServerLoad = async () => {
try {
const params = new URLSearchParams({
'filters[publishedAt][$notNull]': 'true',
'sort[0]': 'category:asc',
'sort[1]': 'title:asc',
'pagination[pageSize]': '50'
});
const result: StrapiCollectionResponse<Article> = await fetchFromStrapi('articles', params);
// Group articles by category for the frontend
const grouped: Record<string, Article[]> = {};
for (const article of result.data) {
const cat = article.category || 'Uncategorized';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(article);
}
return {
articlesByCategory: grouped,
total: result.meta?.pagination?.total ?? result.data.length
};
} catch (err) {
throw error(503, 'Knowledge base temporarily unavailable');
}
};Each category section uses a Melt UI Accordion to let users expand individual articles:
<!-- src/lib/components/ArticleAccordion.svelte -->
<script lang="ts">
import { createAccordion } from '@melt-ui/svelte';
import type { Article } from '$lib/types/strapi';
export let articles: Article[];
export let categoryName: string;
const {
elements: { root, item, trigger, content },
states: { value }
} = createAccordion({
multiple: true
});
</script>
<div use:melt={$root} class="accordion">
<h3 class="category-heading">{categoryName} ({articles.length})</h3>
{#each articles as article (article.documentId)}
<div use:melt={$item(article.documentId)} class="accordion-item">
<h4>
<button use:melt={$trigger(article.documentId)} class="accordion-trigger">
<span>{article.title}</span>
<span class="chevron" aria-hidden="true">
{$value.includes(article.documentId) ? '−' : '+'}
</span>
</button>
</h4>
<div use:melt={$content(article.documentId)} class="accordion-content">
<p class="excerpt">{article.excerpt}</p>
<div class="article-body">
{@html article.content}
</div>
</div>
</div>
{/each}
</div>
<style>
.accordion {
margin-bottom: 2rem;
}
.category-heading {
font-size: 1.25rem;
margin-bottom: 0.75rem;
color: #1e293b;
}
.accordion-item {
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.accordion-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.875rem 1rem;
background: #f8fafc;
border: none;
cursor: pointer;
font-weight: 500;
text-align: left;
}
.accordion-trigger[data-state='open'] {
background: #eef2ff;
}
.accordion-content {
padding: 1rem;
border-top: 1px solid #e2e8f0;
}
.excerpt {
color: #64748b;
font-style: italic;
margin-bottom: 1rem;
}
</style>Note how article.documentId serves as the unique key for each accordion item. This is the stable identifier Strapi v5 provides across content variations.
Add a Dialog that opens when users want a focused reading view:
<!-- src/lib/components/ArticleDialog.svelte -->
<script lang="ts">
import { createDialog } from '@melt-ui/svelte';
import type { Article } from '$lib/types/strapi';
export let article: Article;
const {
elements: { trigger, overlay, content, title, description, close },
states: { open }
} = createDialog({
preventScroll: true,
closeOnOutsideClick: true,
escapeBehavior: 'close'
});
</script>
<button use:melt={$trigger} class="preview-btn">
Quick View
</button>
{#if $open}
<div use:melt={$overlay} class="dialog-overlay" />
<div use:melt={$content} class="dialog-content">
<h2 use:melt={$title}>{article.title}</h2>
<p use:melt={$description} class="dialog-excerpt">{article.excerpt}</p>
<div class="dialog-body">
{@html article.content}
</div>
<footer class="dialog-footer">
<time datetime={article.publishedAt}>
Published {new Date(article.publishedAt).toLocaleDateString()}
</time>
<button use:melt={$close} class="close-btn">Close</button>
</footer>
</div>
{/if}
<style>
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.dialog-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 0.75rem;
padding: 2rem;
max-width: 640px;
width: 90vw;
max-height: 80vh;
overflow-y: auto;
z-index: 50;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.dialog-excerpt {
color: #64748b;
margin-bottom: 1.5rem;
font-style: italic;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.close-btn {
padding: 0.5rem 1rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
}
.preview-btn {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid #cbd5e1;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
</style>Melt UI's createDialog automatically traps focus inside the dialog, returns focus to the trigger element on close, and handles Escape key dismissal. The preventScroll option stops background content from scrolling while the dialog is open.
Combine the tabs, accordion, and dialog components on the main page:
<!-- src/routes/knowledge-base/+page.svelte -->
<script lang="ts">
import { createTabs } from '@melt-ui/svelte';
import ArticleAccordion from '$lib/components/ArticleAccordion.svelte';
import ArticleDialog from '$lib/components/ArticleDialog.svelte';
import type { PageData } from './$types';
export let data: PageData;
const categories = Object.keys(data.articlesByCategory);
const {
elements: { root, list, trigger, content },
states: { value }
} = createTabs({
defaultValue: categories[0],
loop: true,
activateOnFocus: true
});
</script>
<svelte:head>
<title>Knowledge Base — {data.total} Articles</title>
</svelte:head>
<main class="container">
<h1>Knowledge Base</h1>
<p class="subtitle">{data.total} articles across {categories.length} categories</p>
<div use:melt={$root}>
<nav use:melt={$list} class="tab-list" aria-label="Article categories">
{#each categories as category}
<button use:melt={$trigger(category)} class="tab-trigger">
{category}
<span class="badge">{data.articlesByCategory[category].length}</span>
</button>
{/each}
</nav>
{#each categories as category}
<section use:melt={$content(category)} class="tab-panel">
<ArticleAccordion
articles={data.articlesByCategory[category]}
categoryName={category}
/>
<div class="quick-view-grid">
{#each data.articlesByCategory[category] as article (article.documentId)}
<div class="quick-view-card">
<h4>{article.title}</h4>
<p>{article.excerpt}</p>
<ArticleDialog {article} />
</div>
{/each}
</div>
</section>
{/each}
</div>
</main>
<style>
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1rem;
}
.subtitle {
color: #64748b;
margin-bottom: 2rem;
}
.tab-list {
display: flex;
gap: 0.25rem;
border-bottom: 2px solid #e2e8f0;
margin-bottom: 2rem;
overflow-x: auto;
}
.tab-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: none;
background: transparent;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: border-color 0.15s;
}
.tab-trigger[data-state='active'] {
border-bottom-color: #4f46e5;
color: #4f46e5;
}
.badge {
font-size: 0.75rem;
background: #e2e8f0;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.tab-trigger[data-state='active'] .badge {
background: #eef2ff;
color: #4f46e5;
}
.quick-view-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.quick-view-card {
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
}
.quick-view-card h4 {
margin-bottom: 0.5rem;
}
.quick-view-card p {
color: #64748b;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
</style>Setting loop: true on the tabs means keyboard arrow navigation wraps from the last tab back to the first. The activateOnFocus: true option activates each tab as the user arrows through them, matching the WAI-ARIA Tabs pattern for automatic activation.
For a detail page, use Strapi v5's documentId in a dynamic route:
// src/routes/knowledge-base/[documentId]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchFromStrapi } from '$lib/server/strapi';
import type { StrapiSingleResponse, Article } from '$lib/types/strapi';
export const load: PageServerLoad = async ({ params }) => {
try {
const result: StrapiSingleResponse<Article> = await fetchFromStrapi(
__INLINECODE_26__
);
if (!result.data) {
throw error(404, 'Article not found');
}
return { article: result.data };
} catch (err) {
if ((err as any)?.status === 404) throw err;
throw error(503, 'Unable to load article');
}
};This pattern handles the v5 identifier format correctly — documentId is a string, not a numeric ID, so your route parameter accepts it without conversion.
Start both services:
# Terminal 1: Start Strapi
cd my-strapi
npm run develop
# Terminal 2: Start SvelteKit
cd meltui-strapi-app
npm run devNavigate to http://localhost:5173/knowledge-base to see your Strapi content rendered inside accessible Melt UI tabs, accordions, and dialogs.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Melt UI documentation.
Yes. Strapi's Rich Text fields return HTML that you can render inside Melt UI layouts using Svelte's {@html}. Melt UI takes care of the accessible container behavior (roles, keyboard support, focus handling), while the rich text is just content. In production, treat CMS HTML as untrusted: sanitize with a whitelist (for example, DOMPurify), and consider restricting which roles can edit rich text in the Strapi Admin Panel. That extra step helps prevent XSS surprises later.
Melt UI version 0.86.x is built for Svelte 4's store-based reactivity model. According to maintainer discussions, a Svelte 5-compatible refactor is planned as a separate package. SvelteKit projects using Svelte 5 can still run Melt UI through compatibility mode, and Strapi v5's REST API works identically regardless of which Svelte version your frontend uses, since data fetching happens in server load functions. Pin versions and run accessibility smoke tests after upgrades.
Treat documentId as a string and use it everywhere you need a stable key: keyed {#each} blocks, Melt UI item IDs (for example, $item(article.documentId)), and dynamic routes like /knowledge-base/[documentId]. Avoid parsing it or falling back to numeric id, because that can lead to state mismatches when content is re-created, localized, or moved between environments. Consistent keys also prevent subtle issues like the wrong accordion panel staying open after a list refresh.
For most SvelteKit projects, Strapi's REST API is the simpler choice — no extra plugin installation required, and SvelteKit's fetch works directly. GraphQL becomes valuable when you need to fetch deeply nested relations or want to reduce over-fetching across multiple content types in a single request. The GraphQL plugin auto-generates a schema from your content types once installed. If you enable the GraphQL plugin, double-check permissions for the token, and keep queries on the server to avoid leaking credentials.
Think in layers. On the Strapi side, use the REST Cache plugin for HTTP-level caching with automatic invalidation on content updates. On the SvelteKit side, set Cache-Control headers in server load functions when content is safe to cache, and consider @tanstack/svelte-query for client-side caching with background refetching. For static content, SvelteKit's static adapter pre-renders pages at build time, eliminating runtime API calls entirely for content that changes infrequently.