React Context with Server Components to avoid prop hell

Last updated Oct 15, 2026 Published Feb 21, 2026

The content here is under the Attribution 4.0 International (CC BY 4.0) license

React Server Components (RSC), introduced in React 18 and adopted broadly by Next.js 13+ through the App Router, represent a shift in how data flows from the server to the user interface. One practical problem they create is that the React Context API, the common solution for avoiding prop drilling, cannot be used directly inside server components. This post explores the boundary between server and client, shows why context is unavailable on the server, and demonstrates a pattern that lets you fetch data once on the server and distribute it to any client component in the tree without threading props through every intermediate layer.

What is prop hell

Prop drilling, sometimes called prop hell, occurs when a piece of data must be passed through several component layers only to reach a deeply nested consumer (Wieruch, 2019). Consider a tree where Page fetches the current user and must hand it to Layout, which hands it to Sidebar, which finally hands it to UserAvatar. None of the intermediate components use the data. They are just conduits.

// Every intermediate component carries a prop it does not consume.
<Page user={user}>
  <Layout user={user}>
    <Sidebar user={user}>
      <UserAvatar user={user} />
    </Sidebar>
  </Layout>
</Page>

The React Context API was designed to solve exactly this problem (React Team, 2024). A provider near the root of the tree makes a value available to any descendant that calls useContext, regardless of depth.

How server components change the picture

React Server Components run exclusively on the server. They can read databases, call internal services, and access secrets safely because their code never reaches the browser (React Team, 2024). However, because they have no browser lifecycle, hooks such as useState, useEffect, and useContext are unavailable inside them.

Client components, marked with the "use client" directive (React Team, 2024), do run in the browser and support all hooks. The directive does not prevent the component from being rendered on the server during initial page load; it simply tells React that this component will also hydrate and run in the browser.

The result is a two-zone model:

  • Server zone: async components, direct data access, no hooks, no browser APIs.
  • Client zone: hooks, event handlers, browser APIs, interactive behavior.

Context providers belong to the client zone because createContext and useContext are hook-based APIs. Yet the data that needs to be shared often originates in the server zone.

The context bridge pattern

The solution is a thin client component that acts as a bridge. The server component fetches the data, then renders the client provider with that data as a prop. The provider stores it in context, making it available to all client components lower in the tree.

ServerPage (server component)
  └─ UserProvider (client component, "use client")
       └─ Layout (server component)
            └─ Sidebar (server component)
                 └─ UserAvatar (client component, consumes context)

Server components can be children of client components. The Next.js composition model allows this by treating server component output as pre-rendered JSX that the client component receives as the children prop (Vercel, 2024).

Step 1: define the context and its client provider

Create a file that uses "use client" at the top. This file exports the context object and a provider component.

// context/user-context.tsx
"use client"

import { createContext, useContext, ReactNode } from "react"

export type User = {
  id: string
  name: string
  email: string
}

const UserContext = createContext<User | null>(null)

export function UserProvider({
  user,
  children,
}: {
  user: User
  children: ReactNode
}) {
  return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}

export function useUser(): User {
  const ctx = useContext(UserContext)
  if (!ctx) {
    throw new Error("useUser must be called inside a UserProvider")
  }
  return ctx
}

This file is fully contained in the client zone. The UserProvider accepts user as a plain prop and places it in context. Any client component that calls useUser will receive the value without any prop threading.

Step 2: fetch data in the server component and pass it to the provider

The page component is a server component (no "use client" directive). It fetches the user and wraps its children with UserProvider.

// app/page.tsx  (server component, Next.js App Router)
import { UserProvider } from "@/context/user-context"
import { Layout } from "@/components/layout"

async function fetchUser(id: string) {
  // Direct database access or internal API call is safe here.
  const response = await fetch(`https://internal-api/users/${id}`, {
    cache: "no-store",
  })
  return response.json()
}

export default async function Page() {
  const user = await fetchUser("current")

  return (
    <UserProvider user={user}>
      <Layout />
    </UserProvider>
  )
}

The user is fetched once, on the server. No client-side fetch is needed and no secret leaks to the browser.

Step 3: consume the context in any client component

Client components that need the user call useUser. There is no prop to receive from parents.

// components/user-avatar.tsx
"use client"

import { useUser } from "@/context/user-context"

export function UserAvatar() {
  const user = useUser()

  return (
    <div className="avatar">
      <span>{user.name[0].toUpperCase()}</span>
    </div>
  )
}

Layout and Sidebar can remain server components and need not carry any user-related prop.

// components/layout.tsx  (server component)
import { Sidebar } from "@/components/sidebar"

export function Layout() {
  return (
    <div className="layout">
      <Sidebar />
      <main>Content area</main>
    </div>
  )
}
// components/sidebar.tsx  (server component)
import { UserAvatar } from "@/components/user-avatar"

export function Sidebar() {
  return (
    <nav>
      <UserAvatar />
    </nav>
  )
}

The server components Layout and Sidebar are not aware of the User type at all. The data path is:

  1. Page (server) fetches the user.
  2. UserProvider (client) places it in context.
  3. UserAvatar (client) reads it from context.

Passing multiple values without creating a new context for each

When several independent pieces of server-fetched data are needed across the tree, the same pattern scales by composing providers or by grouping related data into a single context value.

Composing providers keeps each concern isolated:

// app/dashboard/page.tsx
import { UserProvider } from "@/context/user-context"
import { ThemeProvider } from "@/context/theme-context"
import { DashboardShell } from "@/components/dashboard-shell"

export default async function DashboardPage() {
  const [user, theme] = await Promise.all([fetchUser("current"), fetchTheme()])

  return (
    <UserProvider user={user}>
      <ThemeProvider theme={theme}>
        <DashboardShell />
      </ThemeProvider>
    </UserProvider>
  )
}

Both values are fetched in parallel with Promise.all. Each provider is a small, focused client component. Client components in the tree can consume either or both contexts independently.

What the pattern does not solve

The context bridge pattern distributes server-fetched data to client components but does not replace server component data fetching for components that do not need interactivity. If Sidebar only displays static information, it is better to keep it as a server component and pass the data directly as a prop from the nearest server parent, rather than routing through context. Context shines when the consuming component is several levels deep or when many components across the tree need the same value.

Additionally, context values delivered this way are the initial server-fetched snapshot. If the user data must update in real time based on client-side events, a client-side state management solution (such as updating the context value through a client-side mutation) is needed alongside the initial server data.

Conclusion

React Server Components restrict the use of context on the server, but the restriction is straightforward to work around. By isolating the context provider in a dedicated client component and passing server-fetched data to it as a prop, you retain the benefits of server-side data access while distributing that data to client components without prop drilling. The server zone stays lean and focused on data retrieval, the client zone manages interactivity, and the boundary between them is a single, explicit prop on the provider. This approach scales naturally as the number of shared values grows, keeping component signatures clean and the data flow traceable.

Resources

References

  1. Wieruch, R. (2019). React Prop Drilling. https://www.robinwieruch.de/react-prop-drilling/
  2. React Team. (2024). createContext. https://react.dev/reference/react/createContext
  3. React Team. (2024). Server Components. https://react.dev/reference/rsc/server-components
  4. React Team. (2024). use client. https://react.dev/reference/rsc/use-client
  5. Vercel. (2024). Server and Client Components. https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns

About this post

This post was written by a human with AI assistance, which was used for research, outlining, editing and code suggestions. Claims, code samples and references were reviewed before publication, and the sources are cited so you can judge them yourself.

You also might like