React Server Components Practical Guide: Reducing Bundle Size and Fetching Data Without useEffect
Have you ever wondered this whenever you wrote code to load data using useEffect? "Loading state management, error handling, dependency arrays... Is all of this really necessary just to display a single piece of data?" React Server Components (RSC) starts right from this question. It is the result of a fundamental re-examination of why the client requests data again when it would suffice to simply read and render it directly from the server.
RSC became an official standard with React 19 (stable version in December 2024), and Next.js 15 adopted it as the default paradigm for App Router architecture. According to the State of React 2024 survey, only 29% of developers have actually used RSC. This means that while positive perception of the technology exceeds half, the barriers to entry—namely, the learning curve and framework dependencies—remain high. This article is written for developers with basic React experience who are considering switching to Next.js App Router.
After reading this article, you will understand how RSC differs from traditional SSR, how it actually achieves bundle size reduction and data fetching simplification, and how to avoid common pitfalls encountered in practice. The core of RSC is the simple concept of a "component running only on the server," but once you properly understand it, you can simultaneously achieve bundle size reduction, data fetching simplification, and enhanced security.
index
- Key Concepts
- What is the difference between RSC and traditional SSR?
- Component Boundary Rule
- Suspense and Streaming Rendering
- Practical Application
- Example 1: Running a large library only on the server
- [예시 2: 하이브리드 페이지 구성](#%EC%98%88%EC%8B%9C-2-%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C-%ED%8E%98%EC%9D%B4%EC%A7%80--%EC%88%EC%9 2%84%EC%99%80-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%A1%B0%ED%95%A9)
- Example 3: Configuring a Caching Strategy in Combination with TanStack Query
- Pros and Cons Analysis
- Common Mistakes
- In Conclusion
Key Concepts
What is the difference between RSC and traditional SSR?
Traditional Server-Side Rendering (SSR) generates HTML on the server, sends it to the client, and then undergoes a hydration process where the same JavaScript is executed again on the client to attach event handlers to the DOM. In other words, the same component code is executed on both the server and the client.
Hydration: This is the process where React connects event listeners and state to the static HTML generated by SSR to make it interactive. The initial HTML is displayed quickly, but actual interaction is not possible until the JavaScript bundle is loaded and executed.
RSC is different. Server components run only on the server, and the component's JavaScript code itself is not included in the client bundle. Instead, it internally delivers component tree information to the client in a serialized format called the RSC Payload (Flight Protocol), and the client manipulates the DOM based on this.
RSC Payload (Flight Protocol): A React-specific serialization format used to deliver results rendered by server components to the client. Since it transmits React's internal tree representation rather than an HTML string, it enables merging with client components.
| Classification | Traditional SSR | RSC |
|---|---|---|
| Server execution | HTML generation, subsequent hydration required | The component itself runs only on the server |
| Client Bundle | Includes all component code | Excludes server component code |
| Data Fetching | useEffect + Client API Calls |
Direct Use of async/await Within the Component |
| Interaction | Possible after hydration | Client component responsible |
Component Boundary Rules
Based on Next.js App Router, all components within the app/ directory are server components by default. If client components are required, specify the "use client" directive at the top of the file.
// 서버 컴포넌트 (별도 지시어 없음 — 기본값)
async function ProductList() {
const products = await db.products.findMany(); // DB 직접 접근 가능
return (
<ul>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
);
}// 클라이언트 컴포넌트
"use client";
import { useState } from "react";
function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
const handleAdd = async () => {
setLoading(true);
await addToCart(productId);
setLoading(false);
};
return (
<button onClick={handleAdd} disabled={loading}>
{loading ? "추가 중..." : "장바구니 추가"}
</button>
);
}The most important rules regarding component boundaries are as follows.
- Server components can
importand render client components. - If you directly
importa server component in a client component, that server component is automatically treated as a client component. A build error occurs only when using server-specific APIs (fs,prisma, etc.). By utilizing the children props pattern, a client component can wrap a server component while maintaining its functionality as a server component.
// ✅ children props 패턴 — 클라이언트 컴포넌트가 서버 컴포넌트를 감쌀 수 있음
"use client";
import { useState } from "react";
function Collapsible({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "접기" : "펼치기"}
</button>
{isOpen && children}
</div>
);
}
// app/page.tsx — 서버 컴포넌트에서 조합
async function Page() {
return (
<Collapsible>
<ServerDataList /> {/* 서버 컴포넌트로 유지됨 */}
</Collapsible>
);
}"use client" Propagation: If "use client" is declared in a specific file, all modules that become import in that file are also included in the client bundle. Therefore, placing client boundaries as close to the end of the component tree as possible is advantageous for bundle size optimization.
Suspense and Streaming Rendering
One of the most powerful UX benefits of RSC is Suspense-based streaming rendering. By wrapping server components in <Suspense>, you can immediately display a fallback UI while data is loading and stream only that part when the data is ready. This allows you to progressively display content without blocking the entire page.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { UserStats } from "./UserStats";
import { RecentOrders } from "./RecentOrders";
export default function DashboardPage() {
return (
<div>
<h1>대시보드</h1>
{/* 빠른 데이터: 먼저 표시 */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats /> {/* 서버 컴포넌트 */}
</Suspense>
{/* 느린 데이터: 독립적으로 로딩 */}
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders /> {/* 서버 컴포넌트 */}
</Suspense>
</div>
);
}In Next.js App Router, the <Suspense> boundary is automatically applied simply by creating the loading.tsx file in the app/ directory.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />; // 데이터 로딩 중 표시할 UI
}LCP (Largest Contentful Paint): A Core Web Vitals metric that measures the time it takes for the largest content element on a page to be rendered on the screen. The recommended standard is 2.5 seconds or less, and using RSC and Suspense Streaming together directly contributes to improving this metric by delivering initial content quickly.
Practical Application
Example 1: Running a Large Library Only on the Server
Including packages amounting to hundreds of KB, such as Markdown parsers or syntax highlighting libraries, in the client bundle slows down initial loading. The question, "Do we really need to run this heavy library in the browser?" is the starting point for server components. By import only in the server component, the corresponding library is completely excluded from the client bundle.
// app/blog/[slug]/page.tsx — 서버 컴포넌트
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeSanitize from "rehype-sanitize"; // XSS 방지 필수
import rehypeStringify from "rehype-stringify";
import { notFound } from "next/navigation";
async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // Next.js 15: params는 Promise 타입
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) notFound();
// unified, rehype-* 모두 서버에서만 실행 — 클라이언트 번들에 포함되지 않음
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeSanitize) // 외부 입력(DB, CMS)의 XSS 위험 제거
.use(rehypeStringify)
.process(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: String(result) }} />
</article>
);
}| Points | Description |
|---|---|
unified, rehype-* import |
Since it is a server component, completely exclude it from the client bundle |
rehype-sanitize |
Eliminate XSS risks from external inputs such as databases or CMS — Security vulnerabilities arise if omitted |
prisma Direct Call |
Direct DB access possible without API layer |
await params |
In Next.js 15, params was changed to Promise type |
Example 2: Hybrid Page — A Combination of Server and Client Components
In practice, the most common pattern involves a mix of data display (server) and interaction (client), such as on user profile pages. Designing by separating the "data display area" from the "user click area" allows for a natural boundary between the server and client.
// app/profile/[userId]/page.tsx — 서버 컴포넌트가 레이아웃 담당
async function ProfilePage({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params; // Next.js 15 기준
try {
// 서버에서 병렬로 데이터 페칭
const [user, posts] = await Promise.all([
getUser(userId),
getPosts(userId),
]);
return (
<div className="profile">
<UserInfo user={user} /> {/* 서버 컴포넌트: 정적 정보 표시 */}
<FollowButton userId={user.id} /> {/* 클라이언트 컴포넌트: 팔로우 인터랙션 */}
<PostList posts={posts} /> {/* 서버 컴포넌트: 게시물 목록 */}
</div>
);
} catch (error) {
throw error; // Next.js error boundary(error.tsx)로 위임
}
}// components/FollowButton.tsx — 클라이언트 컴포넌트
"use client";
import { useState, useTransition } from "react";
import { followUser } from "@/actions/user"; // Server Action
function FollowButton({ userId }: { userId: string }) {
const [isFollowing, setIsFollowing] = useState(false);
const [isPending, startTransition] = useTransition();
const handleFollow = () => {
startTransition(async () => {
await followUser(userId);
setIsFollowing(true);
});
};
return (
<button onClick={handleFollow} disabled={isPending}>
{isPending ? "처리 중..." : isFollowing ? "팔로잉" : "팔로우"}
</button>
);
}Server Actions ("use server"): Server-side functions that can be called from the client. Like followUser, they are used for mutation processing and allow you to directly call server logic without a separate API endpoint.
Example 3: Configuring a Caching Strategy in Combination with TanStack Query
How do we handle cases where the server component handles initial data loading but subsequent real-time or optimistic updates are required? By using TanStack Query, you can achieve both fast initial rendering on the server and flexible caching on the client. Data prefetched from the server is received by the client QueryClient, which immediately displays the screen without additional network requests.
// lib/dashboard.server.ts — 서버 전용 데이터 접근 함수 (DB 직접 쿼리)
export async function getDashboardStats() {
return prisma.dashboardStats.findFirst({ orderBy: { createdAt: "desc" } });
}
// lib/dashboard.client.ts — 클라이언트에서 사용하는 API 함수
export async function fetchDashboardStats() {
const res = await fetch("/api/dashboard/stats");
if (!res.ok) throw new Error("대시보드 데이터 로딩 실패");
return res.json();
}// app/dashboard/page.tsx — 서버 컴포넌트
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { getDashboardStats } from "@/lib/dashboard.server";
import { DashboardClient } from "./DashboardClient";
async function DashboardPage() {
const queryClient = new QueryClient();
// 서버에서 미리 데이터 페칭 (초기 HTML에 포함)
await queryClient.prefetchQuery({
queryKey: ["dashboard-stats"],
queryFn: getDashboardStats, // 서버 전용 DB 접근 함수
});
return (
// 서버 데이터를 클라이언트 QueryClient로 전달
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient />
</HydrationBoundary>
);
}// app/dashboard/DashboardClient.tsx — 클라이언트 컴포넌트
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchDashboardStats } from "@/lib/dashboard.client";
function DashboardClient() {
// 서버에서 prefetch된 데이터를 즉시 사용 (초기 네트워크 요청 없음)
// staleTime이 지나면 fetchDashboardStats()로 자동 재검증
const { data, error } = useQuery({
queryKey: ["dashboard-stats"],
queryFn: fetchDashboardStats, // 클라이언트 API 함수
staleTime: 30 * 1000, // 30초
});
if (error) return <ErrorDisplay message={error.message} />;
return <StatsDisplay stats={data} />;
}Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Reduced Bundle Size | Server components and dependency libraries are excluded from the client bundle. When measured with @next/bundle-analyzer, a reduction of 18–29% is reported depending on project characteristics. |
| Simplifying Data Fetching | You can request data directly from async/await within the component without useEffect + useState. |
| Initial Loading Performance | Max Content Pool Paint (LCP) is improved, and the burden of JavaScript parsing and execution on the client is reduced. |
| Streaming Rendering | Combined with <Suspense>, it can display data progressively starting from where it is ready. |
| SEO Improvement | Completed HTML is included in the initial response, allowing search engine crawlers to index content without executing JavaScript. |
| Security | API keys, DB connection strings, etc., are processed only within the server component and are not exposed to the client. |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Framework Dependencies | It cannot be used with pure React alone; a separate framework such as Next.js is required. | Consider adopting Next.js App Router or TanStack Start. |
| Learning Curve | You will need a new mental model covering server/client boundaries, "use client" propagation rules, the children props pattern, etc. |
It is recommended to learn sequentially, starting from the "Rendering" section of the official Next.js documentation. |
| Security Vulnerability | A security vulnerability related to Flight Protocol deserialization was reported in 2025. | Keep the framework up to date and thoroughly validate external inputs. |
| Limited to Interactive Apps | Apps with high interaction, such as editors and real-time dashboards, generally require client-side components, so their effectiveness is limited. | Design by clearly separating the data display layer and the interaction layer. |
| Understanding Caching Strategies is Necessary | Unpredictable behavior may occur if you do not understand Next.js's multi-layer caching (Request Memoization, Data Cache, Full Route Cache). | Develop a habit of explicitly specifying cache(), revalidate, no-store options. |
Common Mistakes
- When placing the
"use client"boundary too high: If you declare"use client"on a parent component, the entire child component is included in the client bundle. For example, instead of making the entire page a client component because of a single button, if you separate only the button component and declare"use client", the rest of the tree remains a server component. - Attempting to use
useState,useEffectin a server component: Server components cannot use browser APIs and React hooks. An error will occur during the build, so if state or side effects are required, it is necessary to separate that logic into a client component. - When unexpected stale data is returned due to not specifying caching: Next.js caches fetch requests by default. It is recommended to specify
fetch(url, { cache: "no-store" })if you always need the latest data, or to specify it explicitly like{ next: { revalidate: 60 } }if updates are required at specific intervals. - Cases where you are unaware that
importa server component in a client component results in a loss of functionality: If you directlyimporta server component in a client component, no build error occurs, but that server component is treated as a client component. Since build errors only occur when using server-specific APIs, it is easy to miss the issue. To retain the benefits of server components, it is recommended to utilize the children props pattern introduced earlier.
In Conclusion
When adopting RSC, the real decision is not "whether or not to use server components," but "where to draw the boundary between the server and the client." The part that reads and displays data belongs to the server, and the part that interacts with the user belongs to the client—the clearer this separation is designed, the more naturally bundle size reduction and data fetching will follow.
3 Steps to Start Right Now:
- Try App Router with a New Project: Running the
pnpm create next-app@latestcommand launches an interactive CLI and asks whether you want to use App Router. If you select App Router,app/page.tsxis created as the default server component. If you createasyncas a function and useawait fetch()yourself, you can immediately feel the difference compared to theuseEffectmethod. - Migrate an existing
useEffectdata fetching component to a server component: It is recommended to select a component in your current project that fetches data touseEffectand convert it to a server component. It is also a good idea to compare the bundle size before and after the transition to@next/bundle-analyzer. - Thoroughly read the Data Fetching section of the official documentation: If you read the caching strategy section of the Next.js official documentation, you can systematically understand the relationships between
cache(),revalidate, andSuspensestreaming. Understanding caching strategies is the key to using RSC predictably.
Next Post: Mastering Server Actions — If RSC changed "where to fetch data," Server Actions change "how to send data." From form submissions to optimistic updates, the next post covers a new pattern for directly calling server logic without separate API endpoints.
Reference Materials
- React Official Documentation - Server Components
- Next.js Official Documentation - Server Components
- Making Sense of React Server Components - Josh W. Comeau
- React Trends in 2025 - Robin Wieruch
- React Server Components in Production: Benefits, Pitfalls and Best Practices for 2026 - Growin
- The Complete Guide to React Server Components: Mental Models for 2025 - DEV Community
- From RSC (React Server Components) Operation Principles to Performance Optimization
- React Server Components + TanStack Query: The 2026 Data-Fetching Power Duo - DEV Community
- TanStack Blog - React Server Components Your Way
- React Server Components: the Good, the Bad, and the Ugly - mayank.co