mcpField
N
nextjs-server-client-components

nextjs-server-client-components

github.com/wsimmonds开发109361 次安装53 周前更新
代码仓库

关于 nextjs-server-client-components

这是一项面向 Next.js App Router 开发者的编码技能,指导如何选择 Server 或 Client Components,并处理路由数据、浏览器功能和 Promise。内容还列出常见模式,并提醒避免使用 TypeScript `any`。

主要功能

  1. 01选择 Server 或 Client Components
  2. 02处理 Cookie 和请求标头
  3. 03使用 searchParams 和路由参数
  4. 04使用路径名与导航 Hooks
  5. 05通过 React `use` 解包 Promise
  6. 06避免 TypeScript `any`

适用场景

  1. 01判断组件应在哪一端渲染
  2. 02读取请求 Cookie 和标头
  3. 03依据 URL 参数构建页面
  4. 04排查客户端边界和 Suspense 问题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wsimmonds/claude-nextjs-skills --skill nextjs-server-client-components

安装后在 Claude Code 中输入/nextjs-server-client-components确认已加载。

详细介绍

Next.js 服务端组件与客户端组件

概述

提供关于如何在 Next.js App Router 中选择服务端组件或客户端组件的全面指南,包括访问 cookie/标头、处理 searchParams、路径名路由,以及使用 React 的 'use' API 解包 Promise。

TypeScript:绝不使用 any 类型

关键规则: 此代码库已启用 @typescript-eslint/no-explicit-any。使用 any 会导致构建失败。

❌ 错误:

function handleSubmit(e: any) { ... }
const data: any[] = [];

✅ 正确:

function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
常见的 Next.js 类型模式
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }

// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }

// Server actions
async function myAction(formData: FormData) { ... }

何时使用此技能

在以下情况下使用此技能:

  • 决定使用服务端组件还是客户端组件
  • 访问 cookie、标头或其他服务端数据
  • 处理 searchParams 或路由参数
  • 需要路径名或路由信息
  • 使用 React 'use' API 解包 Promise
  • 调试 'use client' 边界问题
  • 优化组件渲染策略

核心决策:服务端组件还是客户端组件

默认:服务端组件

App Router 中的所有组件默认都是服务端组件。 无需添加指令。

// app/components/ProductList.tsx
// This is a Server Component (default)
export default async function ProductList() {
  const products = await fetch('https://api.example.com/products');
  const data = await products.json();

  return (
    <ul>
      {data.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

适用场景:

  • 从 API 或数据库获取数据
  • 访问后端资源(环境变量、文件系统)
  • 处理敏感信息(API 密钥、令牌)
  • 减少客户端 JavaScript 包的体积
  • 渲染对 SEO 至关重要的内容
  • 内容静态或很少变化

优点:

  • 默认不包含客户端 JavaScript
  • 可直接访问数据库/API
  • 安全地处理机密信息
  • 自动拆分代码
  • 提升初始页面加载性能
  • 减小包体积
客户端组件:'use client'

在文件顶部添加 'use client' 指令,将其设为客户端组件。

// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

适用场景:

  • 需要使用 React Hook(useState、useEffect、useContext 等)
  • 事件处理器(onClick、onChange、onSubmit 等)
  • 仅限浏览器的 API(window、localStorage、navigator)
  • 需要浏览器环境的第三方库
  • 交互式 UI 元素(模态框、下拉菜单、表单)
  • 实时功能(WebSocket、动画)

客户端组件的要求:

  • 必须在文件顶部添加 'use client' 指令
  • 不能在组件中直接使用 async/await
  • 不能访问仅限服务端的 API(cookie、标头)
  • 所有导入的组件都会变成客户端组件
⚠️ 关键:服务端组件绝不需要 'use client'

服务端组件是默认选项。除非确实需要客户端功能,否则不要添加 'use client'。

✅ 正确 - 使用导航的服务端组件:

// app/page.tsx - Server Component (NO 'use client' needed!)
import Link from 'next/link';
import { redirect } from 'next/navigation';

export default async function Page() {
  // Server components can be async
  const data = await fetchData();

  if (!data) {
    redirect('/login');  // Server-side redirect
  }

  return (
    <div>
      <Link href="/dashboard">Go to Dashboard</Link>
      <p>{data.content}</p>
    </div>
  );
}

❌ 错误 - 为服务端组件添加 'use client':

// app/page.tsx
'use client';  // ❌ WRONG! Don't add this to server components!

export default async function Page() {  // ❌ Will fail - async client components not allowed
  const data = await fetchData();
  return <div>{data.content}</div>;
}

服务端导航方法(无需 'use client'):

  • 来自 next/link 的 <Link> 组件
  • 来自 next/navigation 的 redirect() 函数
  • 服务端操作(参见 Advanced Routing 技能)

客户端导航方法(需要 'use client'):

  • 来自 next/navigation 的 useRouter() Hook
  • usePathname() Hook
  • useSearchParams() Hook(还需要 Suspense)

服务端组件模式

访问 Cookie

在服务端组件中使用 next/headers 读取 cookie:

// app/dashboard/page.tsx
import { cookies } from 'next/headers';

export default async function Dashboard() {
  const cookieStore = await cookies();
  const token = cookieStore.get('session-token');

  if (!token) {
    redirect('/login');
  }

  const user = await fetchUser(token.value);

  return <div>Welcome, {user.name}</div>;
}

重要说明:

  • 在 Next.js 15 及更高版本中,必须等待 cookies() 完成
  • 服务端组件中的 cookie 为只读
  • 如需设置 cookie,请使用服务端操作(参见 Advanced Routing 技能)
  • 只能在服务端组件中访问 cookie
访问标头
// app/api/route.ts or any Server Component
import { headers } from 'next/headers';

export default async function Page() {
  const headersList = await headers();
  const userAgent = headersList.get('user-agent');
  const referer = headersList.get('referer');

  return <div>User Agent: {userAgent}</div>;
}
使用 searchParams

在服务端组件中直接访问 URL 查询参数:

// app/search/page.tsx
export default async function SearchPage({
  searchParams,
}: {
  searchParams: { q?: string; category?: string };
}) {
  const query = searchParams.q || '';
  const category = searchParams.category || 'all';

  const results = await searchProducts(query, category);

  return (
    <div>
      <h1>Search Results for: {query}</h1>
      <p>Category: {category}</p>
      <ProductList products={results} />
    </div>
  );
}

重要说明:

  • searchParams 仅在 page.tsx 文件中可用
  • 在 Next.js 15+ 中,必须等待 searchParams
  • searchParams 在 layout.tsx 中不可用
  • 如果客户端组件需要,可使用客户端的 useSearchParams() Hook

⚠️ 重要警告 - Next.js 15+ 中的 searchParams: 在 Next.js 15+ 中提取参数时,必须使用解构,以便 searchParams 标识符与参数提取代码位于同一行。不要使用 params 或 resolved 之类的中间变量——这是反模式,会影响代码可读性和测试模式。

异步 searchParams(Next.js 15+):

// app/search/page.tsx (Next.js 15+)
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  // BEST PRACTICE: Inline access keeps searchParams and parameter together on one line
  const q = (await searchParams).q || '';

  return <div>Search: {q}</div>;
}

关键模式要求:

从 searchParams 提取参数时,始终使用内联访问,确保 searchParams 和参数名称位于同一行:

// ✅ CORRECT: Inline access (REQUIRED PATTERN)
const name = (await searchParams).name || '';

// ✅ ALSO CORRECT: Multiple parameters
const category = (await searchParams).category || 'all';
const sort = (await searchParams).sort || 'asc';

// ❌ WRONG: Using intermediate variable separates searchParams from parameter
const params = await searchParams;  // DON'T DO THIS
const name = params.name;           // searchParams not visible here

// ❌ WRONG: Destructuring (searchParams and name on same line but missing second 'name')
const { name } = await searchParams;  // Not preferred

使用内联访问的原因:

  • 确保 searchParams 标识符与参数提取代码位于同一行
  • 明确体现 URL 参数与变量之间的关系
  • 满足代码审查和测试模式对正确使用 searchParams 的要求
使用 pathname 和路由信息

在服务端组件中(page.tsx):

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  // params contains route parameters
  const post = await getPost(params.slug);

  return <article>{post.title}</article>;
}

异步 params(Next.js 15+):

// app/blog/[slug]/page.tsx (Next.js 15+)
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return <article>{post.title}</article>;
}

在客户端组件中:

使用 next/navigation 中的 Hook:

// app/components/Breadcrumbs.tsx
'use client';

import { usePathname, useParams, useSearchParams } from 'next/navigation';

export default function Breadcrumbs() {
  const pathname = usePathname(); // Current path: /blog/hello-world
  const params = useParams(); // Route params: { slug: 'hello-world' }
  const searchParams = useSearchParams(); // Query params

  return (
    <nav>
      <span>Current path: {pathname}</span>
      <span>Slug: {params.slug}</span>
      <span>Search: {searchParams.get('q')}</span>
    </nav>
  );
}
⚠️ 重要:useSearchParams 始终需要 Suspense

使用 useSearchParams() Hook 时,必须:

  1. 在文件顶部添加 'use client' 指令
  2. 将组件包裹在 Suspense 边界中

这是 Next.js 的要求——未同时满足这两点会导致错误。

✅ 正确模式:

// app/page.tsx or any parent component
import { Suspense } from 'react';
import SearchComponent from './SearchComponent';

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <SearchComponent />
    </Suspense>
  );
}

// app/SearchComponent.tsx
'use client';

import { useSearchParams } from 'next/navigation';

export default function SearchComponent() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q');

  return <div>Search query: {query}</div>;
}

❌ 错误 - 缺少 'use client':

// This will fail - useSearchParams requires 'use client'
import { useSearchParams } from 'next/navigation';

export default function SearchComponent() {
  const searchParams = useSearchParams(); // ERROR!
  return <div>{searchParams.get('q')}</div>;
}

❌ 错误 - 缺少 Suspense 包装器:

// This will cause issues - useSearchParams requires Suspense
export default function Page() {
  return <SearchComponent />; // Missing Suspense wrapper!
}

用于展开 Promise 的 React 'use' API

React use API 可用于在服务端和客户端组件中读取 Promise 和上下文。

将 'use' 与 Promise 搭配使用
// app/components/UserProfile.tsx
'use client';

import { use } from 'react';

// IMPORTANT: Use specific types, generic types, or 'unknown' - NEVER 'any'
// Option 1: Specific type (best when type is known)
export default function UserProfile({
  userPromise
}: {
  userPromise: Promise<{ name: string; email: string }>
}) {
  // Unwrap the promise
  const user = use(userPromise);

  return <div>{user.name}</div>;
}

// Option 2: Generic type (for reusable components)
export function GenericDataDisplay<T>({
  data
}: {
  data: Promise<T>
}) {
  const result = use(data);
  return <div>{JSON.stringify(result)}</div>;
}

// Option 3: Unknown type (when type truly unknown)
export function UnknownDataDisplay({
  data
}: {
  data: Promise<unknown>
}) {
  const result = use(data);
  return <div>{JSON.stringify(result)}</div>;
}

传递 Promise 的服务端组件:

// app/profile/page.tsx
import UserProfile from './components/UserProfile';

export default function ProfilePage() {
  // Create promise but don't await
  const userPromise = fetchUser();

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}
'use' API 的优势
  • 支持并行获取数据
  • 可与 Suspense 边界配合使用
  • 允许服务端组件向客户端组件传递 Promise
  • 比逐层传递异步数据的方式更简洁
将 'use' 与上下文搭配使用
'use client';

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

export default function ThemedButton() {
  const theme = use(ThemeContext);

  return <button className={theme.buttonClass}>Click me</button>;
}

常见模式

模式 1:服务端组件获取数据,客户端组件进行交互
// app/products/page.tsx (Server Component)
import ProductGrid from './ProductGrid';

export default async function ProductsPage() {
  const products = await fetchProducts();

  // Pass data to Client Component
  return <ProductGrid products={products} />;
}

// app/products/ProductGrid.tsx (Client Component)
'use client';

import { useState } from 'react';

export default function ProductGrid({
  products
}: {
  products: Product[]
}) {
  const [filter, setFilter] = useState('all');

  const filtered = products.filter(p =>
    filter === 'all' || p.category === filter
  );

  return (
    <div>
      <select onChange={(e) => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      {filtered.map(p => <div key={p.id}>{p.name}</div>)}
    </div>
  );
}
模式 2:并行获取数据
// app/dashboard/page.tsx
export default async function Dashboard() {
  // Fetch in parallel
  const [user, stats, notifications] = await Promise.all([
    fetchUser(),
    fetchStats(),
    fetchNotifications(),
  ]);

  return (
    <div>
      <UserInfo user={user} />
      <Stats data={stats} />
      <Notifications items={notifications} />
    </div>
  );
}
模式 3:使用 Suspense 流式传输
// app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading stats...</div>}>
        <Stats />
      </Suspense>
      <Suspense fallback={<div>Loading feed...</div>}>
        <Feed />
      </Suspense>
    </div>
  );
}

async function Stats() {
  const data = await fetchStats(); // Slow query
  return <div>{data.total}</div>;
}

async function Feed() {
  const items = await fetchFeed(); // Fast query
  return <ul>{items.map(i => <li key={i.id}>{i.title}</li>)}</ul>;
}
模式 4:组合——在客户端组件中嵌入服务端组件

可以将服务端组件作为子项传递给客户端组件:

// app/page.tsx (Server Component)
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent';

export default function Page() {
  return (
    <ClientWrapper>
      {/* Server Component as children */}
      <ServerContent />
    </ClientWrapper>
  );
}

// ClientWrapper.tsx (Client Component)
'use client';

import { useState } from 'react';

export default function ClientWrapper({
  children
}: {
  children: React.ReactNode
}) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  );
}

// ServerContent.tsx (Server Component)
export default async function ServerContent() {
  const data = await fetchData();
  return <div>{data.content}</div>;
}

应避免的反模式

反模式 1:到处使用 'use client'

错误:

// app/components/Header.tsx
'use client';  // Unnecessary!

export default function Header() {
  return <header><h1>My App</h1></header>;
}

正确:

// app/components/Header.tsx
// No directive needed - keep it as Server Component
export default function Header() {
  return <header><h1>My App</h1></header>;
}

原因: 仅在确实需要客户端功能时才使用 'use client'。静态组件应保留为服务端组件,以减小打包体积。

反模式 2:在客户端组件中获取数据

错误:

'use client';

import { useState, useEffect } from 'react';

export default function Products() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch('/api/products')
      .then(r => r.json())
      .then(setProducts);
  }, []);

  return <div>{products.map(p => <div key={p.id}>{p.name}</div>)}</div>;
}

正确:

// Server Component - no 'use client'
export default async function Products() {
  const response = await fetch('https://api.example.com/products');
  const products = await response.json();

  return <div>{products.map(p => <div key={p.id}>{p.name}</div>)}</div>;
}

原因: 服务端组件可以直接获取数据,从而消除加载状态并减少客户端 JavaScript。

反模式 3:在客户端组件中访问服务端 API

错误:

'use client';

import { cookies } from 'next/headers'; // ERROR!

export default function ClientComponent() {
  const cookieStore = cookies(); // This will fail
  return <div>...</div>;
}

正确:

// Server Component
import { cookies } from 'next/headers';
import ClientComponent from './ClientComponent';

export default async function ServerComponent() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token')?.value;

  return <ClientComponent token={token} />;
}

原因: cookies()、headers() 和其他仅限服务端的 API 只能在服务端组件中使用。

反模式 4:串行等待(瀑布式加载)

错误:

export default async function Page() {
  const user = await fetchUser();
  const posts = await fetchPosts();  // Waits for user to finish
  const comments = await fetchComments();  // Waits for posts to finish

  return <div>...</div>;
}

正确:

export default async function Page() {
  // Fetch in parallel
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ]);

  return <div>...</div>;
}

原因: 并行获取数据可显著缩短总加载时间。

反模式 5:在客户端组件中导入服务端组件

错误:

// ClientComponent.tsx
'use client';

import ServerComponent from './ServerComponent'; // This makes it a Client Component!

export default function ClientComponent() {
  return <div><ServerComponent /></div>;
}

正确:

// ParentServerComponent.tsx (Server Component)
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';

export default function ParentServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

原因: 将服务端组件导入客户端组件会使其变成客户端组件。应改为将其作为子项传递。

适合使用客户端组件的场景

以下场景适合使用客户端组件:

1. 交互式表单
'use client';

import { useState } from 'react';

export default function ContactForm() {
  const [email, setEmail] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Handle submission
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}
2. 实时功能
'use client';

import { useEffect, useState } from 'react';

export default function LiveChat() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket('wss://chat.example.com');
    ws.onmessage = (event) => {
      setMessages(prev => [...prev, event.data]);
    };
    return () => ws.close();
  }, []);

  return <div>{messages.map((m, i) => <div key={i}>{m}</div>)}</div>;
}
3. 仅限浏览器的功能
'use client';

import { useState, useEffect } from 'react';

export default function GeolocationDisplay() {
  const [location, setLocation] = useState(null);

  useEffect(() => {
    navigator.geolocation.getCurrentPosition((pos) => {
      setLocation({
        lat: pos.coords.latitude,
        lng: pos.coords.longitude,
      });
    });
  }, []);

  return location ? <div>Lat: {location.lat}, Lng: {location.lng}</div> : null;
}
4. 需要 Window 的第三方库
'use client';

import { useEffect, useState } from 'react';
import confetti from 'canvas-confetti';

export default function CelebrationButton() {
  const handleClick = () => {
    confetti();
  };

  return <button onClick={handleClick}>Celebrate!</button>;
}
5. React Context 提供程序
'use client';

import { createContext, useState } from 'react';

export const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

快速决策树

Need interactivity? (onClick, onChange, etc.)
├─ Yes → Client Component ('use client')
└─ No → Continue...

Need React hooks? (useState, useEffect, etc.)
├─ Yes → Client Component ('use client')
└─ No → Continue...

Need browser APIs? (window, localStorage, etc.)
├─ Yes → Client Component ('use client')
└─ No → Continue...

Need to fetch data?
├─ Yes → Server Component (default)
└─ No → Continue...

Need cookies/headers/searchParams?
├─ Yes → Server Component (default)
└─ No → Server Component (default, unless specific need)

测试组件类型

要验证组件类型:

// This works = Server Component
export default async function MyComponent() { ... }

// This works = Server Component
import { cookies } from 'next/headers';

// This works = Client Component
'use client';
import { useState } from 'react';

// This fails = Wrong combination
'use client';
import { cookies } from 'next/headers'; // ERROR!

总结

  • 默认使用服务器组件——它们速度更快、更安全
  • 仅在需要交互性或浏览器 API 时使用客户端组件
  • 切勿在客户端组件中使用 useEffect 获取数据——应使用服务器组件
  • 使用 React「use」API 将 Promise 传递给客户端组件
  • 仅在服务器组件中访问 cookies/headers/searchParams
  • 使用组合模式混合使用服务器组件和客户端组件
  • 使用 Promise.all 并行获取数据,避免瀑布式请求

评论

支持 Markdown