Component Architecture for React Server Components
22 May 2026
For most of React's history, the conventional way to load data on a page has been to fetch at the top of a route and pass it down through props. Most React developers still reach for that model first, even when working in the Next.js App Router.
In this guide, we will examine why that habit ends up producing tightly coupled components and clumsy loading states, and explore how React Server Components (RSCs) let us architect a page differently. We will walk through the progression from useEffect to React Query to route loaders to RSCs, and then put together a page that describes the loading experience rather than managing all the data.
Background
Data fetching on the server is fundamentally faster than fetching on the client.
When we fetch on the client, the browser must wait for the JavaScript bundle to download, parse, and execute before the first network request can even fire. As the UI renders and more components mount, each one can trigger its own fetch, leading to sequential network waterfalls.
The server, by contrast, sits physically adjacent to the database and can fetch data in parallel with rendering, sending the result inline with the HTML stream. The end user receives rendered content without paying for extra client-to-server roundtrips.
This is why route-level loaders in frameworks like Remix, the Next.js Pages Router (getServerSideProps), and React Router v7 became so popular. They put data fetching at the route boundary on the server.
The question is what we lose in the process, and whether RSCs let us keep the server-side performance wins without the architectural trade-offs.
The Use Case
Let's imagine we are building a social feed page. The UI has a layout shell, a sidebar, a feed of posts, a list of suggested users to follow (WhoToFollow), and a list of trending tags.
In plain JSX, the page looks like this:
function HomePage() {
return (
<Layout>
<Sidebar />
<main>
<PageHeader title="Home" />
<Feed />
</main>
<aside>
<TrendingTags />
<WhoToFollow />
</aside>
</Layout>
);
}This is just the structural layout. No data fetching, no loading states. Every component here will eventually need data. Let's explore how different data fetching approaches change the architecture of this page.
1. Local Data Fetching with useEffect
The original way to handle data in React was with useEffect and useState. Each component fetches its own data, owns its own loading flag, and manages its own state:
function Feed() {
const [posts, setPosts] = useState<PostT[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchFeed().then((p) => {
setPosts(p);
setIsLoading(false);
});
}, []);
if (isLoading) return <FeedSkeleton />;
return (
<ul>
{posts.map((post) => (
<Post key={post.id} post={post} />
))}
</ul>
);
}This works for a single component. However, the moment another part of the tree needs the same data, we are forced to hoist posts and setPosts up to a common ancestor and pass them down via props.
Mutations follow the same pattern: if a Post wants to like itself and have the count update elsewhere, the like handler has to live somewhere both components can reach:
function HomePage() {
const [posts, setPosts] = useState<PostT[]>([]);
function handleLike(postId: string) {
likePost(postId).then(() => {
fetchFeed().then(setPosts);
});
}
return <Feed posts={posts} onLike={handleLike} />;
}
function Feed({ posts, onLike }: Props) {
return (
<ul>
{posts.map((post) => (
<Post key={post.id} post={post}>
<LikeButton onClick={() => onLike(post.id)} />
</Post>
))}
</ul>
);
}The handleLike handler lives in HomePage solely because it needs to mutate posts. Feed receives both the data and the callback. LikeButton has no idea where the handler comes from. Everything is forced through props.
The React Query Improvement
Client data libraries like TanStack Query cleaned this up significantly. Data is keyed and cached centrally, so any component can request what it needs without prop drilling:
function Feed() {
const { data, isLoading } = useQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
});
if (isLoading) return <FeedSkeleton />;
return (
<ul>
{data.map((post) => (
<Post key={post.id} post={post} />
))}
</ul>
);
}
function LikeButton({ postId }: { postId: string }) {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: () => likePost(postId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["feed"] }),
});
return <button onClick={() => mutation.mutate()}>Like</button>;
}State no longer needs to live higher than the component that owns it. LikeButton sits deep in the tree, fires its mutation, and the Feed query refetches automatically.
However, the downside is popcorn UI: every component decides when it is ready independently, causing elements to pop onto the screen one by one in whatever order network calls resolve. More critically, all fetching happens on the client, requiring users to download, parse, and execute JavaScript before data requests can even start.
2. Route-Level Loaders
To solve the client-fetching problem, frameworks introduced route-level loaders. Instead of each component fetching on its own, a single function on the server fetches everything the page needs up front:
// Route Loader
export async function loader() {
const user = await getCurrentUser();
const [feed, whoToFollow, trendingTags] = await Promise.all([
getFeed(user.handle),
getWhoToFollow(user.handle),
getTrendingTags(),
]);
return { user, feed, whoToFollow, trendingTags };
}
export default function HomePage() {
const { user, feed, whoToFollow, trendingTags } =
useLoaderData<typeof loader>();
return (
<Layout>
<Sidebar user={user} />
<Feed posts={feed.posts} currentUser={user} />
<aside>
<TrendingTags tags={trendingTags} />
<WhoToFollow users={whoToFollow} currentUser={user} />
</aside>
</Layout>
);
}This pattern is easy to recreate in the Next.js App Router by making the page component async and awaiting everything at the top:
// Next.js App Router loader mindset
export default async function HomePage() {
const user = await getCurrentUser();
const [feed, whoToFollow, trendingTags] = await Promise.all([
getFeed(user.handle),
getWhoToFollow(user.handle),
getTrendingTags(),
]);
return (
<Layout>
<Sidebar user={user} />
<Feed posts={feed.posts} currentUser={user} />
<aside>
<TrendingTags tags={trendingTags} />
<WhoToFollow users={whoToFollow} currentUser={user} />
</aside>
</Layout>
);
}The Trade-off: Tightly Coupled Views
While this feels organized, components are now welded to whatever the page chose to fetch for them:
function WhoToFollow({ users, currentUser }: Props) {
return (
<ul>
{users.map((user) => (
<UserRow key={user.handle} user={user} currentUser={currentUser} />
))}
</ul>
);
}On the home page, this works fine. But if you want to reuse <WhoToFollow /> on a profile page, that route loader must also fetch the exact same data, in the exact same shape, and thread the same props down:
// Profile Page Loader
const [user, profile, whoToFollow] = await Promise.all([
getCurrentUser(),
getProfile(handle),
getWhoToFollow(handle), // Duplicated query logic
]);
<WhoToFollow users={whoToFollow} currentUser={user} />;The component cannot be moved without updating the data requirements of every route that hosts it.
3. Async Server Components
What if each component could fetch its own data on the server without needing a parent loader to hand it down?
That is the core architecture of React Server Components. They run strictly on the server, can be async, read from the database directly, and never execute in the browser.
This preserves the composability of the useEffect approach while keeping data fetching on the server like loaders.
Instead of the page fetching everything and passing it down, each component fetches what it needs based on minimal props:
// components/WhoToFollow.tsx
export async function WhoToFollow() {
const handle = await getCurrentUserHandle();
const users = await getWhoToFollow(handle);
return (
<ul>
{users.map((user) => (
<UserRow key={user.handle} handle={user.handle} />
))}
</ul>
);
}Now <WhoToFollow /> can be dropped onto any page without wiring up data from above.
The same applies to Feed:
// components/Feed.tsx
export async function Feed() {
const handle = await getCurrentUserHandle();
const { posts } = await getFeed(handle);
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Post post={post} />
</li>
))}
</ul>
);
}With every component fetching its own data, the page component goes back to being a clean compositor:
// app/page.tsx
export default function HomePage() {
return (
<Layout>
<Sidebar />
<main>
<PageHeader title="Home" />
<Feed />
</main>
<aside>
<TrendingTags />
<WhoToFollow />
</aside>
</Layout>
);
}Request Deduplication with React cache()
You might worry about duplicate fetches: if multiple components call getCurrentUserHandle(), does the database get hit repeatedly?
React's cache() function deduplicates identical calls per request. Calling it ten times across different components during a single server render hits the database only once. This acts like React Query's cache, but runs natively within the server render lifecycle.
4. Avoiding Blocking Renders with Suspense
Server components render as an HTTP stream. This means React can begin sending HTML to the browser before every async component has finished fetching.
<Suspense> is what enables this streaming architecture. Wrapping an async component in a Suspense boundary tells React to send a fallback immediately while the server component resolves in the background:
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>Without Suspense, the page blocks until every async component in the tree finishes. Adding boundaries allows the fast parts of your page to render instantly while slow data streams in.
5. Skeletons That Stay in Sync
To prevent layout drift between loading states and rendered content, export the skeleton directly from the component file it represents:
// features/post/components/feed.tsx
export async function Feed() {
const handle = await getCurrentUserHandle();
const { posts } = await getFeed(handle);
return (
<ul className="flex flex-col gap-4">
{posts.map((post) => (
<li key={post.id}>
<Post post={post} />
</li>
))}
</ul>
);
}
export function FeedSkeleton({ count = 5 }: { count?: number }) {
return (
<ul className="flex flex-col gap-4">
{Array.from({ length: count }).map((_, i) => (
<li key={i}>
<PostSkeleton />
</li>
))}
</ul>
);
}And in the Post component:
// features/post/components/post.tsx
export function Post({ post }: { post: PostT }) {
return (
<article className="h-24 rounded-lg border p-4">
<p>{post.body}</p>
</article>
);
}
export function PostSkeleton() {
return (
<article className="h-24 rounded-lg border p-4">
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
</article>
);
}FeedSkeleton is composed from PostSkeleton in the exact same structure that Feed is composed from Post. If you add a line of metadata or change padding on Post, the skeleton lives in the same file and stays synchronized, eliminating layout shift.
6. Designing the Loading Experience
With Suspense and skeletons in place, the page component designs the loading sequence.
Rather than letting components stream in randomly, we group boundaries deliberately.
Grouping Boundaries for Visual Balance
If we split every component into its own boundary, the page can feel disjointed with three separate areas popping in at different times.
Instead, we can group related sections (such as the sidebar recommendations) behind a single boundary:
// app/page.tsx
export default function HomePage() {
return (
<div className="layout">
<main>
<PageHeader title="Home" />
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
</main>
<aside>
<Suspense fallback={<TrendingTagsSkeleton />}>
<TrendingTags />
<WhoToFollow />
</Suspense>
</aside>
</div>
);
}The page now loads in two coordinated groups. By grouping <TrendingTags /> and <WhoToFollow /> behind a single fallback, the entire sidebar renders together without awkward vertical jumps.
7. Building a Parameterized Page
On dynamic routes like post/[id]/page.tsx, Next.js 15+ provides params as a Promise.
If we make the page component async and await params, the page cannot render its shell until params resolve. Instead, we can resolve params inline using .then():
// app/post/[id]/page.tsx
export default function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div>
<PageHeader title="Post" />
<Suspense fallback={<PostDetailSkeleton />}>
{params.then(({ id }) => (
<>
<PostDetail id={id} />
<section>
<SectionHeader>Replies</SectionHeader>
<Suspense fallback={<RepliesSkeleton />}>
<Replies postId={id} />
</Suspense>
</section>
</>
))}
</Suspense>
</div>
);
}The .then() unwraps params directly inside the Suspense boundary. This allows the PageHeader and outer shell to render synchronously while PostDetail and Replies stream in parallel.
8. Adding Client Interactivity at the Leaves
When parts of the page need client-side interactivity (such as a like button), push "use client" down to the smallest possible leaf component:
// features/post/components/like-button.tsx
"use client";
import { useOptimistic } from "react";
export function LikeButton({
postId,
liked,
count,
}: {
postId: string;
liked: boolean;
count: number;
}) {
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(state) => ({
liked: !state.liked,
count: state.count + (state.liked ? -1 : 1),
}),
);
async function handleLike() {
setOptimistic(null);
await likePost(postId);
}
return (
<button onClick={handleLike}>
{optimistic.liked ? "Liked" : "Like"} ({optimistic.count})
</button>
);
}Then compose it directly inside the server-rendered Post:
// features/post/components/post.tsx
export async function Post({ post }: { post: PostT }) {
const userState = await getPostUserState(post.id);
return (
<article>
<PostAuthor handle={post.authorHandle} />
<PostBody body={post.body} />
<LikeButton postId={post.id} liked={userState.liked} count={post.likes} />
</article>
);
}The parent Post remains an async Server Component, while LikeButton executes as a lightweight interactive leaf node.
9. Next.js 16 Cache Components and Partial Prerendering
With Next.js 16 Cache Components, this architecture maps directly to Partial Prerendering (PPR):
- Everything outside
<Suspense>boundaries becomes part of the static HTML shell served instantly from the edge CDN. - Dynamic data fetches placed behind
<Suspense>stream in over the open HTTP connection. - Components using
"use cache"can cache their rendered output, allowing even dynamic regions to resolve without loading delays.
Because our components fetch their own data and pages use synchronous boundaries, the codebase is already structured for PPR out of the box.
Conclusion: Architectural Principles
The shift from useEffect to route loaders to React Server Components is about keeping data fetching on the server while restoring component modularity.
The core rules to follow:
- Pages are synchronous compositors: Pages do not fetch data; they define layout structure and orchestrate Suspense boundaries.
- Components fetch their own data: Co-locate database and API reads with the JSX that renders them.
- Skeletons live with components: Export loading fallbacks from the same file as the component to prevent layout shift.
- Suspense boundaries live at the page level: The page decides the loading sequence and visual groupings.
- Client boundaries are leaf nodes: Push
"use client"as far down the component tree as possible to minimize client bundle sizes.