children or props.Want a client-side interactive shell (tab switcher, accordion, modal) with server-rendered content inside? Pass the content as children.
1// components/Tabs.tsx — Client
2"use client";
3import { useState } from "react";
4
5export function Tabs({ labels, children }: { labels: string[]; children: React.ReactNode[] }) {
6 const [active, setActive] = useState(0);
7 return (
8 <div>
9 <nav>{labels.map((l, i) => (
10 <button key={l} onClick={() => setActive(i)} className={active === i ? "font-bold" : ""}>
11 {l}
12 </button>
13 ))}</nav>
14 <section>{children[active]}</section>
15 </div>
16 );
17}1// app/dashboard/page.tsx — Server
2import { Tabs } from "@/components/Tabs";
3import { Earnings } from "./Earnings"; // Server Component
4import { Students } from "./Students"; // Server Component, async + DB
5
6export default function Dashboard() {
7 return (
8 <Tabs labels={["Earnings", "Students"]}>
9 <Earnings />
10 <Students />
11 </Tabs>
12 );
13}The Tabs shell is interactive. The slot contents are still rendered on the server with full access to the DB. The bundle ships only Tabs to the client — not Earnings or Students.
1// app/profile/page.tsx — Server
2import { ProfileForm } from "./ProfileForm";
3
4export default async function Profile() {
5 const user = await getCurrentUser();
6 return <ProfileForm initialValues={{ name: user.name, email: user.email }} />;
7}
8
9// ./ProfileForm.tsx — Client
10"use client";
11import { useState } from "react";
12
13export function ProfileForm({ initialValues }: { initialValues: { name: string; email: string } }) {
14 const [v, setV] = useState(initialValues);
15 // …
16}1// ❌ Will fail
2<MyClient onSave={async (data) => db.user.update(data)} />Pass a Server Action instead — it serializes to a reference, not a function body.
1// ✅
2import { saveUser } from "./actions";
3<MyClient onSave={saveUser} />server-only and client-only Packages¶To prevent accidental bundle leaks, use these tiny marker packages:
1npm i server-only client-only1// lib/db.ts
2import "server-only";
3import { PrismaClient } from "@prisma/client";
4export const db = new PrismaClient();If anything imports lib/db.ts from a client component, the build will fail with a clear error.
1// lib/analytics.ts
2import "client-only";
3import posthog from "posthog-js";
4posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!);A real Next.js app typically splits like this:
| Layer | Components | Roughly % of LOC |
|---|---|---|
| Pages & layouts | Server | 30% |
| Domain components (Cards, Lists, Details) | Server | 40% |
| Interactive shells (Tabs, Modals, Forms) | Client | 20% |
| Primitives (Button, Input, Dialog) | Mostly Client | 10% |
Server Components dominate the line count. Client Components stay narrow.