DevInsight

A developer's field notes

Frontend
3 viewsAbout 6 min read

One 'use client' Line Splits Server and Client, and Shakes Everything From Bundle Size to State Management

In the Next.js App Router, a single 'use client' line that draws the server-client boundary reshapes your entire bundle size, data fetching, and state management strategy. Draw that boundary wrong and bundle bloat plus duplicate fetching blow up all at once, late in the game. This memo lays out the judgment principles for redrawing the boundary based on per-layer responsibilities and data ownership, plus the priority order for refactoring boundaries that were already drawn wrong.

Published by DevInsight.

#React#Next.js#Server Components#Client Components#App Router#use client#번들 최적화#데이터 페칭#리팩터링#아키텍처 설계

'use client' became the most copy-pasted line of code in the Next.js App Router. This directive is not a property of a component; it is a fork point in the import graph. The file that carries the directive, along with every module it pulls in, gets folded into the client bundle, while everything on the other side executes only on the server and stays out of the JavaScript that ships to the browser. This is exactly why where that single line sits changes your bundle size, data fetching, and state management strategy all at once.

The boundary usually starts at the top of a page file. It happens because of one scroll interaction, one modal. The directive doesn't attach to just a single file; it applies to the entire graph that file imports transitively. Say a component that got 'use client' for a single event imports a date-formatting utility somewhere. That utility never caused any trouble on the server. But the moment a client component imports it, the entire codebase moves into the browser. If you trace the "source that loaded into the bundle" for an expensive library, the culprit is usually not the logic itself but the upstream boundary that dragged that logic into the client graph.

Why heavy utilities stayed quiet on the server

The server component's strength is simple: that module's code is entirely absent from the client bundle. Only the rendered output goes down to the browser; the source stays on the server. In this arrangement, a page that accumulates over several months grows its bundle size only gently. But the day a 'use client' is drawn, every benefit below that point evaporates at once. First-load JS jumping by hundreds of kilobytes is common at exactly this point.

Verification is easy enough. Use the First Load JS that next build outputs as your baseline and compare commits right before and after a 'use client' was added. The bigger the team, the more this number looks like something you can skip, but in practice it's the most honest metric you have.

What's dynamic is the button, not the page

Like buttons, infinite scroll, and modals: the parts that change the screen on click are unavoidably client-side. The recurring mistake here is hoisting the page file itself to 'use client' with the reasoning "this page is dynamic." What's dynamic is the button inside it, not the page. Keep the page as a server component, peel out only the interactive fragments as client components, and pass props to them. Values passed down as props must be serializable. Try to pass a function and you'll hit an error immediately. This is where the "data ownership" view takes shape: the server holds the data, and the client handles only the screen and its interactions.

Global context (theme, session, query client) structurally requires 'use client'. The warning that "wrapping a Provider around the root layout turns the whole app client-side" is only half right. Since the children a Provider receives are passed through as already-server-rendered results, the child tree stays on the server. The problem lies in how things are assembled. If you import and lay out the page directly inside the Provider file, those pages get folded into the client graph even without their own directive. The two setups look structurally identical, but their outcomes are opposite. Miss this difference, and the misunderstanding that "a Provider makes everything client-side" and the correct answer that "you pull it out into a slot" end up coexisting, jumbled together, in the same repository.

Fetching belongs on the server, rendering belongs on the client

Server components can await directly in the body. Fetch several datasets, wait for them in parallel, and hand the results down as props, and rendering finishes in a single pass. Doing the same thing on the client means rebuilding loading, error, and data states inside a useEffect, and you can only kick off the fetch after hydration finishes. Data filling in late on top of an already server-rendered screen is the normal shape of a client fetch, but for data that could have been fetched from the server from the start, it's pure waste. On top of that, the caching and request deduplication that server fetch provides operate at the server level. The moment you move to the client you lose that benefit and the same data gets requested repeatedly, once per browser. And a structure where the parent must receive data before the child can fetch in turn becomes a chain of useEffects on the client, which also multiplies round trips. On the server, that's a single Promise.all.

State management libraries also get redefined at this boundary. Form inputs, the selected tab, an open dialog: those are client state. The problem is making a global store the default choice for the whole service just to handle those fragments. If the server is the source of truth anyway, most state ends within the local scope a few components share. A global store only earns its place at the point where "multiple client components in places far apart from one another read and write the same value." Rather than worrying about where to put context and stores, it's better to first sort out which values genuinely need to exist only in the browser.

A boundary that's already drawn: where to start untangling it

If misdrawn boundaries have piled up, the removal order is set too. First, find the files where you can delete 'use client' outright. Targets are components that only receive props and render them, with no event handlers and no hooks. Delete the one line, and if the build still passes, that alone drops the whole subtree out of the client bundle. Next, hunt for useEffects that fill in data, lift just that part up into a server component, and reduce the client side to a view that renders data it was handed. What should remain at the end are only the leaf fragments where interaction actually happens, as client components. Run this order in reverse and you'll fail. Start with a large file and the blast radius grows until regressions are hard to find, and you'll likely end up reverting anyway.

Once the fixes are done, use @next/bundle-analyzer to trace which library made it onto the client bundle, and check the network tab to see whether the same endpoint is called twice. If the client re-fetches data the server already received, duplicate fetching shows up immediately. Only after those two checks pass can you say the boundary has found its place.

The point of this article is not to treat 'use client' as taboo. Things like reading cookies, accessing secret keys, and work that needs caching genuinely cannot happen outside the server. There is exactly one criterion: does this file's code need to run in the browser? As long as the files whose answer is "no" stay in the server realm, the bundle graph keeps itself thin on its own. Start this week by counting every 'use client' in the repo, because that number is your next refactoring list.

Comments

Loading comments.

Good Follow-up Reads

Posts connected to the topic you just read.

View all Frontend
Frontend

The Page Is Already Shifting Before the Font Even Shows

Korean web fonts carry large per-glyph payloads that delay loading, and the later a font arrives, the worse the CLS metric swings. This article walks through subsetting glyphs, nailing down preload timing, and choosing a font-display strategy with real numbers. It separates what Next.js's next/font handles from what it doesn't, and shows how to design a font pipeline that keeps text rendering stable without blowing the performance budget.

#웹 폰트#CLS#Core Web Vitals#Next.js
Frontend

next/image sizes 한 줄이 LCP를 0.5초 당긴다

LCP 개선을 위해 무작정 이미지를 압축하고 CDN을 도입하기 전에, next/image의 sizes 속성과 priority 플래그가 실제로 어떤 영향을 미치는지 정량적으로 이해해야 한다. 이 글은 next/image 설정값이 LCP에 미치는 영향을 실제 코드 레벨에서 분석하고, 이미지 CDN이 진짜 필요한 상황과 불필요하게 최적화를 도입했다가 역효과를 보는 사례까지 함께 다룬다.

#LCP#이미지최적화#next-image#CoreWebVitals

Previous post

A Week Facing 2,731 Type Errors: The Reality Between Flipping strict On and Off

Next post

When Search Keeps Pulling the Wrong Documents, What to Suspect Before Embeddings

DevInsight Digest

Keep every new article in one calm feed.

Follow the full publication feed without promotional alerts.

Subscribe to RSS