You need a theme provider, so you create a file, add 'use client' at the top, and wrap your app in it.
Then a nagging thought arrives: "I just wrapped my entire layout in a client component. Did I turn the whole app into client-side React?"
Most developers either panic and avoid providers, or give up and put 'use client' at the top of every file "just to be safe". Both are the wrong response, and the actual rule is simpler than you expect.
The Setup
// components/ThemeProvider.tsx
'use client';
import { createContext, useState } from 'react';
export const ThemeContext = createContext('light');
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme] = useState('light');
return <ThemeContext value={theme}>{children}</ThemeContext>;
}
// app/layout.tsx (a Server Component)
import { ThemeProvider } from '@/components/ThemeProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
ThemeProvider is a Client Component. It wraps every page. So are all the pages client components now?
No.
What 'use client' Actually Does
'use client' marks a boundary. It says: "this file, and everything this file imports, is part of the JavaScript sent to the browser."
The key word is imports. The boundary follows the import graph, not the position in the rendered tree.
In the layout above, children is not imported by ThemeProvider. It is passed in as a prop by the layout, which is a Server Component. The server renders the page first, and hands the finished result to the client component as a slot to fill.
So your pages stay Server Components. They can still fetch data on the server, use secrets, and ship no JavaScript for their static parts.
The Mistake That Does Change Things
Now look at this version:
// components/ThemeProvider.tsx
'use client';
import { Dashboard } from './Dashboard'; // imported, not passed in
export function ThemeProvider() {
// ...
return <Dashboard />;
}
Here the client file imports Dashboard, so Dashboard joins the client bundle, along with everything it imports. If it was a Server Component that queried your database, it is no longer one. It will either break the build or quietly ship heavy code to every visitor.
The difference between the two examples is a single word: import versus prop.
The Pattern: Pass Server Components Through as Children
Any time a client component needs to wrap server content, use a slot:
// components/Modal.tsx
'use client';
import { useState } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Show stats</button>
{open && <div role="dialog">{children}</div>}
</>
);
}
// app/page.tsx (Server Component)
import { Modal } from '@/components/Modal';
import { Stats } from '@/components/Stats'; // reads the database
export default function Page() {
return (
<Modal>
<Stats />
</Modal>
);
}
Stats runs on the server, even though it is rendered inside a client component's JSX. The button and the toggle are interactive in the browser. The data never leaves the server except as finished markup.
This works for any prop that holds JSX, not only children. A sidebar prop or a header prop behaves the same way.
Rule of Thumb: Push the Boundary Down
Put 'use client' on the smallest piece that needs interactivity, not on the page:
// Bad: the whole page becomes client code
'use client';
export default function ProductPage() { /* ...200 lines... */ }
// Better: only the button is client code
export default function ProductPage() {
return (
<main>
<ProductDetails /> {/* server */}
<AddToCartButton /> {/* 'use client' lives here */}
</main>
);
}
A page that is mostly text and images, with one button, should send JavaScript for the button, not for the page. Less JavaScript sent means a faster load, which is what I walk through in this plain-language guide to Core Web Vitals.
Two Things That Still Bite
1. Props crossing the boundary must be serializable. Data goes from server to client as text, so you can pass strings, numbers, plain objects, arrays and dates. You cannot pass a regular function. (Server Actions are the exception, because they are designed to cross.)
// Error: functions cannot be passed to Client Components
<ClientButton onClick={() => console.log('hi')} />
Define the handler inside the client component instead.
2. A server-only module can leak into the client by import. If a client file imports a helper that touches your database or secrets, the build either fails or bundles code you never meant to ship. The server-only package turns that into a loud build error:
// lib/db.ts
import 'server-only';
Import this file from a client component and the build fails, instead of shipping the connection code.
The Rule
'use client' marks an import boundary, not a rendering zone. Whatever a client file imports goes to the browser. Whatever you pass to it as props or children can stay on the server. Keep the directive on small interactive leaves, and use the children slot for everything else.
Quick audit: search your project for 'use client' and look at each file. If it is a page or a layout, ask whether it really needs state or browser APIs. If it is a wrapper, check that it receives its content as children and does not import it.
If you want a Next.js starting point built around the App Router, you can browse my templates on pixelanas.com.
Get the templates: https://pixelanas.gumroad.com
Where in your project is 'use client' doing more than it needs to? Drop it in the comments, and tell me if the children trick surprised you.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (2)
The line that would have saved me a week: the boundary follows the import graph, not the rendered tree. The bite I hit was a shared utils barrel. A client component imported one tiny helper from it, and the barrel re-exported a server-only module two files away, so the db code landed in the client bundle through an import I never wrote directly. Named imports don't save you there. Now I treat any barrel a client file touches as suspect.
That’s a great point! Shared barrels can hide dependencies that are easy to miss, even when importing just one helper. I’d also recommend using import 'server-only' in database and secret-dependent modules to catch accidental imports early. Keeping client and server utilities separate makes these issues much easier to avoid.