Yugen UI

Server-Side Rendering & Next.js

Component Yugen là client component; chúng chạy tốt trong Next.js App Router, Remix hay bất kỳ framework SSR nào sau vài bước chuẩn bị.

Client component

Mọi file trong src/components/ui dùng hook, context hoặc primitive Radix — nghĩa là chúng phải chạy trên client.

Site docs này là Vite SPA nên mã nguồn không có chỉ thị "use client". Khi chép vào Next.js App Router, thêm "use client"; ở dòng đầu mỗi file component. Đoạn script dưới làm việc đó một lần cho cả thư mục:

// scripts/use-client.mjs — chạy một lần: 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);
  }
}

Cài đặt cho Next.js

Các bước ở trang Bắt đầu nhanh vẫn đúng; chỉ khác plugin Tailwind và nơi đặt CSS.

Có sẵn template:npx create-yugen-ui@latest my-app --template next-starter tạo app App Router đã làm sẵn các bước dưới đây, theme lưu trong cookie để server render đúng theme ngay từ HTML đầu tiên.
# Tailwind v4 trong Next.js chạy qua PostCSS
npm install -D tailwindcss @tailwindcss/postcss
// postcss.config.mjs
export default {
  plugins: { "@tailwindcss/postcss": {} },
};
/* src/app/globals.css — nội dung của src/index.css, chỉ sửa đường dẫn tokens */
@import "tailwindcss";
@import "../styles/tokens.css";

/* … giữ nguyên phần @theme, @theme inline, @utility, @keyframes và @layer base của index.css … */
// tsconfig.json — Next.js đọc paths trực tiếp, không cần cấu hình bundler
{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] }
  }
}

Root layout & chống nháy theme

HTML từ server không biết theme người dùng đã chọn. Script trong <head> đặt data-theme trước lần vẽ đầu; provider client đọc lại cùng giá trị.

// 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: "Ứng dụng Yugen" };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // Script sửa attribute của <html> trước khi hydrate → tắt cảnh báo lệch attribute cho riêng thẻ này.
    <html lang="vi" {...yugenHtmlAttributes({ brand: "sakura" })} suppressHydrationWarning>
      <head>
        {/* Chạy trước khi trình duyệt vẽ trang: data-theme theo lựa chọn đã lưu, không có thì theo hệ điều hành. */}
        <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 {
  // Server không có document → "light"; client đọc lại giá trị script đã đặt.
  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 bị chặn */
    }
  }, [theme]);

  return (
    <SetThemeContext.Provider value={setTheme}>
      <YugenProvider applyToDocument theme={theme}>
        <Toaster />
        {children}
      </YugenProvider>
    </SetThemeContext.Provider>
  );
}
  • suppressHydrationWarning chỉ tác động lên chính thẻ <html> (một cấp), không che lỗi hydrate của phần còn lại.
  • yugenThemeScript() đọc giá trị đã lưu ở storageKey: tên theme, hoặc JSON có theme và tuỳ chọn brand, dir. Chưa lưu gì thì theo hệ điều hành: high-contrast khi prefers-contrast: more (tắt bằng highContrast: false), sau đó tới prefers-color-scheme. Đặt defaultTheme để dùng một theme cố định.
  • yugenHtmlAttributes() trả về đúng những gì applyToDocument ghi lên <html> (cùng mặc định), nên giá trị cố định như brand và dir có ngay trong HTML đầu tiên. Khi server biết theme (cookie), truyền theme vào đây và bỏ script. Cả hai helper là hàm thường, gọi được trong Server Component.
  • Với applyToDocument, YugenProvider không render attribute theme vào markup nên server và client cho cùng HTML. Provider cập nhật <html> trong layout effect, trước khi trình duyệt vẽ. Nếu UI của bạn render khác nhau theo theme (icon mặt trời / mặt trăng), hãy render phần đó sau khi mount.

Content Security Policy

Script chống nháy là script inline. Policy chặt có thể cho phép nó bằng nonce hoặc bằng hash, không cần 'unsafe-inline'.

Dùng nonce: tạo một giá trị ngẫu nhiên mới cho mỗi response, đặt vào header Content-Security-Policy và lên thẻ <script>. Với Next.js, tạo nonce trong middleware rồi đọc lại trong layout bằng headers().

// Mỗi response một nonce ngẫu nhiên mới, đặt ở header và trên thẻ <script>:
// Content-Security-Policy: script-src 'self' 'nonce-<nonce>'
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: yugenThemeScript() }} />

Dùng hash: cùng tuỳ chọn thì script giống hệt nhau, nên trang tĩnh hoặc prerender có thể ghi hash sha256 của nó. Giá trị dưới đây là của yugenThemeScript() không tuỳ chọn. Hash đổi khi bạn truyền tuỳ chọn hoặc khi bản cập nhật đổi script, nên tính từ hàm khi có thể.

# yugenThemeScript() không tuỳ chọn, ở phiên bản này
Content-Security-Policy: script-src 'self' 'sha256-aQ5/7/M1H2ZEsd4PyJ7/nRi/VlDCe/SWLVR6JGIWbgM='
// Tuỳ chọn khác: tính hash ở server hoặc lúc build
import { createHash } from "node:crypto";
import { yugenThemeScript } from "@/components/ui";

const script = yugenThemeScript({ storageKey: "my-app-theme" }); // đúng chuỗi bạn render trong <head>
const hash = createHash("sha256").update(script).digest("base64");
// Content-Security-Policy: script-src 'self' 'sha256-${hash}'

Dùng trong Server Component

Server Component import và render component Yugen bình thường — chúng là ranh giới client. Điều kiện: props phải tuần tự hoá được.

// src/app/settings/page.tsx — Server Component: lấy dữ liệu, render component Yugen với props tuần tự hoá được
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="Hồ sơ" description={user.email} />
      <CardContent>
        <SettingsForm defaultName={user.name} />
      </CardContent>
    </Card>
  );
}

// src/app/settings/settings-form.tsx — mọi thứ có hàm (onClick, render-prop, toast) nằm ở đây
"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: "Đã lưu hồ sơ" });
      }}
    >
      <Field label="Tên hiển thị" required>
        <Input name="name" defaultValue={defaultName} />
      </Field>
      <Button appearance="primary" type="submit" className="self-start">
        Lưu
      </Button>
    </form>
  );
}
Nên
  • Truyền chuỗi, số, JSX tĩnh, mảng options từ Server Component.
  • Gom handler, state, render-prop của Field và toast() vào một client component nhỏ.
  • Lấy dữ liệu trên server rồi truyền xuống làm defaultValue.
Không nên
  • Không truyền hàm (onClick, onValueChange, children dạng hàm) từ Server Component.
  • Không gọi toast() khi đang render trên server.
  • Không dùng useYugen() trong Server Component — đó là hook.

Portal, Toast & hydration

Những phần chỉ tồn tại trên client không có trong HTML của server.

  • Portal của Radix (Dialog, Drawer, Menu, Popover, Tooltip, listbox) chỉ gắn vào <body> sau khi mount. Dialog mở sẵn (defaultOpen) xuất hiện sau hydrate — đừng đặt nội dung quan trọng cho SEO bên trong overlay.
  • Toaster dùng một store phía client; toast() chỉ gọi trong event handler hoặc effect.
  • Field, Input, Checkbox… sinh id bằng useId của React nên id ở server và client khớp nhau, htmlFor / aria-describedby không bị lệch.
  • useSyncExternalStore của Toaster đã có snapshot cho server, không gây lỗi khi render SSR.
  • Toaster của withToaster không có trong HTML từ server: nó nạp sau khi hydrate. Nút “…” của Breadcrumb và chip chú thích của StreamingMarkdown render ở server thành cùng một nút, rồi đổi sang component đầy đủ khi code của nó nạp xong, nên HTML hai phía khớp nhau. Xem Hiệu năng.

Phần chỉ dành cho site docs

Một vài cơ chế bạn thấy trong repo là để dựng trang tài liệu, không cần trong ứng dụng.

  • import.meta.glob (API riêng của Vite) trong src/docs/registry/index.ts, src/docs/concepts/index.ts và BlocksPage chỉ để tự gom trang tài liệu. Component trong src/components/ui không dùng API nào của Vite, nên chạy được với webpack, Turbopack, Rspack.
  • Import ?raw để hiện mã nguồn Block cũng là của site docs.
  • useTheme() trong src/hooks là hook của site docs; trong ứng dụng, dùng state của bạn + YugenProvider.
Tóm lại:chép tokens.css, index.css, cn.ts, components/ui; thêm "use client"; bọc layout bằng một client Providers chứa YugenProvider; thêm script chống nháy vào <head>.