A Next.js App Router product page has 'use client' at the top of page.tsx purely so an Add to Cart button can handle a click, and product data fetched higher up is drilled down through three client components to reach it. Walk through how you restructure this.
answer
- boundary at the root, requirement at a leaf
- push the directive down
- invert wrappers to take children
- pass an id, not the whole object
- drilling is a symptom, not the disease
basics
~20 sMove the directive down to the button. Keep page.tsx a Server Component that fetches where the data is used, render the interactive leaf inline, and where a client component must wrap content, give it a children prop instead of hoisting the boundary above the whole subtree.
solid answer
~50 sThe defect is that the boundary was placed at the top of the tree to satisfy one leaf. I would delete `'use client'` from `page.tsx`, create a small `add-to-cart-button.tsx` with the directive, and render `<AddToCartButton productId={product.id} />` directly from the server page — it needs an id, not the whole product. Everything else on that page goes back to being server-rendered, which also means each section can fetch its own data where it renders instead of having it threaded through as props. Where an interactive component genuinely has to *wrap* content — an accordion, a resizable pane — I invert it: give it a `children` prop and pass the server-rendered subtree in, rather than importing that subtree inside the client module. The rule of thumb is that `'use client'` belongs at the leaves of the tree, and any wrapper that sits above server content takes it as a slot.
code
tsx · 9 lines'use client'
export function AddToCartButton({ productId }: { productId: string }) {
return (
<button type="button" onClick={() => console.log('add', productId)}>
Add to cart
</button>
)
}go deeper
Be able to say the directive belongs on the small interactive component, not on the whole page, and that the page can keep rendering that component directly.
Explain why the drilling appeared: client components cannot fetch server data at the point of use, so the fetch is forced to the top of the boundary and threaded down as props.
Lead with the diagnosis — boundary at the root, requirement at a leaf — then give both moves: push the directive to the leaf, and invert any component that must wrap so it takes children instead of importing the content.
Own the standard and its exceptions: when shared state makes the smallest honest boundary larger than a leaf, where providers sit, and how you keep this from regressing as features accrete on a page.
## Diagnosing the shape Two symptoms travel together here, and they have one cause. The cause is a boundary placed at the **root** of a subtree to satisfy a **leaf** of it. Once `page.tsx` carries `'use client'`, every module it imports is client code, so the entire page — sections, cards, formatting helpers, the lot — is on the client side of the boundary. The first symptom is prop drilling. Server components can each fetch what they need at the point of use. Client components cannot, so the data has to be fetched once, at the top, and threaded downward through components that do not use it. That is the drilling: it is not a styling preference, it is a forced consequence of the boundary position. The second symptom is that everything on the page is now client code — which is a separate topic, but it is worth naming as evidence when you argue for the change. ## The restructuring, step by step **1. Find the actual reason for the directive.** Here it is one `onClick`. Something needs state, an event handler, or a browser API. That component — and only that component — needs the boundary. **2. Extract that leaf into its own module.** ```tsx // app/products/[id]/add-to-cart-button.tsx 'use client' export function AddToCartButton({ productId }: { productId: string }) { return <button onClick={() => addToCart(productId)}>Add to cart</button> } ``` Note the prop: an id, not the product object. A leaf boundary usually needs far less data than a root boundary was being handed. **3. Delete the directive from the page and render the leaf inline.** ```tsx // app/products/[id]/page.tsx — Server Component again import { AddToCartButton } from './add-to-cart-button' export default async function ProductPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const product = await getProduct(id) return ( <article> <h1>{product.name}</h1> <ProductSpecs productId={id} /> <AddToCartButton productId={id} /> </article> ) } ``` **4. Undo the drilling.** Now that `ProductSpecs` and its siblings are server components again, each can await its own data instead of receiving it as props from the top. The page stops being a data-distribution hub. **5. Invert the wrappers that must wrap.** Some interactive components are containers by nature — an accordion, a tab shell, a drag-and-drop pane. You cannot make those leaves. The move is to give them a `children` prop (or named element props) and pass the server-rendered content in from the server parent: ```tsx // server parent <Accordion title="Specifications"> <ProductSpecs productId={id} /> {/* still server-rendered */} </Accordion> ``` The client `Accordion` never imports `ProductSpecs`; it receives finished output and shows or hides it. This is the single most important structural pattern in the App Router, because it is what stops "this needs to be interactive" from cascading upward through the tree. ## Where judgment comes in Not every extraction is worth it. Three considerations: - **Chattiness of state.** If several sibling leaves share state, extracting each one separately means lifting that state into a shared client component anyway. Then the honest boundary is the smallest component that owns the shared state — with server content passed in as children. - **Cohesion.** Splitting a component into a server half and a client half that always ship together, for one handler, can hurt readability more than it helps. Judge it against how much sits below the boundary. - **Contexts.** If the interactive leaves need a client context, place the provider as low as the consumers allow and pass the rest through as children, rather than at the root of every route. ## How to talk about it in an interview Say the diagnosis first ("the boundary is at the root but the requirement is at a leaf"), then the two moves ("push the directive down; invert wrappers to take children"), then the consequence you actually care about ("data fetching goes back to where it is used, so the drilling disappears"). Candidates who jump straight to "split the button out" without naming *why* the drilling existed usually cannot handle the accordion follow-up.
- What if the interactive component genuinely has to wrap the content, like an accordion?Then it stays a wrapper but stops importing what it wraps. Give it a `children` prop, keep the directive in that one module, and have the server parent render `<Accordion><ServerSpecs /></Accordion>`. The accordion receives finished server output as a slot and only controls whether it is shown.
- Three interactive leaves on the page need to share state. Does pushing the boundary to each leaf still work?Not on its own — shared state has to live above all three, so extracting each leaf separately just forces you to lift the state anyway. Draw the boundary at the smallest component that owns the shared state, and pass the non-interactive content through it as children so that subtree stays server-rendered.
- Why does prop drilling appear at all once a subtree is client-side?Because client components cannot await server data at the point of use. The fetch has to happen above the boundary and be threaded down as props through components that do not need it. Restoring server components below the boundary lets each one fetch what it renders, and the intermediate props disappear.
saying these in an interview costs you the question
- Solves the drilling with a client context instead of moving the boundary
- Says the whole page must be client because one button has an onClick
- Passes the entire fetched object to a leaf that only needs an id
- Makes a wrapper import the content it wraps instead of taking children
- Treats prop drilling as a style problem rather than a boundary-placement symptom