Integrate Ark UI with Strapi
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
These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Ark UI?
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.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Ark UI with Strapi
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:
- Complete styling control: Ark UI's data attribute system (
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. - TypeScript end-to-end: Strapi v5 features native TypeScript support with a completed migration to TypeScript, and Ark UI provides fully typed component props. You can generate typed API clients from Strapi's schema and pass that data directly to typed component props.
- Built-in accessibility: Ark UI implements WAI-ARIA patterns at the finite state machine level. Your FAQ accordions, navigation tabs, and modal dialogs are accessible by default—no extra work required.
- Framework flexibility: Building a React app now but might need Vue later? Ark UI is built on Zag.js, which provides a shared finite state machine (FSM) core that works across React, Vue, Solid, and Svelte, ensuring consistent component behavior across frameworks. Combined with Strapi's API-first approach, your content can work with any frontend framework without rebuilding your backend infrastructure.
- Faster iteration cycles: Strapi v5 uses Vite for fast builds and hot module reloading (HMR). Ark UI's finite state machine (FSM) architecture via Zag.js provides predictable component behavior through explicit state transitions, eliminating the need to implement complex UI state management patterns from scratch.
- Production-ready content workflows: Strapi's Draft & Publish system, Media Library, and role-based permissions handle the content management complexity so you can focus on the frontend.
How to Integrate Ark UI with Strapi
Prerequisites
Before starting, make sure you have:
- Node.js: v20, v22, or v24 (LTS versions)
- Package manager: npm, yarn, or pnpm
- Database: PostgreSQL, MySQL, MariaDB, or SQLite (SQLite for development only)
- Basic knowledge: React or Next.js fundamentals, REST API concepts
Step 1: Create Your Strapi Project
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.
Step 2: Define Your Content Types
Open the Content-Type Builder in the Admin Panel. For this guide, create a simple FAQ collection type:
- Navigate to Content-Type Builder in the Strapi Admin Panel.
- Click "Create new collection type".
- Name it
faq. - Add these fields:
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.
Step 3: Configure API Permissions
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.
Step 4: Set Up Your Frontend Project
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/reactStep 5: Create a Strapi API Client
Set 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();
}Step 6: Build Your First Ark UI Component
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>
);
}Step 7: Integrate with Next.js Server Components
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>
);
}Step 8: Style with Data Attributes
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;
}Project Example: Interactive Product Catalog
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.
Content Model Setup
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).
Type Definitions
// 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();
}Product Catalog Component
// 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>
);
}Page Implementation
// 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.
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.
For more details, visit the Strapi documentation and Ark 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.