Back

Isomorphic Contexts in Next.js 14

16 min read
next php

isomorphic. adjective. iso·​mor·​phic ˌī-sə-ˈmȯr-fik. : being of identical or similar form or shape or structure.”

If you’ve been part of the web ecosystem for a while, especially in Node.js (backend), you’ve likely encountered isomorphic-fetch. This library was crucial because fetch wasn’t natively available in Node.js for a long time.

“Isomorphic” in web development means code that runs unchanged on both client-side (browser) and server-side (e.g., Node.js). This approach lets developers write code once for use in multiple environments, streamlining development and maintaining consistency across the stack.

What is an Isomorphic Context?

React’s Context API, introduced in version 16.3, made sharing data between parent and child components easier. You don’t have to pass props manually through every level anymore. This is really helpful when you have deeply nested components and need to send data from top to bottom.

Next.js 13 brought in server components, which run on the server to fetch data and do server-side rendering. They make pages load faster and perform better. But since these components only run on the server, they can’t use browser or React-specific tools like useRouter or useContext. This makes it hard to share data between server components or pass it down the component tree.

Developers ran into two main problems with server components:

  1. They can’t use context in server components because it’s not available there.
  2. They can’t get information about the current page path, search parameters, or route parameters in server components. The only way to do this right now is to pass this info as props from the page to the server components.

To tackle these challenges, developers often resorted to workaround solutions. One common approach was to designate leaf components as client components and directly integrate React/Next.js hooks like useRouter or useContext within them. While effective in most cases, this method requires you to have clear boundaries between server and client components, which can be cumbersome to maintain and manage.

Another approach developers discovered was using Next.js middleware. This method involves setting a header for each incoming request in the middleware, which can then be accessed in server components using the cookies or headers function. While this solution works, it comes with a drawback: if used at the layout or page level, it disables Next.js caching and static site generation (SSG) capabilities.

// middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request: Request) {

  // Store current request url in a custom header, which you can read later
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-url', request.url);

  return NextResponse.next({
    request: {
      // Apply new request headers
      headers: requestHeaders,
    }
  });

// /app/layout.tsx
import { headers } from 'next/headers';

export default function RootLayout() {
  const headersList = headers();
  // read the custom x-url header
  const header_url = headersList.get('x-url') || "";
}
dynamic functions

An isomorphic context would solve these issues. It would combine:

  • A way to share context among server components, keeping the parent-child relationship.
  • The regular client-side React context.

This isomorphic context would work on both the server and client sides. It would let developers share data easily between components, no matter where they run. This would make building apps with both server and client components much simpler and more efficient.

old times

Server Contexts in Next.js 14

Luckily for us, the solution also lies in the APIs provided to use by recent releases of Node.js and React 19 (We can access them in Next.js 14 as of now). The two of the solutions are:

  1. AsyncLocalStorage in Node
  2. Cache function from React 19

AsyncLocalStorage in Node

What is AsyncLocalStorage? It is a new feature in Node.js 16.8.0 that allows you to store data in a context that is accessible across async calls. This is especially useful when you need to share data between different parts of your application, such as middleware and route handlers. Next.js uses this internally for the headers() and cookies() functions. The fact that it only stays in the same async context (a single request response cycle) is what makes it perfect for server components as server components are never rerendered again once sent to the client.

AsyncLocalStorage in action

We are in a Next.js environment with the given file structure:

└── package.json
└── app
│   └── page.tsx
│   └── _components
│       └── ServerComponent.tsx
└── server-context
    └── context.tsx

In our context.tsx file within the server-context folder, we define the logic for context shared among server components. It’s important to understand that an AsyncLocalStorage store, once set, is accessible throughout your application during the same request-response cycle on the server. This means the store is available in server components even outside the context boundary. However, our goal here is to mimic the behavior of the React Context API on the server, providing a familiar pattern for managing and sharing state across server components.

// server-context/context.tsx
// We need to make sure that this file is only imported in server environment.
import "server-only";
import { AsyncLocalStorage } from "async_hooks";
// A helper type to wrap our generic type into an object. useful when you want to extend the context with additional metadata.
type BindServerContextStoreData<T> = {
    storeValue: T;
};

// Type for our server context, we will try to make its signature similar to the react context so that it works well with our mental modal.
export type ServerContext<T> = {
    Provider: ({
        children,
        storeValue
    }: {
        children: React.ReactNode;
        storeValue: T;
    }) => React.ReactNode;
    store: AsyncLocalStorage<BindServerContextStore<T>>;
    defaultValue: T;
};

Let’s understand the above code:

  1. We are importing server-only which is a file that is only available in the server environment. This is a good practice to make sure that the file is only imported in the server environment. If used in the client environment, it will throw an uncaught error.
  2. We are importing AsyncLocalStorage from async_hooks which is a new feature in Node.js 16.8.0.
  3. We are defining a type BindServerContextStoreData which is a helper type to wrap our generic type into an object. This is useful when you want to extend the context with additional metadata.
  4. We are defining a type ServerContext which is a generic type that takes a type T and returns an object with three properties:
    • Provider: A React component that takes two props children and storeValue and returns a React node.
    • store: An instance of AsyncLocalStorage that takes a generic type BindServerContextStore<T>.
    • defaultValue: A default value of type T.

Now let’s define our ServerContext:

// server-context/context.tsx
export const createServerContext = <T,>(defaultValue: T): ServerContext<T> => {
    const store: ServerContext<T>["store"] = new AsyncLocalStorage<
        BindServerContextStore<T>
    >();
    return {
        Provider: ({ children, storeValue }) => {
            return (
                <Fragment>
                    <SyncContext
                        serverContextStore={store}
                        storeValue={storeValue}
                    />
                    {children}
                </Fragment>
            );
        },
        store,
        defaultValue
    };
};

// We need to inject the store into the context so that it is available in the server components as well.
const SyncContext = <T,>({
    serverContextStore,
    storeValue
}: {
    serverContextStore: AsyncLocalStorage<BindServerContextStore<T>>;
    storeValue: T;
}) => {
    serverContextStore.enterWith({ storeValue });
    return null;
};

Let’s understand the above code:

  1. We are defining a function createServerContext that takes a generic type T and a default value of type T and returns an object of type ServerContext<T>.
  2. Inside the function, we are creating an instance of AsyncLocalStorage with the generic type BindServerContextStore<T>.
  3. We are returning an object with three properties:
    • Provider: A React component that takes two props children and storeValue and returns a React node.
    • store: An instance of AsyncLocalStorage that takes a generic type BindServerContextStore<T>.
    • defaultValue: A default value of type T.
  4. We are defining a component SyncContext that takes two props serverContextStore and storeValue and returns null.
    • This piece of code is very important as enterWith sets up a new asynchronous context with a predefined store value. Unlike run, it doesn’t require a callback and affects all subsequent async operations in the current execution context. It’s useful for scenarios where you need to establish a persistent context across multiple async operations without nesting callbacks. This method is particularly valuable in middleware scenarios or when setting up context for a series of related async tasks.

Now let’s create a function to consume the context:

// server-context/context.tsx
export const useServerContext = <T,>(serverCtx: ServerContext<T>) => {
    const store = serverCtx.store.getStore();
    return store?.storeValue || serverCtx.defaultValue;
};

Let’s understand the above code:

  1. We are defining a function useServerContext that takes a generic type T and a ServerContext<T> and returns the store value or the default value of the context.
  2. Inside the function, we are getting the store value from the context using getStore method and returning the store value or the default value of the context.

In just three lines of code, we got similar functionality to the React context API useContext. Now let’s see how we can use this context in our server components.

Example: We need to use search params in our server component. We all know that in the current implementation of Next.js, we can’t get search params in the server components. We need to pass them as props to the server components from the page or use client components to access search params using useSearchParams

// app/page.tsx
import { ServerComponent } from "./_components/ServerComponent";
import { createServerContext } from "@/server-context/context";

type SearchParamsType = {
    [k: string]: undefined | string | string[];
};

export const ctx = createServerContext<SearchParamsType>({});

export default function Home({
    searchParams
}: {
    searchParams: SearchParamsType;
}) {
    return (
        <ctx.Provider storeValue={searchParams}>
            <main className="flex min-h-screen flex-col items-center justify-between p-24">
                <ServerComponent />
            </main>
        </ctx.Provider>
    );
}

What we are doing here is:

  1. We are importing ServerComponent from _components/ServerComponent.
  2. We are importing createServerContext from server-context/context.
  3. We are defining a type SearchParamsType which is an object with keys of type string and values of type undefined | string | string[].
  4. We are creating a context ctx using createServerContext with a default value of an empty object.
  5. We are defining a function Home that takes an object with a key searchParams of type SearchParamsType.
  6. Note that this implementation needs a defaultValue, you can choose to keep it optional.

Now let’s see how we can use the context in our server component:

// app/_components/ServerComponent.tsx
import { useServerContext } from "@/server-context/context";
import { ctx } from "../page";

export const ServerComponent = () => {
    const value = useServerContext(ctx);
    return <div>this is a server context - {JSON.stringify(value)}</div>;
};

What we are doing here is:

  1. We are importing useServerContext from server-context/context.
  2. We are importing ctx from ../page.
  3. We are defining a component ServerComponent that uses the useServerContext hook with the ctx context.

With just a single file, and a few lines of code, we have created a server context that can be used in server components. This is a powerful feature that can be used to share data between server components and pass data from the top to the bottom of the component tree without having to pass props manually at every level.

Making it isomorphic

To create an isomorphic context, we need to ensure it functions on both server and client sides. We can achieve this by creating an additional context using the React Context API. There’s a common misconception that React context can’t be used with server components, but this isn’t entirely accurate. While the context itself and its consumers must be client components, we can indeed use React context in server components, and the children of a React context can be server components. This approach allows us to leverage the benefits of both server-side rendering and client-side interactivity, providing a more flexible and efficient way to manage shared state across our application.

Ideally what we will end up with is:

// app/page.tsx
import { ServerComponent } from "./_components/ServerComponent";
import { createServerContext } from "@/server-context/context";

type SearchParamsType = {
    [k: string]: undefined | string | string[];
};

export const ctx = createServerContext<SearchParamsType>({});

export default function Home({
    searchParams
}: {
    searchParams: SearchParamsType;
}) {
    return (
        <ctx.Provider storeValue={searchParams}>
            <ClientCtx.Provider value={searchParams}>
                <main className="flex min-h-screen flex-col items-center justify-between p-24">
                    <ServerComponent />
                </main>
            </ClientCtx.Provider>
        </ctx.Provider>
    );
}

cache() in React 19 (Next 14)

The use of AsyncLocalStorage in the server environment is effective because they all preserve values for a single request-response cycle. This means that from the moment a request hits your server until the response is sent back to the client, these tools maintain consistent data. React’s cache() function fits into this category too, as it memoizes and deduplicates computations or API responses within a single render. This behavior is particularly useful with React Server Components, which render once on the server and don’t re-render on the client. By using these methods, we can create a kind of async context that behaves similarly to React’s Context API on the server, allowing data sharing among server components within that single request-response or render cycle.

React 19 introduced a new feature called cache(). This function allows you to cache the result of a function call and reuse it across components in a single request cycle (single render, thus making it perfect for sharing data across server components).

  • It only works with React Server Components.
  • React will invalidate the cache for all memoized functions for each server request.
cache() in action

Let’s see how we can use cache() in our server components:

import { cache } from "react";

export default function cacheContext<TStoreValue>(
    defaultValue: TStoreValue
): [() => TStoreValue, (value: TStoreValue) => void] {
    const cachedValue = cache(() => ({
        store: defaultValue
    }));

    const getter = (): TStoreValue => {
        return cachedValue().store;
    };

    const setter = (value: TStoreValue): void => {
        cachedValue().store = value;
    };

    return [getter, setter];
}

What we are doing here is:

  1. We are importing cache from react.
    • This will cache the map for one render (one request cycle) and all the components that uses/consumes this cached map object will receive the same object.
    • Since the server response for a page comes in a sequence, therefore, when we set the value in a parent component, the child component will receive the updated value. and changing the same later on in the child component will affect the parent component as well. (This is the same behavior as AsyncLocalStorage or React Context)
  2. We are defining a function cacheContext that takes a default value of type TStoreValue and returns an array with two functions:
    • getter: A function that returns the cached value.
    • setter: A function that sets the cached value.
  3. We are using the cache() function to cache the default value and return a function that returns the cached value. Rest is self explanatory.

Now let’s see how we can use the cache in our server components:

Suppose we are in a Next.js website with locale support and we want to share the locale across server components. We can use the cacheContext function to achieve this. Almost all of the libraries for i18nl comes with a context provider to provide locale in client components so our cacheContext will be able to achieve the same in server components.

// page.tsx
import cacheContext from "@/server-context/cached-store";
import { ServerComponent } from "./_components/ServerComponent";

type ParamsType = {
    [k: string]: undefined | string | string[];
};

// providing default value as "pl"
export const [getLocale, setLocale] = cacheContext("pl");

export default function Home({
    searchParams,
    params: { locale }
}: {
    searchParams: SearchParamsType;
    params: SearchParamsType;
}) {
    // setting the locale
    setLocale(locale);

    return (
        <main>
            <ServerComponent />
        </main>
    );
}

As you can see, we are using the cacheContext function to create a cached store for the locale. We are setting the locale in the parent component and using it in the server component.

// _components/ServerComponent.tsx
import { getLocale } from "../page";

// accessing our locale here
export const ServerComponent = () => {
    const locale = getLocale();
    return <div>this is a server context - {locale}</div>;
};

We have imported the getLocale function from the page file and used it in the ServerComponent component to get the locale. Now we have successfully shared the locale across server components using the cache() function.

This is how we can use the cache() function in React 19 to share data across server components.

When to use it:

In 95% of cases, you probably don’t need these complex solutions. I have this theory: if you find yourself relying heavily on custom implementations or hacky solutions in a very opinionated framework like Next.js, you might be overcomplicating things.

Think about it - if something was really necessary, wouldn’t the framework have already included it? It’s like those good old memory eating React hooks, useMemo and useCallback. Most of the time, you don’t actually need them.

However there are times when you do not have any other option but to use these solutions.

  1. When you need to share data between server components. (locales, computation results etc.)
  2. When you want route params in the server components.
  3. When you want to use any Next specific config in the server components. (i.e. Route Segment Config)

Conclusion

In this blog, we learned how to use isomorphic contexts in Next.js 14. We learned how we can share data among parent and its child components in server components similar to the client components. We also learned how we can use AsyncLocalStorage in Node.js and cache() function in React 19 to achieve this. We also learned when to use these solutions and when not to use them. For most of the cases, like sharing locales or params etc. these solutions works fine without any performance issues.

NOTE: Please make sure, you are not using these solutions in Layout.tsx as Layout.tsx are not rerendered on every request cycle and you may get stale/unwanted data. Please read for more: - https://github.com/vercel/next.js/issues/43704#issuecomment-2090798307

Codesandbox Link: https://codesandbox.io/p/devbox/isomorphic-context-n45z8j?embed=1

References: