components

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Components (Server & Client)

组件(Server & Client)

IMPORTANT: This skill defines architecture, rules, structure, component capabilities, constraints, and Value Object usage. It DOES NOT explain or restrict visual design or styling logic (e.g. CSS layout choices). You MAY use component libraries like
shadcn/ui
or similar inside your components.
重要提示: 本指南定义了架构、规则、结构、组件能力、约束以及值对象(Value Object,VO)的使用方式。解释或限制视觉设计或样式逻辑(如CSS布局选择)。你可以在组件中使用
shadcn/ui
或类似的组件库。

Server vs Client — Decision Rule

Server与Client组件——决策规则

If the component needs interactivity (hooks, events, browser APIs) →
"use client"
.
If not → Server Component (no directive needed).
The key project-specific distinction is what each type can access:
CapabilityServer ComponentClient Component
serviceContainer
(use cases)
Value Object instantiation (
new
,
.from()
)
Receive Class Instances as parameters❌ (MUST use
.toBranded()
)
auth.api.getSession
+
headers()
await determineLocale()
❌ — MUST use
useLocale()
useLocale()
,
useRouter()
, other hooks
Server-only modules (drizzle,
load-env
)
async
function
Default to Server Components. Push the
"use client"
boundary as low in the tree as possible.

如果组件需要交互性(hooks、事件、浏览器API)→ 添加
"use client"

如果不需要→ Server Component(无需指令)。
项目特有的核心区别在于两种组件可以访问的资源:
能力Server ComponentClient Component
serviceContainer
(用例)
值对象实例化(
new
.from()
接收类实例作为参数❌(必须使用
.toBranded()
auth.api.getSession
+
headers()
await determineLocale()
❌ — 必须使用
useLocale()
useLocale()
useRouter()
及其他hooks
仅服务端模块(drizzle、
load-env
async
函数
默认使用Server Component。尽可能将
"use client"
边界置于组件树的最底层。

File & Naming Conventions

文件与命名规范

  • Name:
    PascalCase
    matching the exported function —
    LinksTable.tsx
    LinksTable
  • "use client"
    MUST be the absolute first line of the file when present — before any imports.
  • One primary component per file. Small co-located sub-components used only in that file are acceptable.

  • 命名:采用
    PascalCase
    与导出函数名匹配 — 如
    LinksTable.tsx
    对应
    LinksTable
  • 当需要添加
    "use client"
    时,必须放在文件的第一行 — 位于所有导入语句之前。
  • 每个文件对应一个主组件。允许在同一文件中放置仅在该组件内使用的小型子组件。

1. Value Objects in Components

1. 组件中的值对象(VO)

Server Components

Server Component

Server Components CAN explicitly receive, instantiate, and use Value Objects natively in their parameters and body. Since they never cross the network boundary as JSON payloads, they aren't subject to serialization limits.
Server Component可以在参数和代码中直接接收、实例化并使用值对象。由于它们不会以JSON payload形式跨网络传输,因此不受序列化限制。

Client Components

Client Component

Client Components CANNOT receive Value Objects as class instances via parameters from a Server Component. Next.js RSC serialization fails on classes. Client Components MUST receive primitive Branded Types via props. However, a Client Component MAY instantiate or manipulate its own Value Objects internally (e.g., during form validation before submitting to a Server Action).

Client Component无法通过参数从Server Component接收值对象的类实例。Next.js RSC序列化对类实例会失效。Client Component必须通过props接收原始的品牌类型(Branded Types)。 不过,Client Component可以在内部自行实例化或处理值对象(例如,在提交到Server Action之前的表单验证过程中)。

2. Shared Library Components (
shadcn
style)

2. 共享库组件(
shadcn
风格)

Common Components (
cva
&
cn
)

通用组件(
cva
&
cn

When building common, reusable UI components (e.g., Buttons, Badges, Inputs) that appear in multiple contexts and possess differing styles but identical underlying functionality, you MUST manage these styling variants using the
cva
(class-variance-authority) library and the
cn
utility. This directly mirrors the
shadcn/ui
architectural style.
当构建在多个场景中出现、样式不同但核心功能一致的通用可复用UI组件(如按钮、徽章、输入框)时,必须使用
cva
(class-variance-authority)库和
cn
工具管理这些样式变体。这与
shadcn/ui
的架构风格完全一致。

Simple Flat Composition

简单扁平组合

When designing complex reusable UI components (e.g., Table, DropdownMenu, Select), keep them as independent, flat components (e.g.,
Table
,
TableRow
,
TableBody
,
TableCell
). You MUST NOT use the dot-notation Compound Component pattern (
Table.Row
) for now to avoid unnecessary complexity. Each part should simply be imported and used as a distinct component.

在设计复杂的可复用UI组件(如表、下拉菜单、选择器)时,需将它们设计为独立的扁平组件(如
Table
TableRow
TableBody
TableCell
)。目前请勿使用点符号复合组件模式(
Table.Row
),以避免不必要的复杂度。每个组件部分应作为独立组件导入并使用。

3. Logic-Based Responsive Separation

3. 基于逻辑的响应式拆分

If a component requires completely different React structural elements or rendering logic for Mobile screens versus Desktop screens (e.g., a Mobile user gets a swipe-up
<Drawer>
, and a Desktop user gets a centered
<Dialog>
), you SHALL separate this into multiple distinct local components within the same file.
For example, you MUST structure your file with:
  1. function ContentList()
    (The wrapper that measures
    useIsMobile()
    )
  2. function ContentListMobile()
  3. function ContentListDesktop()
This separation MUST be based on structural or lifecycle logic (
{isMobile ? <Mobile> : <Desktop>}
), NOT merely on visual styling differences which belong in CSS (Tailwind classes).

如果组件针对移动端和桌面端需要完全不同的React结构元素或渲染逻辑(例如,移动端用户看到上滑
<Drawer>
,桌面端用户看到居中
<Dialog>
),则应将其拆分为同一文件中的多个独立本地组件。
例如,你必须按以下结构组织文件:
  1. function ContentList()
    (用于检测
    useIsMobile()
    的包装组件)
  2. function ContentListMobile()
  3. function ContentListDesktop()
这种拆分必须基于结构或生命周期逻辑(
{isMobile ? <Mobile> : <Desktop>}
),而非仅基于视觉样式差异(视觉样式差异应通过CSS/Tailwind类处理)。

4. The Server → Client Serialization Boundary

4. Server → Client序列化边界

Next.js serializes props when passing them from a Server Component to a Client Component (RSC payload). Class instances are not serializable — this includes all Value Object classes. This is a transport constraint, not a general restriction: a Client Component can instantiate or use VOs internally just fine.
当从Server Component向Client Component传递props时,Next.js会对props进行序列化(RSC payload)。类实例不可序列化 — 包括所有值对象类。这是一个传输约束,而非通用限制:Client Component可以在内部正常实例化或使用VO。

The
toBranded()
pattern

toBranded()
模式

VOs expose a
.toBranded()
method that returns a branded primitive (a plain
number
or
string
tagged with a unique TypeScript symbol). It solves two problems at once:
  1. Serializability — a branded primitive crosses the RSC boundary without issues.
  2. Validation guarantee — because
    toBranded()
    can only be called on an already-constructed VO, the receiving Client Component knows the value has already been validated. There is no need to call
    .from()
    again or handle
    Result
    errors — the branded type itself is the proof that the value is valid.
typescript
// In the VO definition
declare const uniqueBrand: unique symbol;
export type BrandedFolderId = string & { readonly [uniqueBrand]: never };

export class FolderId {
  // ...
  toBranded(): BrandedFolderId {
    return this.value as BrandedFolderId;
    // Caller receives a plain string at runtime,
    // but TypeScript knows it came from a valid FolderId.
  }
}
Server Component — calls
.toBranded()
before passing the prop:
tsx
// Server Component
import { FolderItem } from "./FolderItem"; // client component

export async function FolderList() {
  const folders = /* from use case */;

  return folders.map((folder) => (
    <FolderItem
      key={folder.getId().toBranded()}
      folderId={folder.getId().toBranded()} // ✅ branded primitive
      name={folder.getName().toBranded()}    // ✅ plain string
    />
  ));
}
Client Component — receives and uses the branded primitive:
tsx
"use client";

import { type BrandedFolderId } from "@/folders/domain/value-objects/folderId";

interface FolderItemProps {
  folderId: BrandedFolderId; // branded primitive — valid by construction, no need to re-validate
  name: string;
}

export function FolderItem({ folderId, name }: FolderItemProps) {
  // folderId is a string at runtime, branded for type safety.
  // Pass it directly to a server action — no .from() or error handling needed.
  // The server action receives it as a string and reconstructs the VO internally.
}
值对象暴露了
.toBranded()
方法,该方法返回一个品牌化原始值(带有唯一TypeScript标记的普通
number
string
)。它同时解决了两个问题:
  1. 可序列化 — 品牌化原始值可以无问题地跨越RSC边界。
  2. 验证保证 — 因为
    .toBranded()
    只能在已构建的VO上调用,接收方Client Component知道该值已通过验证。无需再次调用
    .from()
    或处理
    Result
    错误 — 品牌类型本身就是值有效的证明。
typescript
// 在VO定义中
declare const uniqueBrand: unique symbol;
export type BrandedFolderId = string & { readonly [uniqueBrand]: never };

export class FolderId {
  // ...
  toBranded(): BrandedFolderId {
    return this.value as BrandedFolderId;
    // 运行时调用方收到普通字符串,
    // 但TypeScript知道它来自有效的FolderId。
  }
}
Server Component — 在传递props前调用
.toBranded()
tsx
// Server Component
import { FolderItem } from "./FolderItem"; // client component

export async function FolderList() {
  const folders = /* 来自用例 */;

  return folders.map((folder) => (
    <FolderItem
      key={folder.getId().toBranded()}
      folderId={folder.getId().toBranded()} // ✅ 品牌化原始值
      name={folder.getName().toBranded()}    // ✅ 普通字符串
    />
  ));
}
Client Component — 接收并使用品牌化原始值:
tsx
"use client";

import { type BrandedFolderId } from "@/folders/domain/value-objects/folderId";

interface FolderItemProps {
  folderId: BrandedFolderId; // 品牌化原始值 — 构建时已验证,无需重新验证
  name: string;
}

export function FolderItem({ folderId, name }: FolderItemProps) {
  // 运行时folderId是字符串,通过品牌化实现类型安全。
  // 直接传递给Server Action — 无需调用.from()或处理错误。
  // Server Action接收字符串并在内部重建VO。
}

What can cross the boundary

可跨越边界的类型

TypeSerializableHow
string
,
number
,
boolean
,
null
Directly
Plain objects
{ key: value }
Directly (no class methods)
Arrays of the aboveDirectly
Date
Passed as-is (Next.js handles it)
VO class instanceUse
.toBranded()
Functions / callbacksNot serializable — use Server Actions instead
undefined
⚠️Omit the prop or use
null

类型是否可序列化方式
string
,
number
,
boolean
,
null
直接传递
普通对象
{ key: value }
直接传递(无类方法)
上述类型的数组直接传递
Date
直接传递(Next.js会处理)
VO类实例使用
.toBranded()
函数/回调不可序列化 — 使用Server Action替代
undefined
⚠️省略该props或使用
null

4. Composition Patterns

4. 组合模式

Server renders Client (standard)

Server渲染Client(标准方式)

A Server Component can render a Client Component and pass props. Props must be serializable (see table above):
tsx
// ServerPage.tsx (server)
import { ClientWidget } from "./ClientWidget";

export default async function ServerPage() {
  const data = await fetchData();
  return <ClientWidget items={data.map((d) => ({ id: d.getId().toBranded(), name: d.getName().toBranded() }))} />;
}
Server Component可以渲染Client Component并传递props。props必须可序列化(见上表):
tsx
// ServerPage.tsx (server)
import { ClientWidget } from "./ClientWidget";

export default async function ServerPage() {
  const data = await fetchData();
  return <ClientWidget items={data.map((d) => ({ id: d.getId().toBranded(), name: d.getName().toBranded() }))} />;
}

Client renders Server via
children
(slot pattern)

Client通过
children
渲染Server(插槽模式)

A Client Component cannot import Server Components. But a Server Component can pass Server Components as
children
to a Client Component:
tsx
// Layout.tsx (server)
import { Sidebar } from "./Sidebar"; // client component
import { UserProfile } from "./UserProfile"; // server component

export default async function Layout({ children }: { children: ReactNode }) {
  return (
    <Sidebar>
      <UserProfile /> {/* server component passed as children — valid */}
      {children}
    </Sidebar>
  );
}
tsx
// Sidebar.tsx (client)
"use client";

export function Sidebar({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(true);
  return <aside>{children}</aside>; // renders server components correctly
}
Rule: Client Components must never
import
Server-only modules. Pass server-rendered content via
children
or other slot props instead.
Client Component 无法导入Server Component。但Server Component可以将Server Component作为
children
传递给Client Component:
tsx
// Layout.tsx (server)
import { Sidebar } from "./Sidebar"; // client component
import { UserProfile } from "./UserProfile"; // server component

export default async function Layout({ children }: { children: ReactNode }) {
  return (
    <Sidebar>
      <UserProfile /> {/* 作为children传递的server组件 — 合法 */}
      {children}
    </Sidebar>
  );
}
tsx
// Sidebar.tsx (client)
"use client";

export function Sidebar({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(true);
  return <aside>{children}</aside>; // 正确渲染server组件
}
规则:Client Component绝不能
import
仅服务端模块。通过
children
或其他插槽props传递服务端渲染的内容。

Pushing the client boundary down

降低Client边界层级

Keep the
"use client"
boundary as low in the tree as possible. Extract only the interactive part into a Client Component and keep the rest as a Server Component:
tsx
// ✅ Only the button is a client component
export async function FolderCard({ folder }: Props) {
  return (
    <div>
      <h2>{folder.getName().toBranded()}</h2>
      <DeleteFolderButton folderId={folder.getId().toBranded()} /> {/* client */}
    </div>
  );
}

// ❌ The entire card becomes client just because of one button
"use client";
export function FolderCard({ folder }: Props) { ... }

尽可能将
"use client"
边界置于组件树的最低层。仅将交互部分提取为Client Component,其余部分保留为Server Component:
tsx
// ✅ 仅按钮是client组件
export async function FolderCard({ folder }: Props) {
  return (
    <div>
      <h2>{folder.getName().toBranded()}</h2>
      <DeleteFolderButton folderId={folder.getId().toBranded()} /> {/* client */}
    </div>
  );
}

// ❌ 仅因为一个按钮,整个卡片变成client组件
"use client";
export function FolderCard({ folder }: Props) { ... }

5. Props Interface Rules

5. Props接口规则

  • Always define an explicit
    interface
    or
    type
    for component props — never inline complex shapes or use
    any
    .
  • Export the props type if other components or tests need it.
  • VO class instances MUST NOT appear in Client Component props received from a Server Component — use the branded primitive type. A Client Component may, however, instantiate or work with VOs internally.
tsx
// ✅ Correct — serializable props for data coming from a Server Component
interface LinksTableProps {
  links: {
    id: BrandedUUID;
    name: BrandedLinkName;
    shortLink: BrandedShortLink;
    destination: BrandedDestination;
    clicks: BrandedNonNegativeInteger;
  }[];
}

// ❌ Wrong — VO class in props passed from a Server Component (not serializable)
interface LinksTableProps {
  links: ShortUrl[]; // ShortUrl is a class — cannot cross the RSC boundary
}

  • 始终为组件props定义明确的
    interface
    type
    — 绝不要内联复杂结构或使用
    any
  • 如果其他组件或测试需要,导出props类型。
  • VO类实例绝不能出现在从Server Component接收的Client Componentprops中 — 使用品牌化原始类型。不过,Client Component可以在内部实例化或处理VO。
tsx
// ✅ 正确 — 从Server Component传递的可序列化props
interface LinksTableProps {
  links: {
    id: BrandedUUID;
    name: BrandedLinkName;
    shortLink: BrandedShortLink;
    destination: BrandedDestination;
    clicks: BrandedNonNegativeInteger;
  }[];
}

// ❌ 错误 — 从Server Component传递的props中包含VO类(不可序列化)
interface LinksTableProps {
  links: ShortUrl[]; // ShortUrl是类 — 无法跨越RSC边界
}

Checklist

检查清单

Server Components

Server Component

  • No
    "use client"
    directive
  • Function is
    async
  • Locale via
    await determineLocale()
  • Data fetched via
    serviceContainer
    use cases
  • VO values passed to Client Components via
    .toBranded()
    , never as class instances
  • "use client"
    指令
  • 函数为
    async
  • 通过
    await determineLocale()
    获取Locale
  • 通过
    serviceContainer
    用例获取数据
  • 传递给Client Component的VO值使用
    .toBranded()
    ,绝不是类实例

Client Components

Client Component

  • "use client"
    is the first line of the file — before any imports
  • Function is not
    async
  • Locale via
    useLocale()
    — never
    determineLocale()
  • Mutations call a Server Action then
    router.refresh()
    — never
    window.location.reload()
  • No imports of server-only modules (drizzle,
    load-env
    ,
    auth.ts
    , etc.)
  • Props received from Server Components use branded primitives or plain values — never VO class instances
  • Explicit props
    interface
    defined and named
  • "use client"
    位于文件第一行 — 所有导入语句之前
  • 函数不是
    async
  • 通过
    useLocale()
    获取Locale — 绝不用
    determineLocale()
  • 变更操作调用Server Action后执行
    router.refresh()
    — 绝不用
    window.location.reload()
  • 不导入仅服务端模块(drizzle、
    load-env
    auth.ts
    等)
  • 从Server Component接收的props使用品牌化原始值或普通值 — 绝不是VO类实例
  • 定义并命名明确的props
    interface

Boundary

边界

  • All props crossing Server → Client are serializable (strings, numbers, booleans, plain objects, arrays, Date)
  • IDs passed across the boundary use
    .toBranded()
    — branded type signals the value is already validated
  • "use client"
    boundary pushed as low in the tree as possible
  • Server content passed into Client Components via
    children
    slot, not by importing Server Components
  • 所有跨Server→Client的props均可序列化(字符串、数字、布尔值、普通对象、数组、Date)
  • 跨边界传递的ID使用
    .toBranded()
    — 品牌类型表明值已验证
  • "use client"
    边界尽可能置于组件树最低层
  • 服务端内容通过
    children
    插槽传递给Client Component,而非导入Server Component

7. References

7. 参考资料

For practical examples of components adhering to these rules, see the
.agents/skills/components/references/
directory:
  • server-component-i18n.md
  • server-component.md
  • client-component-i18n.md
  • client-component.md
  • responsive-component.md
如需符合这些规则的组件实用示例,请查看
.agents/skills/components/references/
目录:
  • server-component-i18n.md
  • server-component.md
  • client-component-i18n.md
  • client-component.md
  • responsive-component.md