Integrate Radix with Strapi following this guide

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.
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.
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.asChild prop lets you compose Radix behavior onto your custom components while maintaining clean DOM structure.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.
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.
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.
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_hereDefine 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>
);
}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.
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.
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.
Radix UI provides unstyled, accessible primitives for building design systems. Unlike opinionated libraries, Radix gives you behavior and accessibility while you control all styling for Strapi frontends.
Install Radix primitives for needed components (Dialog, Dropdown, Tabs, etc.), add your styling (CSS, Tailwind, styled-components), and use them to build interfaces displaying Strapi content.
Radix handles keyboard navigation, focus management, screen reader announcements, and ARIA attributes automatically. Build accessible Strapi content displays without manual accessibility implementation.
Yes, Radix is completely unstyled by design. Use CSS, Tailwind, CSS-in-JS, or any approach. This flexibility lets you match any design system while maintaining accessibility.
shadcn/ui is built on Radix with Tailwind styling pre-applied. Use shadcn for quick starts, or use Radix directly for complete styling control. Both work well with Strapi frontends.