These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Park UI?
Park UI is a component library built on two architectural layers: Ark UI for headless component logic and Panda CSS for build-time styling. The defining characteristic that sets it apart from traditional component libraries is its open code distribution model—components ship as source code you copy into your project, not npm packages you depend on.
This architecture delivers three practical benefits for full-stack developers:
- Zero runtime overhead: Panda CSS generates all styles during the build process. No JavaScript executes at runtime for styling, which translates to smaller bundles and faster initial page loads compared to runtime CSS-in-JS solutions.
- Complete component ownership: When you add a Park UI button to your project, you get the actual source file. Modifications happen directly in your codebase through editable component recipes, not through brittle CSS overrides or wrapper components.
- Built-in accessibility: Ark UI handles the complex accessibility patterns—ARIA attributes, keyboard navigation, focus management—so you start with WCAG 2.1 compliant components by default.
Park UI supports React, Vue, and Solid.js with identical component APIs across all three frameworks. A Button component in React behaves exactly like its Vue counterpart, reducing cognitive load when working across multiple projects.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Park UI with Strapi
Building content-driven applications typically involves wrestling with two separate concerns: how content gets managed and how it gets displayed. This integration addresses both without forcing compromises on either side.
- True separation of concerns: Content creators work in Strapi's Admin Panel while developers control the entire presentation layer through Park UI components. Neither side blocks the other.
- Framework flexibility with a single content source: Strapi's REST and GraphQL APIs serve any frontend framework. You can run a React dashboard, Vue marketing site, and Solid micro-frontend—all consuming the same Strapi instance with consistent Park UI component patterns.
- Build-time performance optimization: Both technologies prioritize build-time processing. Panda CSS generates all CSS during the build phase with zero runtime overhead, eliminating the JavaScript cost of runtime CSS-in-JS solutions. Strapi v5 flattens response structures by removing nested
attributeswrappers, reducing payload size and parsing complexity compared to v4. Combined with Next.js static generation (getStaticProps), incremental static regeneration (ISR withrevalidate), or server-side rendering via React Server Components, this architecture creates measurably faster production deployments. - Reduced accessibility testing burden: Park UI components are built on Ark UI's headless component foundation, which provides proper ARIA implementation for accessibility compliance. Your testing effort shifts from foundational accessibility concerns to business logic validation.
- Transparent customization: Both Strapi's Content-Type Builder and Park UI's source code distribution model give you direct access to modify behavior without fighting abstraction layers.
- Type-safe content rendering: Strapi's content types map cleanly to TypeScript interfaces, and Panda CSS provides type-safe styling. The entire pipeline from CMS to component maintains type safety.
How to Integrate Park UI with Strapi
Prerequisites
Before starting, ensure you have:
- Node.js 18 or later installed
- A Strapi v5 project (or willingness to create one)
- Familiarity with React and REST APIs
- Basic understanding of CSS-in-JS concepts
Step 1: Set Up Your Strapi v5 Backend
Create a new Strapi project if you don't have one running:
npx create-strapi@latest my-strapi-backend --quickstartFor rapid development with Strapi v5, you can start with SQLite's default configuration. For production deployments, configure PostgreSQL through Strapi's database configuration system as documented in the official Strapi configuration guide.
Once Strapi launches, create an Article content type through the admin panel:
Creating an Article Collection Type in Strapi v5
- Navigate to Content-Type Builder in the Strapi admin panel
- Create a new Collection Type named "Article"
- Add the following fields according to the Content-Type Builder documentation:
title(Text, required) - The article headlineslug(UID, attached to title) - URL-friendly identifier auto-generated from titledescription(Text, long text) - Summary or excerpt of the articlecontent(Rich text, using the Block Editor) - Main article body content using Strapi v5's Block Editor for flexible content compositioncover(Media, single image) - Featured image for the articlepublishedAt(Date) - Publication timestamp for scheduling and filtering published content
Configure API permissions by navigating to Settings → Users & Permissions → Roles, then enable find and findOne for the Article content type.
Step 2: Create Your React Frontend with Panda CSS
Initialize a new React project with Vite:
npm create vite@latest my-park-ui-frontend -- --template react-ts
cd my-park-ui-frontend
npm installInstall Panda CSS and initialize the configuration:
npm install -D @pandacss/dev
npx panda initUpdate your panda.config.ts to include the necessary paths:
import { defineConfig } from '@pandacss/dev';
export default defineConfig({
preflight: true,
include: [
'./src/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
],
exclude: [],
theme: {
extend: {},
},
outdir: 'styled-system',
});Configuration Explanation:
According to the Panda CSS Getting Started documentation, this configuration establishes the core settings for Panda CSS's build-time CSS generation:
preflight: Enables CSS normalization and base styles for consistent browser renderinginclude: Specifies which files Panda CSS should scan for style usageexclude: Allows excluding specific files from style scanningtheme.extend: Enables extension of default design tokens (colors, spacing, sizing) without completely overriding defaultsoutdir: Sets the output directory for generatedstyled-systemutilities and CSS files
The styled-system output directory contains auto-generated utilities, recipes, and style functions that provide type-safe access to design tokens throughout your application.
Add the Panda CSS PostCSS plugin to your build. Create or update postcss.config.cjs:
module.exports = {
plugins: {
'@pandacss/dev/postcss': {},
},
};Update your main CSS file (src/index.css) to import the generated styles:
@layer reset, base, tokens, recipes, utilities;Run the initial code generation:
npx panda codegenStep 3: Install Park UI Components
Install the required dependencies for Park UI:
npm install @ark-ui/react lucide-reactInitialize Park UI in your project:
npx @park-ui/cli initThe CLI prompts you to select your framework (React, Vue, or Solid.js) and configure the component output directory. The default @/components/ui path works well for most projects.
Add specific components as needed:
npx @park-ui/cli add button
npx @park-ui/cli add card
npx @park-ui/cli add dialog
npx @park-ui/cli add input
npx @park-ui/cli add tableEach command copies the component source code directly into your project, allowing you to see exactly how it works in the @/components/ui directory—and modify it if your design system requires adjustments.
Step 4: Configure the Strapi API Connection
Create a utility module for Strapi API calls. This centralizes your fetch logic and handles the authentication header:
// src/lib/strapi.ts
const STRAPI_URL = import.meta.env.VITE_STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = import.meta.env.VITE_STRAPI_TOKEN;
interface StrapiResponse<T> {
data: T;
meta?: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export async function fetchStrapi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<StrapiResponse<T>> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(STRAPI_TOKEN && { Authorization: `Bearer ${STRAPI_TOKEN}` }),
};
const response = await fetch(`${STRAPI_URL}${endpoint}`, {
...options,
headers: { ...headers, ...options.headers },
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
export function getStrapiMediaUrl(url: string | null | undefined): string | null {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
return `${STRAPI_URL}${url}`;
}Create your environment file (.env.local):
VITE_STRAPI_URL=http://localhost:1337
VITE_STRAPI_TOKEN=your-api-token-hereGenerate an API token in Strapi's admin panel under Settings → API Tokens. For development, a full-access token works fine; for production, create read-only tokens scoped to specific content types to restrict permissions to only the endpoints your application needs.
Step 5: Build the Article List Component
Create a component that fetches articles from Strapi and displays them using Park UI cards:
// src/components/ArticleList.tsx
import { useEffect, useState } from 'react';
import { fetchStrapi, getStrapiMediaUrl } from '../lib/strapi';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { css } from '../../styled-system/css';
interface Article {
id: number;
documentId: string;
title: string;
slug: string;
description: string;
publishedAt: string;
cover?: {
url: string;
alternativeText: string;
};
}
export function ArticleList() {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadArticles() {
try {
const response = await fetchStrapi<Article[]>(
'/api/articles?populate=cover&sort=publishedAt:desc'
);
setArticles(response.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load articles');
} finally {
setLoading(false);
}
}
loadArticles();
}, []);
if (loading) {
return <div className={css({ textAlign: 'center', py: '8' })}>Loading articles...</div>;
}
if (error) {
return <div className={css({ color: 'red.500', textAlign: 'center', py: '8' })}>{error}</div>;
}
return (
<div className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
gap: '6',
p: '4',
})}>
{articles.map((article) => {
const coverUrl = article.cover?.url
? `${import.meta.env.VITE_STRAPI_URL}${article.cover.url}`
: null;
return (
<Card.Root key={article.id}>
{coverUrl && (
<img
src={coverUrl}
alt={article.cover?.alternativeText || article.title}
className={css({
w: 'full',
h: '48',
objectFit: 'cover',
borderTopRadius: 'l2',
})}
/>
)}
<Card.Header>
<Card.Title>{article.title}</Card.Title>
<Card.Description>
{new Date(article.publishedAt).toLocaleDateString()}
</Card.Description>
</Card.Header>
<Card.Body>
<p className={css({ color: 'fg.muted', lineClamp: 3 })}>
{article.description}
</p>
</Card.Body>
<Card.Footer>
<Button variant="outline" size="sm">
Read More
</Button>
</Card.Footer>
</Card.Root>
);
})}
</div>
);
}Note how the Strapi v5 response structure is flat—article.title instead of article.attributes.title. This critical v5 change removes the nested attributes wrapper layer, making data access and component binding more straightforward when integrating Strapi content with frontend frameworks.
Step 6: Handle Rich Text Content
Strapi v5's Block Editor outputs structured content with defined block types (paragraph, heading, list, image, code) that requires custom rendering. Create a renderer component that handles type-specific logic for each block:
// src/components/BlockRenderer.tsx
import { css } from '../../styled-system/css';
interface TextChild {
type: 'text';
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
}
interface Block {
type: 'paragraph' | 'heading' | 'list' | 'code' | 'image';
children: TextChild[] | Block[];
level?: number;
format?: 'ordered' | 'unordered';
}
interface BlockRendererProps {
content: Block[];
}
export function BlockRenderer({ content }: BlockRendererProps) {
if (!content) return null;
return (
<div className={css({ '& > * + *': { mt: '4' } })}>
{content.map((block, index) => renderBlock(block, index))}
</div>
);
}
function renderBlock(block: Block, index: number) {
switch (block.type) {
case 'paragraph':
return (
<p key={index} className={css({ lineHeight: '1.7' })}>
{renderChildren(block.children as TextChild[])}
</p>
);
case 'heading':
const HeadingTag = `h${block.level}` as keyof JSX.IntrinsicElements;
return (
<HeadingTag
key={index}
className={css({
fontSize: block.level === 1 ? '3xl' : block.level === 2 ? '2xl' : 'xl',
fontWeight: 'bold',
mt: '6',
mb: '3',
})}
>
{renderChildren(block.children as TextChild[])}
</HeadingTag>
);
case 'list':
const ListTag = block.format === 'ordered' ? 'ol' : 'ul';
return (
<ListTag
key={index}
className={css({
ps: '6',
listStyleType: block.format === 'ordered' ? 'decimal' : 'disc',
})}
>
{(block.children as Block[]).map((item, i) => (
<li key={i}>{renderChildren(item.children as TextChild[])}</li>
))}
</ListTag>
);
case 'code':
return (
<pre key={index} className={css({
bg: 'gray.100',
p: '4',
borderRadius: 'l2',
overflowX: 'auto',
fontSize: 'sm',
fontFamily: 'mono',
})}>
<code>{(block.children as TextChild[])[0]?.text}</code>
</pre>
);
default:
return null;
}
}
function renderChildren(children: TextChild[]) {
return children.map((child, index) => {
let content: React.ReactNode = child.text;
if (child.bold) {
content = <strong key={index}>{content}</strong>;
}
if (child.italic) {
content = <em key={index}>{content}</em>;
}
if (child.code) {
content = (
<code key={index} className={css({ bg: 'gray.100', px: '1', borderRadius: 'sm' })}>
{content}
</code>
);
}
return content;
});
}Project Example: Article Management Dashboard
Let's build a practical application that demonstrates the full integration: an article management dashboard where content editors can view all articles, read individual pieces, and (with proper authentication) create new content.
Dashboard Layout
Start with the main dashboard structure using Park UI's layout components:
// src/components/Dashboard.tsx
import { useState } from 'react';
import { css } from '../../styled-system/css';
import { Button } from './ui/button';
import { Dialog } from './ui/dialog';
import { ArticleList } from './ArticleList';
import { ArticleForm } from './ArticleForm';
export function Dashboard() {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const handleArticleCreated = () => {
setIsCreateOpen(false);
setRefreshKey(prev => prev + 1);
};
return (
<div className={css({ maxW: '7xl', mx: 'auto', py: '8' })}>
<header className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: '8',
px: '4',
})}>
<div>
<h1 className={css({ fontSize: '3xl', fontWeight: 'bold' })}>
Article Dashboard
</h1>
<p className={css({ color: 'fg.muted', mt: '1' })}>
Manage your content with Strapi and Park UI
</p>
</div>
<Dialog.Root open={isCreateOpen} onOpenChange={(e) => setIsCreateOpen(e.open)}>
<Dialog.Trigger asChild>
<Button>Create Article</Button>
</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content className={css({ maxW: 'xl', w: 'full' })}>
<Dialog.Title>New Article</Dialog.Title>
<Dialog.Description>
Fill in the details below to create a new article.
</Dialog.Description>
<ArticleForm onSuccess={handleArticleCreated} />
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</header>
<ArticleList key={refreshKey} />
</div>
);
}Article Creation Form
Create a form component for adding new articles. This demonstrates how to POST data back to Strapi:
// src/components/ArticleForm.tsx
import { useState } from 'react';
import { css } from '../../styled-system/css';
import { Button } from './ui/button';
import { Input } from './ui/input';
interface ArticleFormProps {
onSuccess: () => void;
}
export function ArticleForm({ onSuccess }: ArticleFormProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const response = await fetch(
`${import.meta.env.VITE_STRAPI_URL}/api/articles`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_STRAPI_TOKEN}`,
},
body: JSON.stringify({
data: {
title,
description,
slug: title.toLowerCase().replace(/\s+/g, '-'),
publishedAt: new Date().toISOString(),
},
}),
}
);
if (!response.ok) {
throw new Error('Failed to create article');
}
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className={css({ display: 'flex', flexDir: 'column', gap: '4', mt: '4' })}>
<div>
<label className={css({ display: 'block', mb: '1', fontWeight: 'medium' })}>
Title
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter article title"
required
/>
</div>
<div>
<label className={css({ display: 'block', mb: '1', fontWeight: 'medium' })}>
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of your article"
rows={4}
className={css({
w: 'full',
p: '3',
borderWidth: '1px',
borderColor: 'border.default',
borderRadius: 'l2',
resize: 'vertical',
})}
required
/>
</div>
{error && (
<p className={css({ color: 'red.500', fontSize: 'sm' })}>{error}</p>
)}
<div className={css({ display: 'flex', gap: '3', justifyContent: 'flex-end', mt: '2' })}>
<Button type="submit" disabled={submitting}>
{submitting ? 'Creating...' : 'Create Article'}
</Button>
</div>
</form>
);
}Article Detail View
Build a detail view that fetches a single article and renders its rich text content:
// src/components/ArticleDetail.tsx
import { useEffect, useState } from 'react';
import { fetchStrapi, getStrapiMediaUrl } from '../lib/strapi';
import { BlockRenderer } from './BlockRenderer';
import { Button } from './ui/button';
import { css } from '../../styled-system/css';
interface ArticleDetailProps {
slug: string;
onBack: () => void;
}
interface ArticleData {
id: number;
documentId: string;
title: string;
description: string;
content: any[];
publishedAt: string;
cover?: {
url: string;
alternativeText: string;
};
}
export function ArticleDetail({ slug, onBack }: ArticleDetailProps) {
const [article, setArticle] = useState<ArticleData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadArticle() {
try {
const response = await fetchStrapi<ArticleData[]>(
`/api/articles?filters[slug][$eq]=${slug}&populate=*`
);
if (response.data.length > 0) {
setArticle(response.data[0]);
}
} catch (err) {
console.error('Failed to load article:', err);
} finally {
setLoading(false);
}
}
loadArticle();
}, [slug]);
if (loading) {
return <div className={css({ textAlign: 'center', py: '12' })}>Loading...</div>;
}
if (!article) {
return (
<div className={css({ textAlign: 'center', py: '12' })}>
<p className={css({ mb: '4' })}>Article not found</p>
<Button onClick={onBack} variant="outline">Back to List</Button>
</div>
);
}
return (
<article className={css({ maxW: '3xl', mx: 'auto', px: '4', py: '8' })}>
<Button onClick={onBack} variant="outline" size="sm" className={css({ mb: '6' })}>
← Back to Articles
</Button>
{article.cover && (
<img
src={getStrapiMediaUrl(article.cover.url) || ''}
alt={article.cover.alternativeText || article.title}
className={css({
w: 'full',
h: '64',
objectFit: 'cover',
borderRadius: 'l3',
mb: '6',
})}
/>
)}
<header className={css({ mb: '8' })}>
<h1 className={css({ fontSize: '4xl', fontWeight: 'bold', mb: '3' })}>
{article.title}
</h1>
<p className={css({ color: 'fg.muted', fontSize: 'lg', mb: '2' })}>
{article.description}
</p>
<time className={css({ color: 'fg.subtle', fontSize: 'sm' })}>
Published {new Date(article.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</header>
<div className={css({ prose: 'lg' })}>
<BlockRenderer content={article.content} />
</div>
</article>
);
}Data Table View
For content managers who prefer a table view, Park UI's Table component displays articles in a structured format:
// src/components/ArticleTable.tsx
import { useEffect, useState } from 'react';
import { fetchStrapi } from '../lib/strapi';
import { Table } from './ui/table';
import { Button } from './ui/button';
import { css } from '../../styled-system/css';
interface Article {
id: number;
documentId: string;
title: string;
slug: string;
publishedAt: string;
}
interface ArticleTableProps {
onSelectArticle: (slug: string) => void;
}
export function ArticleTable({ onSelectArticle }: ArticleTableProps) {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadArticles() {
try {
const response = await fetchStrapi<Article[]>(
'/api/articles?sort=publishedAt:desc&pagination[pageSize]=25'
);
setArticles(response.data);
} catch (err) {
console.error('Failed to load articles:', err);
} finally {
setLoading(false);
}
}
loadArticles();
}, []);
if (loading) {
return <div className={css({ p: '4' })}>Loading articles...</div>;
}
return (
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Slug</Table.Head>
<Table.Head>Published</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{articles.map((article) => (
<Table.Row key={article.documentId}>
<Table.Cell className={css({ fontWeight: 'medium' })}>
{article.title}
</Table.Cell>
<Table.Cell className={css({ color: 'fg.muted', fontFamily: 'mono', fontSize: 'sm' })}>
{article.slug}
</Table.Cell>
<Table.Cell>
{new Date(article.publishedAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell>
<Button
variant="outline"
size="sm"
onClick={() => onSelectArticle(article.slug)}
>
View
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
}This dashboard demonstrates a complete content management workflow: listing articles with cards or tables using Park UI's accessible components (which implement ARIA attributes and keyboard navigation via the Ark UI foundation), viewing individual articles with rich text rendering from Strapi's Block Editor, and creating new content through a modal form. Park UI components, built on Panda CSS for zero-runtime styling overhead, handle accessibility and visual design while Strapi manages the content backend through REST API endpoints with support for scalar fields, media uploads, relations, and reusable components.
For production deployments, add role-based authentication to protect the creation functionality and configure your preferred hosting provider with proper environment variables. For Strapi Cloud specifically, configure Strapi Cloud with dedicated environment variable management through the platform interface.
Strapi Open Office Hours
If you have questions about Strapi 5 or want to discuss implementing Park UI with your content architecture, join us at Strapi's Discord Open Office Hours Monday through Friday, 12:30 PM to 1:30 PM CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Park 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.