Integrate Melt UI with Strapi
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
These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Melt UI?
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.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Melt UI with Strapi
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.
- Accessible content rendering by default. Melt UI enforces WAI-ARIA compliance on every component, so content from Strapi automatically renders inside accessible containers with proper roles, keyboard navigation, and focus management.
- Complete styling freedom. Since Melt UI ships no CSS, your Strapi-driven content pages can match any brand or design system without fighting component library defaults.
- Simplified data binding with Strapi v5. The flattened response format in Strapi v5 eliminates nested
attributesobjects, making it straightforward to pass CMS data directly into Melt UI component states. - Server-side security. SvelteKit's
+page.server.jsload functions keep Strapi API tokens on the server, while Melt UI handles client-side interactivity without exposing credentials. - Parallel development workflows. Content editors can structure and publish content in Strapi's Admin Panel while developers build and test Melt UI components independently against the API.
- Minimal bundle impact. Melt UI ships only JavaScript logic — no CSS framework, no icon sets, no design tokens. Combined with Strapi's API-first approach and SvelteKit's granular rendering, the result is lean production bundles.
How to Integrate Melt UI with Strapi
Prerequisites
Before starting, confirm you have the following installed and configured:
- Node.js 18.13 or higher (Node.js 20 or 22 recommended for Strapi v5 compatibility)
- npm, pnpm, or yarn as your package manager
- A running Strapi v5 instance with at least one collection type configured
- A Strapi API token generated from Settings → API Tokens in the Admin Panel (read-only access is sufficient for this guide)
- Basic familiarity with Svelte stores and SvelteKit's file-based routing
Step 1: Create a SvelteKit Project
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).
Step 2: Install Melt UI
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;Step 3: Set Up Strapi v5
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 totitle)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.
Step 4: Configure Environment Variables
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.
Step 5: Define TypeScript Types for Strapi Responses
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.
Step 6: Fetch Strapi Content in a Server Load Function
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');
}
};Step 7: Render Content with Melt UI Components
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>Project Example: Knowledge Base with Accessible Article Browser
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.
Content Model Setup in Strapi
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.
Data Layer
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');
}
};Accordion Component for Article Lists
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.
Dialog Component for Article Quick View
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.
Assembling the Knowledge Base Page
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.
Fetching a Single Article by Document ID
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.
Running the Project
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.
Strapi Open Office Hours
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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.