These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Radix UI?
Radix UI is an open-source library of unstyled, accessible React components designed to solve a specific problem: building accessible custom components without sacrificing accessibility or design control.
According to the official documentation, it was created because native HTML elements can't handle modern application requirements, many common UI patterns lack native implementations, and building accessible custom components from scratch represents hundreds of development hours.
The library includes primitives for dialogs, dropdown menus, accordions, tabs, selects, and form controls. All share consistent APIs for predictable integration patterns. You're not learning different approaches for each component.
Why Integrate Radix UI with Strapi
Combining Radix UI with Strapi CMS addresses three pain points full-stack developers face: accessibility compliance through WAI-ARIA pattern implementation and automatic ARIA attributes without manual work, styling flexibility that works with any styling solution (CSS-in-JS, Tailwind CSS, vanilla CSS), and seamless integration with Strapi's admin panel customization capabilities through the custom fields system and plugin development framework.
- Accessibility comes built-in. Radix components handle the complexity that usually requires consulting ARIA specifications. When you render a Dialog component, it automatically manages
aria-labelledby,aria-describedby, proper role assignments, and focus trapping. The escape key closes dialogs, arrow keys navigate menus, and screen readers receive correct announcements. All without additional code. This matters when building content-rich applications where every modal, dropdown, and accordion needs to meet accessibility standards but writing that implementation yourself would pull focus from shipping features. - Styling flexibility matches Strapi's headless philosophy. According to the Radix UI styling guide, Radix Primitives ship with zero default styles, making them compatible with any styling approach. You can use Tailwind CSS utility classes, styled-components for CSS-in-JS patterns, or vanilla CSS modules. Whatever your project already uses. When your content comes from Strapi's Content-Type Builder, you're not fighting framework styles or specificity battles. The
asChildprop lets you compose Radix behavior onto your custom components while maintaining clean DOM structure. - Admin panel customization becomes practical. You can build custom fields using Radix components that match your content requirements. For example, creating an accessible relationship selector with typeahead search using Radix Select primitives, then registering it as a custom field type within Strapi's plugin system to extend the content type builder. The official Strapi Design System uses Radix UI internally, demonstrating the team's confidence in this architecture for complex admin interfaces.
The practical benefit surfaces when building features like bulk content management modals, category navigation dropdowns populated from Strapi collections, or custom dashboard plugins with accessible data controls.
You're composing Strapi's REST API responses into Radix components that handle interaction patterns correctly. This combination preserves development velocity. You're not context-switching to implement accessibility or battling component library restrictions that conflict with your CMS data structures.
Real-world projects validate this pattern. Multiple production implementations use Radix UI with Strapi, with most choosing Shadcn/ui (pre-styled Radix primitives with Tailwind) for faster delivery. The Strapi community provides architectural guidance through official blog tutorials and example repositories that document recommended patterns for component-to-content mapping and type-safe API interactions.
How to Integrate Radix UI with Strapi
Integration requires understanding Strapi v5's breaking changes, particularly the shift to documentId and flattened response structures, along with proper runtime version configuration and component implementation with data fetching patterns that account for non-populated relations by default.
Project Setup and Version Requirements
Start by initializing a Next.js project with TypeScript and Tailwind CSS pre-configured using the create-next-app command:
npx create-next-app@latest my-radix-strapi-app --typescript --tailwind --app
cd my-radix-strapi-appThis creates a Next.js 14+ project with App Router. For production stability as of January 2025, use React 18.2.0-18.3.x rather than React 19, as documented hydration issues exist when combining React 19 with Next.js 15 and Radix UI components.
Set up your Strapi backend separately:
npx create-strapi-app@latest my-strapi-backend --quickstart --typescript
cd my-strapi-backend
npm run developInstall Radix UI components as individual packages:
npm install @radix-ui/react-dialog@1.1.15
npm install @radix-ui/react-dropdown-menu@2.1.16
npm install @radix-ui/react-accordion@1.2.12Each primitive is distributed separately, letting you import only what you use. All require React 16.8+ as peer dependencies and work with React 18.
Configure Strapi Content Types and API Endpoints
Access your Strapi admin panel at http://localhost:1337/admin and navigate to the Content-Type Builder. Create a collection type. For example, an Article content type with fields for title (text), content (rich text), slug (UID), and publishDate (datetime).
Strapi v5 introduced breaking changes you need to understand. The API now uses string documentId instead of numeric id, response structures are flattened with attributes at the first level rather than nested, and relations and media aren't populated by default. You must explicitly request population through query parameters.
According to the Strapi REST API documentation, Strapi automatically generates REST endpoints for each content type:
GET /api/articles // Retrieve all documents
GET /api/articles/:documentId // Retrieve specific document
POST /api/articles // Create document
PUT /api/articles/:documentId // Update document
DELETE /api/articles/:documentId // Delete documentNote: In Strapi v5, the :documentId parameter is a string (not numeric), as documented in the Strapi REST API Documentation. The flattened response structure places attributes at the first level. Relations and media are NOT populated by default. Explicit populate parameters are required:
GET /api/articles?populate=* // Retrieve all with relations populated
GET /api/articles/:documentId?populate[author][populate]=avatar // Selective population
GET /api/articles?filters[title][$contains]=React // Filter documents
GET /api/articles?sort=publishDate:desc&pagination[page]=1&pagination[pageSize]=25 // Sort and paginateQuery parameters control population, filtering, sorting, and pagination:
// Populate all relations and media
GET /api/articles?populate=*
// Selective population with nested relations
GET /api/articles?populate[author][populate]=avatar
// Filter by field value
GET /api/articles?filters[category][slug][$eq]=technology
// Sort and paginate
GET /api/articles?populate=*&sort=publishDate:desc&pagination[page]=1&pagination[pageSize]=25Configure environment variables in .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_hereImplement Type-Safe Data Fetching
Define TypeScript interfaces matching Strapi v5's response structure:
interface StrapiMeta {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
}
interface StrapiResponse<T> {
data: T;
meta: StrapiMeta;
}
interface ArticleAttributes {
title: string;
content: string;
slug: string;
publishDate: string;
}
interface StrapiArticle {
id: number;
documentId: string;
attributes: ArticleAttributes;
}Note: This interface shows Strapi v5's response structure. While Strapi v5 documentation mentions "flattened" responses, attributes remain nested under an attributes object in the actual API response. The documentId field (string) replaces v4's numeric id, and you must explicitly populate relations using query parameters.
For Next.js Server Components, fetch data directly:
// app/articles/page.tsx
import { StrapiResponse, StrapiArticle } from '@/types/strapi';
async function getArticles(): Promise<StrapiResponse<StrapiArticle[]>> {
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/articles?populate=*`, {
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`Failed to fetch articles: ${res.status}`);
}
return res.json();
}
export default async function ArticlesPage() {
const response = await getArticles();
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Articles</h1>
{response.data.map((article) => (
<article key={article.documentId} className="mb-4">
<h2>{article.attributes.title}</h2>
</article>
))}
</div>
);
}Build Components with Radix UI Primitives
Implement a Dialog component that fetches and displays article content:
'use client';
import * as Dialog from '@radix-ui/react-dialog';
import { useState, useEffect } from 'react';
interface ArticleAttributes {
title: string;
content: string;
publishDate: string;
}
interface StrapiArticle {
id: number;
documentId: string;
attributes: ArticleAttributes;
}
export function ArticleDialog({ articleId }: { articleId: string }) {
const [article, setArticle] = useState<StrapiArticle | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open && articleId) {
setLoading(true);
fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/articles/${articleId}`)
.then(res => res.json())
.then(data => {
setArticle(data.data);
setLoading(false);
})
.catch(() => setLoading(false));
}
}, [open, articleId]);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
View Details
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl p-6 w-[90vw] max-w-2xl max-h-[85vh] overflow-auto">
{loading ? (
<div>Loading...</div>
) : article ? (
<>
<Dialog.Title className="text-2xl font-bold text-gray-900 mb-4">
{article.attributes.title}
</Dialog.Title>
<Dialog.Description className="text-sm text-gray-600 mb-4">
Published: {new Date(article.attributes.publishDate).toLocaleDateString()}
</Dialog.Description>
<div className="prose prose-sm max-w-none">
{article.attributes.content}
</div>
</>
) : null}
<Dialog.Close asChild>
<button
className="absolute top-4 right-4 w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100"
aria-label="Close"
>
Ă—
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Create a Dropdown Menu with categories fetched from Strapi:
'use client';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Category {
id: number;
documentId: string;
attributes: {
name: string;
slug: string;
};
}
export function CategoryDropdown() {
const [categories, setCategories] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/categories?sort=name:asc`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch categories');
return res.json();
})
.then(data => {
setCategories(data.data);
setIsLoading(false);
})
.catch(error => {
console.error('Category fetch error:', error);
setIsLoading(false);
});
}, []);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-md hover:bg-gray-200"
aria-label="Open categories menu"
>
Categories
<span className="text-xs">â–Ľ</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white rounded-md shadow-lg border p-1"
sideOffset={5}
aria-label="Category navigation menu"
>
{isLoading ? (
<div className="px-3 py-2 text-sm text-gray-500">Loading...</div>
) : (
<>
{categories.map((category) => (
<DropdownMenu.Item key={category.id} asChild>
<Link
href={`/categories/${category.attributes.slug}`}
className="block px-3 py-2 text-sm rounded hover:bg-gray-100 focus:outline-none focus:bg-gray-100"
>
{category.attributes.name}
</Link>
</DropdownMenu.Item>
))}
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
<DropdownMenu.Item asChild>
<Link
href="/categories"
className="block px-3 py-2 text-sm rounded hover:bg-gray-100 focus:outline-none focus:bg-gray-100"
>
View All Categories
</Link>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}Configure Tailwind for Radix animations by extending your tailwind.config.ts:
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
keyframes: {
slideDown: {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
fadeIn: {
from: { opacity: '0' },
to: { opacity: '1' },
},
},
animation: {
slideDown: 'slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1)',
fadeIn: 'fadeIn 200ms ease-out',
},
},
},
}
export default configThis configuration enables smooth animations for Radix components using Tailwind's animation utilities.
Project Example: Build an Accessible Knowledge Base with Radix UI and Strapi
A knowledge base demonstrates the practical value of this integration: searchable documentation organized into categories with an accessible navigation tree and expandable article sections.
Start by creating Strapi content types for articles and categories with a one-to-many relationship. Configure the Article content type with fields for title, content, excerpt, and a relation to Categories. Build the frontend using Next.js with the App Router for server-side rendering and SEO benefits.
Implement the main navigation using Radix UI's Accordion primitive for expandable category sections:
import * as Accordion from '@radix-ui/react-accordion';
function KnowledgeBaseNav({ categories }) {
return (
<Accordion.Root type="multiple" className="w-64">
{categories.map(category => (
<Accordion.Item key={category.documentId} value={category.slug}>
<Accordion.Trigger className="flex justify-between w-full px-4 py-2 hover:bg-gray-100">
{category.name}
<span aria-hidden>â–Ľ</span>
</Accordion.Trigger>
<Accordion.Content className="px-4 py-2 data-[state=open]:animate-slideDown">
{category.articles.map(article => (
<Link key={article.documentId} href={`/articles/${article.slug}`}>
{article.title}
</Link>
))}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
}The Accordion handles keyboard navigation automatically. Arrow keys move between items, Enter expands/collapses sections, and screen readers announce state changes. Add a search interface using Radix Dialog for an accessible modal experience, Radix Select for filtering by category, and Strapi's filtering API to query content.
Configure Incremental Static Regeneration with the next: { revalidate: 3600 } option or ISR segment configuration to balance performance with content freshness:
export const revalidate = 3600; // Regenerate every hour
export async function generateStaticParams() {
const articles = await fetchAllArticles();
return articles.map(article => ({ slug: article.slug }));
}This pattern uses Next.js data fetching with selective population of Strapi CMS content, providing fast page loads with up-to-date content. The combination delivers an accessible, performant knowledge base where content editors manage content entirely through Strapi's admin panel while developers control the frontend design completely with Radix UI components.
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 Lovable documentation.
FAQ
Do I need to install @types/react separately when using Radix UI with TypeScript?
Yes, Radix UI packages require explicit TypeScript type setup. While Radix components have proper type exports, you must explicitly install @types/react and @types/react-dom as direct dependencies in your project since Radix declares React as a peer dependency without providing its types. For React 19, ensure your @types/react version matches your React version exactly to avoid type conflicts. Alternatively, enable skipLibCheck: true in tsconfig.json if you encounter unresolvable type conflicts, though this reduces overall type safety across your project.
Should I use React 19 with Radix UI and Strapi in production?
No. While Radix UI supports React 19, documented hydration issues exist when combining React 19 with Next.js 15 and Radix components as of January 2025. Use React 18.2.0-18.3.x for production stability. Monitor the Radix UI GitHub issues and Next.js release notes for resolution updates before upgrading.
How do I debug "documentId is undefined" errors when fetching Strapi content?
Verify you're using Strapi v5 endpoints correctly. documentId is a string field, not numeric id. Check that your fetch URL includes /api/resources/:documentId format and your TypeScript interfaces define documentId: string. Use browser DevTools Network tab to inspect actual API responses and confirm field presence in the payload.
Can I use Radix UI with Strapi's GraphQL API instead of REST?
Yes. Radix components work with any data source. Install @apollo/client or your preferred GraphQL client, write queries targeting Strapi's GraphQL endpoint, and pass the fetched data to Radix component props. The component integration patterns remain identical. Only the data fetching layer changes from REST to GraphQL queries.
How do I handle authentication when fetching Strapi content for Radix components?
Add the Authorization: Bearer YOUR_TOKEN header to fetch requests for protected content. Store JWT tokens securely using httpOnly cookies or secure session storage. For Next.js Server Components, use environment variables for API tokens. For Client Components, implement token refresh logic and handle 401 responses by redirecting to login.
Can I use Radix UI components in Strapi's admin panel?
Yes, you can build custom fields using Radix UI components and register them in the Content-Type Builder, or create admin panel plugins that add entire sections with Radix components. Strapi's internal Design System uses Radix UI primitives, demonstrating this architecture works for complex admin interfaces. Custom fields require server-side registration defining the field type and client-side components implementing the admin UI using Radix primitives.
According to the Strapi Custom Fields Documentation, developers can register new field types that extend Strapi's content type builder, allowing the creation of accessible, custom relationship fields and specialized input components using Radix's unstyled primitive components.