Server-side rendering & Next.js
Yugen components are client components. They work in the Next.js App Router, Remix or any other SSR framework after a few setup steps.
Client components
Every file in src/components/ui uses hooks, context or Radix primitives, so it has to run on the client.
This docs site is a Vite SPA, so the source has no "use client" directive. When you copy it into the Next.js App Router, add "use client"; as the first line of each component file. The script below does that once for the whole folder:
// scripts/use-client.mjs — run once: node scripts/use-client.mjs
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const root = "src/components/ui";
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
for (const file of readdirSync(join(root, entry.name))) {
if (!file.endsWith(".tsx")) continue;
const path = join(root, entry.name, file);
const source = readFileSync(path, "utf8");
if (!source.startsWith('"use client"')) writeFileSync(path, '"use client";\n' + source);
}
}fieldControlClass, popoverSurfaceClass, buttonVariants…) exported from a file with "use client" can only be used inside client components. In a Server Component, import cn directly from @/lib/cn (a file without the directive) and write the utilities yourself.Setting up Next.js
The steps in Quick start still apply; only the Tailwind plugin and the location of the CSS differ.
npx create-yugen-ui@latest my-app --template next-starter creates an App Router app with the steps below done, and the theme in a cookie so the server renders it in the first HTML.# In Next.js, Tailwind v4 runs through PostCSS
npm install -D tailwindcss @tailwindcss/postcss// postcss.config.mjs
export default {
plugins: { "@tailwindcss/postcss": {} },
};/* src/app/globals.css — the contents of src/index.css, only the tokens path changes */
@import "tailwindcss";
@import "../styles/tokens.css";
/* … keep the @theme, @theme inline, @utility, @keyframes and @layer base blocks of index.css as they are … */// tsconfig.json — Next.js reads paths directly, no bundler config needed
{
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}Root layout & no theme flash
HTML from the server does not know which theme the user picked. A script in <head> sets data-theme before the first paint; the client provider reads the same value back.
// src/app/layout.tsx — Server Component
import type { Metadata } from "next";
import { yugenHtmlAttributes, yugenThemeScript } from "@/components/ui";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = { title: "Yugen app" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// The script changes attributes on <html> before hydration → silence the attribute mismatch warning for this element only.
<html lang="en" {...yugenHtmlAttributes({ brand: "sakura" })} suppressHydrationWarning>
<head>
{/* Runs before the browser paints the page: data-theme from the saved choice, otherwise from the OS setting. */}
<script dangerouslySetInnerHTML={{ __html: yugenThemeScript({ storageKey: "yugen-ui-theme" }) }} />
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// src/app/providers.tsx
"use client";
import * as React from "react";
import { YugenProvider, type YugenTheme } from "@/components/ui";
const STORAGE_KEY = "yugen-ui-theme";
function readTheme(): YugenTheme {
// No document on the server → "light"; on the client, read back the value the script set.
if (typeof document === "undefined") return "light";
const theme = document.documentElement.getAttribute("data-theme");
return theme === "dark" || theme === "high-contrast" ? theme : "light";
}
export const SetThemeContext = React.createContext<(theme: YugenTheme) => void>(() => {});
export function Providers({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = React.useState<YugenTheme>(readTheme);
React.useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* storage blocked */
}
}, [theme]);
return (
<SetThemeContext.Provider value={setTheme}>
<YugenProvider applyToDocument theme={theme}>
<Toaster />
{children}
</YugenProvider>
</SetThemeContext.Provider>
);
}suppressHydrationWarningonly applies to the<html>element itself (one level deep). It does not hide hydration errors in the rest of the tree.yugenThemeScript()reads the saved value understorageKey: a theme name, or JSON withthemeand optionallybrandanddir. Without one it follows the OS:high-contrastforprefers-contrast: more(turn off withhighContrast: false), thenprefers-color-scheme. SetdefaultThemeto use a fixed theme instead.yugenHtmlAttributes()returns whatapplyToDocumentwrites to<html>(same defaults), so fixed values such as the brand anddirare in the first HTML. When the server knows the theme (a cookie), pass it here and skip the script. Both helpers are plain functions and work in a Server Component.- With
applyToDocument, YugenProvider does not render the theme attribute into the markup, so the server and the client produce the same HTML. It updates<html>in a layout effect, before the browser paints. If your UI renders differently per theme (a sun / moon icon), render that part after mount.
Content Security Policy
The no-flash script is inline. A strict policy can allow it with a nonce or with its hash instead of 'unsafe-inline'.
With a nonce: create a new random value for every response, put it in the Content-Security-Policy header and on the <script>. In Next.js, create it in middleware and read it in the layout with headers().
// A new random nonce for every response, in the header and on the <script>:
// Content-Security-Policy: script-src 'self' 'nonce-<nonce>'
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: yugenThemeScript() }} />With a hash: the script is the same for the same options, so a static or prerendered page can list its sha256 hash. The value below is for yugenThemeScript() with no options. It changes when you pass options or when an update changes the script, so compute it from the function when you can.
# yugenThemeScript() with no options, in this version
Content-Security-Policy: script-src 'self' 'sha256-aQ5/7/M1H2ZEsd4PyJ7/nRi/VlDCe/SWLVR6JGIWbgM='// Other options: compute the hash on the server or at build time
import { createHash } from "node:crypto";
import { yugenThemeScript } from "@/components/ui";
const script = yugenThemeScript({ storageKey: "my-app-theme" }); // the same string you render in <head>
const hash = createHash("sha256").update(script).digest("base64");
// Content-Security-Policy: script-src 'self' 'sha256-${hash}'Use in Server Components
A Server Component imports and renders Yugen components like any other; they act as the client boundary. Their props must be serializable.
// src/app/settings/page.tsx — Server Component: fetches data, renders Yugen components with serializable props
import { Card, CardContent, CardHeader } from "@/components/ui";
import { SettingsForm } from "./settings-form";
export default async function SettingsPage() {
const user = await getCurrentUser();
return (
<Card className="max-w-lg">
<CardHeader header="Profile" description={user.email} />
<CardContent>
<SettingsForm defaultName={user.name} />
</CardContent>
</Card>
);
}
// src/app/settings/settings-form.tsx — everything that takes a function (onClick, render-props, toast) lives here
"use client";
import { Button, Field, Input, toast } from "@/components/ui";
export function SettingsForm({ defaultName }: { defaultName: string }) {
return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
toast({ intent: "success", title: "Profile saved" });
}}
>
<Field label="Display name" required>
<Input name="name" defaultValue={defaultName} />
</Field>
<Button appearance="primary" type="submit" className="self-start">
Save
</Button>
</form>
);
}- Pass strings, numbers, static JSX and option arrays from a Server Component.
- Keep handlers, state, Field render-props and toast() in a small client component.
- Fetch data on the server and pass it down as defaultValue.
- Passing functions (onClick, onValueChange, function children) from a Server Component.
- Calling toast() while rendering on the server.
- Using useYugen() in a Server Component. It is a hook.
Portals, Toast & hydration
Parts that only exist on the client are not in the server HTML.
- Radix portals (Dialog, Drawer, Menu, Popover, Tooltip, listbox) only attach to
<body>after mount. A Dialog that starts open (defaultOpen) appears after hydration, so don't put content that matters for SEO inside an overlay. Toasteruses a client-side store; only calltoast()from event handlers or effects.- Field, Input, Checkbox… generate ids with React's
useId, so server and client ids match andhtmlFor/aria-describedbystay in sync. - The Toaster's
useSyncExternalStorehas a server snapshot, so it renders without errors during SSR. - The Toaster from
withToasteris not in the server HTML: it loads after hydration. Breadcrumb's “…” button and StreamingMarkdown's citation chips render on the server as the same button and switch to the full component once its code has loaded, so both sides produce the same HTML. See Performance.
Only for the docs site
A few mechanisms in the repo exist to build the documentation site. Your app does not need them.
import.meta.glob(a Vite-only API) insrc/docs/registry/index.ts,src/docs/concepts/index.tsandBlocksPageonly collects the docs pages. Components insrc/components/uiuse no Vite API, so they run with webpack, Turbopack and Rspack.- The
?rawimports that show Block source code also belong to the docs site. useTheme()insrc/hooksis a docs-site hook; in your app, use your own state withYugenProvider.
tokens.css, index.css, cn.ts and components/ui; add "use client"; wrap the layout in a client Providers component that renders YugenProvider; add the no-flash script to <head>.