This guide walks you through integrating Ark UI—a headless, framework-agnostic component library built on Zag.js for React, Vue, Solid, and Svelte—with Strapi v5's headless CMS. You'll learn setup, configuration, styling approaches, and build a practical FAQ component powered by CMS content

Ark UI is a headless component library that provides unstyled, accessible primitives for React, Vue, Solid, and Svelte. The "headless" part is key: Ark UI handles all the complex stuff—state management, keyboard navigation, ARIA attributes, and focus management—while giving you zero styling opinions.
What makes Ark UI different from alternatives like Radix UI or Headless UI is its foundation. It's built on Zag.js, which uses finite state machines (FSMs) to model component behavior. This isn't just an implementation detail—it means the same component logic runs identically across all four supported frameworks, not parallel implementations that might drift apart.
The practical result: you get proven accessibility patterns and predictable behavior without inheriting someone else's design system. Style with Tailwind, Panda CSS, vanilla CSS, or whatever fits your project.
When you pair Ark UI's headless components with Strapi's headless CMS architecture, you're combining two tools that share the same philosophy—handle the hard problems, stay out of your way otherwise.
Here's what this integration actually gives you:
data-scope, data-part, data-state) provides zero styling opinions, enabling developers to implement any CSS approach without component library constraints. Fetch content from Strapi and apply completely different styling treatments with full design flexibility.Before starting, make sure you have:
Start by setting up a new Strapi v5 project:
npx create-strapi@latest my-cms-projectThe CLI offers two installation paths. The quickstart option with SQLite gets you running fast for development. For production, choose custom installation to select PostgreSQL, MySQL, or MariaDB.
Start the development server:
cd my-cms-project
npm run developYour Admin Panel is now available at http://localhost:1337/admin. Access the Content-Type Builder to create your content models.
Open the Content-Type Builder in the Admin Panel. For this guide, create a simple FAQ collection type:
faq.question (Text field, required)answer (Rich text field, required)category (Text field)order (Integer field)Save your content type configuration and let Strapi restart. The system auto-generates RESTful endpoints for all content types following the pattern /api/:collectionType—for an FAQ collection, this creates endpoints at /api/faqs for listing entries and /api/faqs/:id for individual entries, with built-in support for filtering, sorting, pagination, and relational data population.
For the faqs content type, Strapi v5 automatically generates REST API endpoints:
GET /api/faqs (retrieve all FAQs)GET /api/faqs/:id (retrieve a single FAQ)These endpoints are automatically available once the content type is created. You can customize access through role-based permissions in the Users & Permissions plugin, where you configure which user roles can perform find and findOne operations on the faqs collection type.
Save changes. Your API is now accessible for public read operations through the REST endpoint.
Create a Next.js project (or add to an existing one):
npx create-next-app@latest my-frontend --typescript --tailwind --app
cd my-frontendInstall Ark UI:
npm install @ark-ui/reactSet up environment variables in .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337This environment variable configuration should be placed in your .env.local file for local development. According to the Strapi v5 Quick Start Guide, Strapi's default development server runs on port 1337.
Create a utility for fetching from Strapi:
// lib/strapi.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export interface StrapiResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface FAQ {
id: number;
documentId: string;
question: string;
answer: string;
category: string;
order: number;
}
export async function getFAQs(): Promise<StrapiResponse<FAQ>> {
const response = await fetch(
`${STRAPI_URL}/api/faqs?populate=*&sort=order:asc`,
{ next: { revalidate: 60 } }
);
if (!response.ok) {
throw new Error('Failed to fetch FAQs');
}
return response.json();
}Create an FAQ accordion that renders Strapi content:
// components/FAQAccordion.tsx
'use client';
import * as Accordion from '@ark-ui/react/accordion';
import { ChevronDown } from 'lucide-react';
import type { FAQ } from '@/lib/strapi';
interface FAQAccordionProps {
faqs: Array<{
id: string;
title: string;
content: string;
}>;
}
export function FAQAccordion({ faqs }: FAQAccordionProps) {
return (
<Accordion.Root
defaultValue={faqs.length > 0 ? [faqs[0].documentId] : []}
className="w-full max-w-2xl mx-auto"
>
{faqs.map((faq) => (
<Accordion.Item
key={faq.documentId}
value={faq.documentId}
className="border-b border-gray-200"
>
<Accordion.ItemTrigger className="flex w-full items-center justify-between py-4 text-left font-medium hover:text-blue-600 data-[state=open]:text-blue-600">
<span>{faq.question}</span>
<Accordion.ItemIndicator>
<ChevronDown className="h-5 w-5 transition-transform duration-200 data-[state=open]:rotate-180" />
</Accordion.ItemIndicator>
</Accordion.ItemTrigger>
<Accordion.ItemContent className="pb-4 text-gray-600 data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp">
<div dangerouslySetInnerHTML={{ __html: faq.answer }} />
</Accordion.ItemContent>
</Accordion.Item>
))}
</Accordion.Root>
);
}Fetch data server-side and pass it to your client component:
// app/faq/page.tsx
import { getFAQs } from '@/lib/strapi';
import { FAQAccordion } from '@/components/FAQAccordion';
export default async function FAQPage() {
const { data: faqs } = await getFAQs();
return (
<main className="container mx-auto py-12 px-4">
<h1 className="text-3xl font-bold text-center mb-8">
Frequently Asked Questions
</h1>
<FAQAccordion faqs={faqs} />
</main>
);
}Ark UI exposes component state through data attributes. These data attributes (data-scope, data-part, and data-state) enable powerful CSS-based styling. Add these Tailwind utilities for animations:
/* app/globals.css */
@keyframes slideDown {
from {
opacity: 0;
height: 0;
}
to {
opacity: 1;
height: auto;
}
}
@keyframes slideUp {
from {
opacity: 1;
height: var(--height);
}
to {
opacity: 0;
height: 0;
}
}
.animate-slideDown {
animation: slideDown 200ms ease-out;
}
.animate-slideUp {
animation: slideUp 200ms ease-out;
}
/* You can also target states directly in CSS */
[data-scope="accordion"][data-part="item-trigger"][data-state="open"] {
background-color: #f3f4f6;
color: inherit;
}
[data-scope="accordion"][data-part="item-content"][data-state="open"] {
animation: slideDown 200ms ease-out;
}Let's build something more substantial—a product catalog with category filtering using Ark UI Tabs and content from Strapi. This demonstrates how multiple Ark UI components work together with CMS-driven content.
In Strapi's Content-Type Builder, create two collection types:
Category:
name (Text, required, unique)slug (UID, based on name)description (Text)Product:
name (Text, required)description (Rich text)price (Decimal, required)image (Media, single file)category (Relation: many-to-one with Category)featured (Boolean, default false)This Product content type is a foundational schema used in Strapi v5's e-commerce platform examples, as referenced in the "PROJECT 2: E-Commerce Product Catalog with Admin Dashboard" section of the integration guide.
The structure supports basic product management with optional rich text descriptions, decimal pricing for accurate currency representation, single image uploads for featured product images, and relational linking to product categories. The featured boolean field enables marketing teams to highlight priority products in storefront displays.
Configure public permissions for both content types (find and findOne).
// lib/strapi.ts
export interface Category {
id: number;
documentId: string;
name: string;
slug: string;
description: string;
}
export interface Product {
id: number;
documentId: string;
name: string;
description: string;
price: number;
image: {
url: string;
alternativeText?: string;
};
category: Category;
featured: boolean;
}
export async function getCategories(): Promise<StrapiResponse<Category>> {
const response = await fetch(
`${process.env.STRAPI_API_URL}/api/categories?sort=name:asc`,
{
headers: {
'Authorization': `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
next: { revalidate: 60 }
}
);
if (!response.ok) {
throw new Error('Failed to fetch categories');
}
return response.json();
}
export async function getProducts(): Promise<StrapiResponse<Product>> {
const response = await fetch(
`${STRAPI_URL}/api/products?populate=*&sort=name:asc`,
{ next: { revalidate: 60 } }
);
return response.json();
}// components/ProductCatalog.tsx
'use client';
import * as Tabs from '@ark-ui/react/tabs';
import * as Dialog from '@ark-ui/react/dialog';
import { X } from 'lucide-react';
import { useState } from 'react';
import type { Category, Product } from '@/lib/strapi';
interface ProductCatalogProps {
categories: Category[];
products: Product[];
}
export function ProductCatalog({ categories, products }: ProductCatalogProps) {
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const getProductsByCategory = (categoryId: string, products: Product[]) => {
if (categoryId === 'all') return products;
return products.filter(p =>
p.category?.documentId === categoryId
);
};
return (
<>
<Tabs.Root defaultValue="all" className="w-full">
<Tabs.List className="flex border-b border-gray-200 mb-6">
<Tabs.Trigger
value="all"
className="px-4 py-2 -mb-px border-b-2 border-transparent data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 hover:text-blue-600 transition-colors"
>
All Products
</Tabs.Trigger>
{categories.map((category) => (
<Tabs.Trigger
key={category.documentId}
value={category.documentId}
className="px-4 py-2 -mb-px border-b-2 border-transparent data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 hover:text-blue-600 transition-colors"
>
{category.name}
</Tabs.Trigger>
))}
</Tabs.List>
<Tabs.Content value="all">
<ProductGrid
products={products}
onSelect={setSelectedProduct}
/>
</Tabs.Content>
{categories.map((category) => (
<Tabs.Content key={category.documentId} value={category.documentId}>
<ProductGrid
products={getProductsByCategory(category.documentId, products)}
onSelect={setSelectedProduct}
/>
</Tabs.Content>
))}
</Tabs.Root>
<ProductDialog
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
/>
</>
);
}
function ProductGrid({
products,
onSelect
}: {
products: Product[];
onSelect: (product: Product) => void;
}) {
const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL;
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<button
key={product.documentId}
onClick={() => onSelect(product)}
className="text-left bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
{product.image && (
<img
src={`${strapiUrl}${product.image.url}`}
alt={product.image.alternativeText || product.name}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="font-semibold text-lg">{product.name}</h3>
<p className="text-blue-600 font-medium">${product.price}</p>
</div>
</button>
))}
</div>
);
}
function ProductDialog({
product,
onClose
}: {
product: Product | null;
onClose: () => void;
}) {
const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL;
return (
<Dialog.Root
open={!!product}
onOpenChange={(details) => !details.open && onClose()}
>
<Dialog.Backdrop className="fixed inset-0 bg-black/50 data-[state=open]:animate-fadeIn" />
<Dialog.Positioner className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Content className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto data-[state=open]:animate-slideIn">
{product && (
<>
{product.image && (
<img
src={`${strapiUrl}${product.image.url}`}
alt={product.image.alternativeText || product.name}
className="w-full h-64 object-cover"
/>
)}
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<Dialog.Title className="text-2xl font-bold">
{product.name}
</Dialog.Title>
<Dialog.CloseTrigger className="p-2 hover:bg-gray-100 rounded-full">
<X className="h-5 w-5" />
</Dialog.CloseTrigger>
</div>
<p className="text-2xl text-blue-600 font-semibold mb-4">
${product.price}
</p>
<Dialog.Description className="text-gray-600">
<div dangerouslySetInnerHTML={{ __html: product.description }} />
</Dialog.Description>
</div>
</>
)}
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
);
}// app/products/page.tsx
import { getCategories, getProducts } from '@/lib/strapi';
import { ProductCatalog } from '@/components/ProductCatalog';
export default async function ProductsPage() {
const [categoriesResponse, productsResponse] = await Promise.all([
getCategories(),
getProducts(),
]);
return (
<main className="container mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-8">Our Products</h1>
<ProductCatalog
categories={categoriesResponse.data}
products={productsResponse.data}
/>
</main>
);
}This pattern scales to more complex scenarios. Combine Ark UI's compositional components with Strapi's content management capabilities: use Strapi's multi-language content support for international applications, implement flexible component structures using Ark UI's asChild prop and factory patterns for dynamic product descriptions, or integrate with production hosting solutions for scaled deployments.
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.
For more details, visit the Strapi documentation and Ark UI documentation.
Strapi v5 uses JWT tokens for authentication. After logging in via /api/auth/local, include the returned token in requests using the Authorization: Bearer <token> header. For server-side rendering in Next.js, store API tokens in environment variables. Ark UI components receive already-fetched data as props, keeping authentication logic separate from presentation.
Yes. Ark UI officially supports React, Vue, Solid, and Svelte with identical APIs across all four frameworks. Since Strapi exposes framework-agnostic REST and GraphQL APIs, your data fetching code stays nearly identical regardless of frontend choice. The main adjustment is the import path—@ark-ui/react, @ark-ui/vue, @ark-ui/solid, or @ark-ui/svelte.
Ark UI exposes component state through data attributes like data-state="open" or data-state="closed" for styling behavior. Pass Strapi field values as CSS custom properties: style={{ '--accent': faq.themeColor }}. Target states with selectors like [data-scope="accordion"][data-state="open"] while using custom properties for dynamic CMS values.
Three strategies work well together. First, use Next.js server components with ISR (next: { revalidate: 60 }). Second, use Ark UI's lazyMount prop on Dialog, Accordion, and Tabs components to defer rendering. Third, avoid populate=*—use targeted queries like ?populate[author]=true&populate[featuredImage]=true to reduce response times.