Combine Park UI's accessible, build-time styled components with Strapi v5's headless CMS to create content-driven web applications. This guide covers setup, data fetching patterns, and a complete project example for full-stack developers

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:
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.
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.
attributes wrappers, reducing payload size and parsing complexity compared to v4. Combined with Next.js static generation (getStaticProps), incremental static regeneration (ISR with revalidate), or server-side rendering via React Server Components, this architecture creates measurably faster production deployments.Before starting, ensure you have:
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:
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 contentConfigure API permissions by navigating to Settings → Users & Permissions → Roles, then enable find and findOne for the Article content type.
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 generated styled-system utilities and CSS filesThe 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 codegenInstall 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.
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.
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.
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;
});
}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.
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>
);
}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>
);
}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>
);
}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.
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.
Park UI distributes components as editable source code rather than compiled npm packages. You copy the component files directly into your project and own them completely. This means modifications happen directly in your codebase through direct file edits, without requiring CSS overrides or wrapper components. The underlying Ark UI foundation provides headless logic, while Panda CSS generates styles at build time with zero runtime JavaScript overhead—providing a significant performance advantage over runtime CSS-in-JS solutions used by other styling approaches.
Yes. Park UI officially supports React, Vue, and Solid.js with identical component APIs across all three frameworks. Strapi's REST and GraphQL APIs are framework-agnostic, so you can serve the same Strapi content to multiple frontend applications. The fetch patterns remain consistent—only the component syntax changes. This flexibility is particularly valuable for teams maintaining multiple frontends or planning framework migrations.
Strapi v5 returns relative URLs for locally stored media files. Your frontend needs to construct absolute URLs by prepending the Strapi base URL. Create a utility function that checks whether the URL is already absolute and prepends your Strapi URL if not. For production deployments using external providers like AWS S3 or Cloudinary through Strapi's Media Library, the URLs will already be absolute. Always configure CORS policies on both Strapi and your storage provider to ensure media loads correctly across domains.
Strapi v5's Block Editor outputs structured JSON with block objects containing type fields and children arrays. Create a custom renderer component that maps block types (paragraph, heading, list, code, image) to React elements. Each block type requires specific handling—paragraphs contain formatted text nodes, headings include level properties, and lists have format properties. Park UI's Panda CSS utilities integrate seamlessly for consistent typography styling.
Use JWT authentication for end-user sessions, storing tokens in HttpOnly cookies rather than localStorage for security. For server-side operations during build time, use API tokens stored in environment variables. For public content like blog posts, read-only scoped API tokens provide the simplest solution. Configure short token expiration times and implement refresh logic for long-running sessions.