← Back to DevBytes

Server-Side Rendering with Radix UI: SSR, SSG, ISR

Server-Side Rendering with Radix UI: SSR, SSG, ISR

Radix UI is a collection of unstyled, accessible primitives that make building robust React components straightforward. Because Radix primitives are designed with server-side rendering (SSR) in mind, they integrate cleanly with modern rendering strategies like Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). This tutorial walks through what each strategy means, why it matters for Radix UI, and how to implement each one in a Next.js application.

What Is Server-Side Rendering with Radix UI?

Server-side rendering means producing HTML on the server before sending it to the browser. Radix UI components are built to be SSR-safe: they avoid relying on browser-only APIs like window or document during the initial render, and they hydrate cleanly on the client. This makes them ideal for frameworks like Next.js, Remix, or Astro that render React on the server.

The three rendering strategies we will cover are:

Why It Matters

Accessibility is a core goal of Radix UI. Many Radix components — such as dialogs, popovers, and dropdown menus — manage focus, keyboard navigation, and ARIA attributes. If these components render incorrectly on the server, users may experience hydration mismatches, broken layouts, or flicker. Proper SSR integration ensures:

Setting Up the Project

Start by creating a Next.js application and installing the Radix primitives you need. This tutorial uses the App Router, which supports React Server Components by default.

npx create-next-app@latest radix-ssr-demo
cd radix-ssr-demo
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tooltip

Because Radix primitives are client components (they use React state and effects), you must mark files that use them with the "use client" directive. However, you can still render them inside server components and pass server-fetched data as props.

SSR: Rendering on Every Request

SSR is useful when content changes frequently or depends on the current request, such as user-specific dashboards. In the Next.js App Router, server components render on the server by default. To force per-request rendering, you can export a dynamic config or use revalidate = 0.

Example: SSR Dialog with Server-Fetched Data

First, create a client component that wraps the Radix Dialog:

// app/components/ServerDialog.tsx
"use client";

import * as Dialog from "@radix-ui/react-dialog";

export function ServerDialog({ user }: { user: { name: string; email: string } }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="px-4 py-2 bg-blue-600 text-white rounded">
        Open profile
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded shadow-lg">
          <Dialog.Title>{user.name}</Dialog.Title>
          <Dialog.Description>{user.email}</Dialog.Description>
          <Dialog.Close className="mt-4 text-sm text-gray-500">Close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Then create a server component page that fetches user data on every request and passes it to the dialog:

// app/ssr/page.tsx
import { ServerDialog } from "../components/ServerDialog";

export const dynamic = "force-dynamic";

async function getUser() {
  const res = await fetch("https://jsonplaceholder.typicode.com/users/1", {
    cache: "no-store",
  });
  return res.json();
}

export default async function SSRPage() {
  const user = await getUser();

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-4">SSR with Radix UI</h1>
      <p className="mb-4">This page is rendered on every request.</p>
      <ServerDialog user={user} />
    </main>
  );
}

The force-dynamic export tells Next.js to render this route on every request. The Radix Dialog renders its trigger on the server, while the portal-mounted content is created on the client after hydration. This avoids hydration mismatches because the portal content is not part of the initial server HTML.

SSG: Static Site Generation

SSG generates HTML once at build time. It is ideal for content that rarely changes, such as marketing pages, documentation, or blog posts. Radix UI works well with SSG because the primitives render deterministic HTML on the server.

Example: SSG Tooltip on a Static Page

Create a client component that uses the Radix Tooltip:

// app/components/InfoTooltip.tsx
"use client";

import * as Tooltip from "@radix-ui/react-tooltip";

export function InfoTooltip({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <Tooltip.Provider delayDuration={200}>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <span className="underline cursor-help text-blue-600">{children}</span>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content
            sideOffset={4}
            className="bg-gray-900 text-white text-sm px-2 py-1 rounded"
          >
            {label}
            <Tooltip.Arrow className="fill-gray-900" />
          </Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  );
}

Then create a statically generated page. In the App Router, pages are static by default when they do not use dynamic functions or request-time data fetching.

// app/ssg/page.tsx
import { InfoTooltip } from "../components/InfoTooltip";

export const dynamic = "force-static";

export default function SSGPage() {
  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-4">SSG with Radix UI</h1>
      <p>
        Hover over this term:{" "}
        <InfoTooltip label="A JavaScript framework for production">
          Next.js
        </InfoTooltip>
      </p>
    </main>
  );
}

Because the tooltip content is rendered inside a portal, it does not appear in the static HTML. Only the trigger is part of the static markup, which keeps the generated HTML clean and avoids hydration issues.

ISR: Incremental Static Regeneration

ISR combines the performance benefits of SSG with the freshness of SSR. Pages are generated statically at build time and then regenerated in the background at a specified interval. This is perfect for content that updates periodically, such as product catalogs or news listings.

Example: ISR Dropdown Menu with Revalidation

Create a client component that uses the Radix Dropdown Menu:

// app/components/CategoryMenu.tsx
"use client";

import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

export function CategoryMenu({ categories }: { categories: string[] }) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger className="px-4 py-2 border rounded">
        Categories
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content className="bg-white border rounded shadow p-2">
          {categories.map((cat) => (
            <DropdownMenu.Item
              key={cat}
              className="px-3 py-1 hover:bg-gray-100 cursor-pointer outline-none"
            >
              {cat}
            </DropdownMenu.Item>
          ))}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Then create a page that fetches categories and revalidates every 60 seconds:

// app/isr/page.tsx
import { CategoryMenu } from "../components/CategoryMenu";

export const revalidate = 60;

async function getCategories() {
  const res = await fetch("https://jsonplaceholder.typicode.com/users", {
    next: { revalidate: 60 },
  });
  const users = await res.json();
  return users.map((u: { company: { name: string } }) => u.company.name);
}

export default async function ISRPage() {
  const categories = await getCategories();

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-4">ISR with Radix UI</h1>
      <p className="mb-4">This page revalidates every 60 seconds.</p>
      <CategoryMenu categories={categories} />
    </main>
  );
}

The revalidate = 60 export tells Next.js to regenerate this page in the background at most once every 60 seconds. The first visitor after the revalidation interval triggers a regeneration, but still receives the cached static page. Subsequent visitors get the fresh static page.

Handling Common SSR Pitfalls with Radix UI

Hydration Mismatches

Hydration mismatches occur when the server-rendered HTML differs from what React renders on the client. With Radix UI, the most common cause is rendering components that depend on browser APIs. For example, the Dialog.Portal and Tooltip.Portal components mount their content into the document body, which does not exist on the server. Radix handles this correctly by deferring portal rendering to the client, so you generally do not need to worry about it.

However, if you add custom logic that reads from window or document, guard it with useEffect:

"use client";

import { useEffect, useState } from "react";

export function SafeWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return <span>Window width: {width}px</span>;
}

Using suppressHydrationWarning

Some Radix components, such as themes that toggle dark mode, may intentionally render different values on the server and client. In those cases, you can use the suppressHydrationWarning prop on the affected element. Use this sparingly, as it masks real mismatches.

Avoiding Layout Shift

Radix components that use portals, such as popovers and tooltips, can cause layout shift if their positioning depends on client-side measurements. To minimize this, ensure that trigger elements have stable dimensions and that content is hidden until ready. Radix handles most of this internally, but custom styling should account for the brief moment before hydration completes.

Best Practices

Conclusion

Radix UI is built to work seamlessly with server-side rendering strategies. By understanding the differences between SSR, SSG, and ISR, and by following the patterns shown in this tutorial, you can build accessible, performant React applications that render correctly on the server and hydrate cleanly on the client. The key is to let server components handle data fetching, mark interactive Radix primitives as client components, and trust Radix to manage portals and accessibility attributes across the server-client boundary. With these practices in place, you get the best of both worlds: the accessibility and developer experience of Radix UI, and the performance and SEO benefits of modern server rendering.

— Ad —

Google AdSense will appear here after approval

← Back to all articles