Composing Server and Client Components
Passing data across the server/client boundary safely, and the patterns that keep the split from becoming awkward.
読了時間 3 分
Real pages aren't purely server-rendered or purely interactive — they're a mix, and the two kinds of components need to pass data to each other constantly. The rules for doing that safely are stricter than they look at first.
Passing data from Server to Client Components
A Server Component can pass data to a Client Component through ordinary props — with one restriction: the value has to be serializable. Props cross an actual network-shaped boundary (React serializes the Server Component tree into a payload the client reads), so functions, class instances, dates as Date objects, and anything with circular references don't survive the trip.
// app/post/[id]/page.tsx — Server Component
import LikeButton from "@/app/ui/like-button";
import { getPost } from "@/lib/data";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
// `post.likes` (a number) is serializable — this works fine
return <LikeButton postId={post.id} initialLikes={post.likes} />;
}// app/ui/like-button.tsx — Client Component
"use client";
import { useState } from "react";
export default function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
return <button onClick={() => setLikes(likes + 1)}>{likes} likes</button>;
}Streaming a promise instead of awaiting it
Sometimes you don't want the Server Component to wait for slow data before rendering anything. Instead of await-ing a fetch, you can pass the promise itself down and let a Client Component resolve it with React's use() API:
// app/page.tsx
import Posts from "@/app/ui/posts";
import { Suspense } from "react";
export default function Page() {
const postsPromise = getPosts(); // not awaited
return (
<Suspense fallback={<p>Loading posts...</p>}>
<Posts posts={postsPromise} />
</Suspense>
);
}// app/ui/posts.tsx
"use client";
import { use } from "react";
export default function Posts({
posts,
}: {
posts: Promise<{ id: string; title: string }[]>;
}) {
const allPosts = use(posts);
return (
<ul>
{allPosts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}The rest of the page renders immediately; the <Suspense> fallback shows until the promise resolves, then swaps in.
Context providers need a Client Component wrapper
React Context isn't available in Server Components at all — createContext and useContext require a client boundary. The standard pattern is a thin Client Component that only exists to host the provider:
// app/theme-provider.tsx
"use client";
import { createContext } from "react";
export const ThemeContext = createContext<"light" | "dark">("light");
export default function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}You then import this into your (Server Component) root layout and wrap children in it — the provider itself is a Client Component, but everything nested inside it can still be Server Components, since children is passed through as already-rendered output rather than re-entering the provider's module graph.
Keep secrets out of shared modules
A subtler risk: a plain utility file with no directive can be imported by both server and client code. If that file reads process.env.API_KEY and gets imported into a Client Component by accident, Next.js won't include the actual secret value in the bundle (only NEXT_PUBLIC_-prefixed variables ship to the client) — but the broken assumption is still a bug waiting to happen. Import the server-only package into files that must never run on the client; it turns an accidental client import into a build error instead of a silent no-op.