The patterns are a bit of a footgun. ~ From “Sabagebu! -Survival Game Club!-” (さばげぶっ!) 2010

Cursed Server Context Patterns In Next.js 14

MrManafon
Homullus
Published in
15 min readFeb 24, 2024

--

Let’s make a “footgun”. At the moment there is no counterpart to the createContext React API in server components. In this article I compile the history and approach that library authors (including Vercel at times) employ to share state — into a single, cute, createServerContext.ts file.

With the release of Next 13 and 14, after various canary versions, we have entered into a new era of React. It is important to keep in mind that it is not only Next.js that is pushing the envelope here — it is React themselves, Next.js was just among the first to adopt these new React patterns. Sure, this will push some people away, but for me, this means that RSC and similar APIs aren’t magically going away, and it would be best to not only get used to them, but to help out in discussing the usage.

The premise is that it is in fact possible to recreate createContext and useContextAPIs in RSC environment, with some limitations. Regardless of if it is “smart to do”, we should know these patterns, freely discuss them as a community and try to figure out where the boundaries should be, and do the one thing that React devs hate doing — learn from other, more mature ecosystems.

This blogpost was written after we’ve used the material in a sweet tech discussion during one of our weekly Tech Learning sessions at NoA Ignite Denmark. The response and the sheer level of engagement from everyone inspired me to compile the thoughts into a semi-readable format. Enjoy :)

A word on servers

The conversation has so far been focused on “old vs new react” or “next vs remix” etc, but in reality, React and Next.js going into the RSC space actually pits them against other backend frameworks. More mature web frameworks, mostly from other languages, that already have these problems solved with battle tested patterns, by the virtue of being around for 30 years. My suggestion is to cut Vercel some slack and give them time to catch-up.

In the server, we consider state bad. We go out of our way to eliminate state. It’s a hard problem. There are whole languages dedicated to eliminating state.

Front-ends, however, are all about managing and embracing state. JS frameworks like React, Svelte and Vue were revolutionary because they have perfected this art.

Kaeya & Diluc — Genshin Impact

If we look at it that way, we can realize that React trying to marry the two fundamentally different sides into an unified framework is batshit crazy. But the power of colocation shines again, as it did with Tailwind before it, as it did with React before it, as it did with NodeJS before it — and makes the effort worth it.

My statement that “state is considered bad in the backend” is a bit of a simplification and can be misleading. It’s not that state itself is inherently bad, but rather that managing state improperly or having unnecessary or excessive mutable state can easily lead to severe problems in backend applications.

Most backend systems are equipped with a toolkit that solves stateless, data transformation problems well. Putting additional constraints when it comes to state can make for a huge step forward, a good example of that is the popularity of development experience that languages that impose immutability or synchronous operation have enjoyed.

One of the major reasons, historically speaking, for state being considered bad is that it was quite hard to achieve process isolation in these languages. Older NodeJS developers might remember how much everyone teased them for having to deal with state cleanup in the error-prone user space, something that had been solved elsewhere. Luckily, this argument isn’t very valid, as NextJS runs in transient, serverless or edge processes.

It is said that >95% of all networking goes through Erlang powered hardware, so saying “state is bad” is just me being silly.

Unless we are working on a very specific problem that stands to gain from being a stateful service, we put a lot of attention to controlling how far we push the boundary. You need look no further than the success of Erlang and Elixir or Scala and Akka, that were able to build systems with millions of processes at Discord, WhatsApp, Cisco — thanks to their extremely powerful state/actor management and focus on protocol design in the place of interface declaration.

An inherent problem of the new Next.js model is that what traditionally were “client-side developers” are now learning how to manage what was traditionally considered server-side data. Getting used to the limitations (and freedoms) of a server, is a process that will take time. I personally think that React and Vercel teams have been doing a good job at teaching about this shift lately, with YouTube videos, live events (we did one together at NoA), influencer community outreach and support.

There is a lot to learn. There are a lot of things that backend developers had to understand and worry about, that frontend developers don’t — and vice versa. Now all of it is your problem.

This is the real reason why we don’t have hooks, contexts, globals, access to the request object — other backend frameworks don’t have them either. Most of the time, they’ve developed highly guarded ways of distributing information to the consuming code — something that traditional Next.js developers simply aren’t used to yet.

Wait, wasn’t there already a Server Context API?

Indeed, an API proposal existed in 2022, and even entered canary, which is why most of you can still find and import createServerContext in your IDE’s autosuggestions — because Next.js ships with a bundled React canary — but it wouldn’t work if you tried using it.

The aspirations that the React team had for it were quite high tho, and ended up being it’s bane. They tried treating it exactly like the client context API, and insisted on things like bidirectional communication — something that is almost non existent apart from revolutionaries like Elixir Phoenix.

I can see why, it’d be amazing if we had a truly “shared” context that rerenders server components, but they let perfection be the enemy of practicality, and killed the API, finding that it is severely limited, mostly useless in practice, wrongly named and would cause confusion and security obstacles.

A good part of the reasons are legit — having the same pattern name would put the expectations of the audience that is used to client contexts quite high, higher than is achievable by very very few backend frameworks, and even then, only those with visionary-level effort.

Sebastian left us with a set of warnings and recommendations on the deprecation PR. All completely sensible stuff — talking about the dangers of the pattern, and giving a set of recommendations for alternatives.

One thing is clear — in more mature frameworks like Phoenix, Symfony, Spring, NestJS, having something akin to a global state is widely understood to be a “footgun”. And this is percicely what Sebastian tried to communicate. Unfortunately, to an audience of developers that are used to having it, and therefore don’t take “this is how backend is” as an answer.

If the “problem” you are trying to solve is prop drilling, this is most likely the wrong tool for the job.

Still, while it is “understood to be a footgun”, escape hatches have been left, for those cases where it is in fact needed. We discuss these again, after looking at the code. In my opinion, the framework shouldn’t go out of it’s way to guard me against myself, this is precisely why I’ve always preferred Symfony over Laravel, for example.

Not-so-helpful comments

As a small intermezzo, let’s turn to the unhelpful comments section of tonight’s show. 🎥

The award for second place, for the most unhelpful comment on a discussion thread that centers around “how can I do server contexts” goes to Lee Robinson from Vercel 😆

Disclaimer: Look, I love his work, especially the focus on community outreach and the YouTube videos lately, this is not intended as a diss. I just found this funny enough that I couldn’t let it go. While the original docs link that he posted doesn’t work anymore, the docs that do exist don’t belong in such a discussion, and also lack any context, to this day. (there is obviously enough context around this issue for me to write this million word blogpost)

🥇 The golden medal for the first place, however, goes to a commenter that we’ll omit the name of, with the entry: 👇

Chef’s kiss.

Now, back to topic.

Cache as useState

Following the recommendations that Sebastian made, most library authors in the ecosystem started using some form of React’s cache as a way to share state. There is no credit to be distributed, but I’ve found Jan Amann from the amazing next-intl package to always be at the forefront of any discussion surrounding this.

One of the things that I’ve seen him do was attempt to compile lists of suggestions for legitimate use cases, before opening tickets towards Next.js or React. Truly wise, I wish more of us had the patience to do that. Anyway, I mention this, because it is with next-intl that I’ve learned about this pattern, as they use it to avoid prop drilling on the locale string, through providing useLocale and getLocale methods for the client and server respectively.

Because cache can dedupe data during a single React tree processing, we can effectively use it to store things away and retrieve them later during the same render. And, it’s simple to use:

Super simple, manually written example. Not from next-intl.

In my mind, this isn’t a Context yet, but it is the basic building block of one — I see it as a replacement for global state or useState , with the caveat that it is not a hook, and cannot trigger re-render on it’s past usages, in the event of change.

We’ve seen a couple of packages kickstart out from this pattern, inside the GitHub discussion threads, namely server-only-context.

The Boy and the Heron (君たちはどう生きるか), 2023

Simulating the createContext API

For a while this worked mighty fine for my needs, I wanted to try out how we could utilize this mechanism to simulate the real API. It was not until I came across next-impl-getters Pull Request that I’ve seriously considered it. The PR in question is pending merge to Next.js and is trying to introduce the much needed getParams , getSearchParams , getPageConfig functions. On the sidelines, however, they’ve actually replicated the context interface.

For some reason that I am fundamentally unable to understand, even tho I’ve tried, they are using AsyncLocalStorage instead of cache . My best guess is, because they are focusing on a lower level implementation as a PR toward Next.js codebase, and using the exact APIs that Next.js uses.

Now comes the fun part. In the codebase above, you can find a single, copypastable file that provides you with a thin wrapper around cache in a way that simulates the context APIs really well. You can find usage examples inside the JSDoc as well as within the repository.

Let’s look at a basic example.

// LocaleServerContext.ts
export const LocaleServerContext = createServerContext<string | null>(null);

// page.tsx
export default function Page() {
return (
<LocaleServerContext.Provider value={"en-GB"}>
<ServerComponent />
</LocaleServerContext.Provider>
);
}

// ServerComponent.tsx
export function ServerComponent() {
const locale = getServerContext(LocaleServerContext);
return <p>Server Locale: {locale}</p>;
}

For the sake of simplicity, my server component is an immediate child of the context. Needless to say, you wouldn’t be brain-broken enough that in the real world. The example can be found in the GitHub repo as well.

Isomorphic React Server Context

Let’s kick things up a notch. I’ve gotten (and borrowed) the idea for an isomorphic context from this PR by Alexander.

The idea is to use both a server and a client context, pass them both data immediately, but hide the fact that we have 2 contexts running.

// page.tsx
export default async function Page() {
return (
<LocaleContextProvider locale={"en-GB"}>
<ClientComponent />
<ServerComponent />
</LocaleContextProvider>
);
}

// LocaleContextProvider.tsx
export function LocaleContextProvider({
locale,
children,
}: LocaleContextParams) {
return (
<LocaleServerContextProvider locale={locale}>
<LocaleClientContextProvider locale={locale}>
{children}
</LocaleClientContextProvider>
</LocaleServerContextProvider>
);
}

Because both contexts are available, both our server and client components will seamlessly have access to the data.

I’d love to see an example where we can update the data in the context, because, in theory we should be able to do so, and the last snapshot of the data would go on to become the client’s version of the context. I don’t think it’d be useful, and it would actually be an anti-pattern, but hey, who am I to judge?

Server Context Limitations

Make no mistake, Server Contexts are an anti-pattern, unless you have a very good reason to use them. Some things are simply not possible as things stand, and are unlikely to be solved soon, or ever:

  • A single context spanning layouts and pages. Layouts and pages may be rendered separately, at different times and with different information. They can’t share a context. They must initialize it individually.
  • Rerendering server components based on downstream updates. Not possible. The server renders in one pass, so if the value changes, there will be no re-renders of the already rendered parts of the tree. The components that are already rendered can be considered immutable. A good ecample of this is the layout, or prerendered components.
  • Rerendering server components based on client updates. Theorethically possible with some weird-ass LiveView-style approach. Totally out of the question at the moment tho, and honestly not sure if there is a real use case. The big power of LiveView lies in the Erlang VM, and how cheap it is for the BEAM to keep processes alive. Try that on Vercel and your billing department will soon find out. 🤭
  • Automatic API minimization towards clients. Client gets the data that we pass, there is no protections apart from taint, which is hard to use, and hardly helps. This means your isomorphic context should be tiny, because anything you pass to it will end up on the client. You can’t simply pass the user, you must pick only the data needed or else you’ll sens the PII, hash, salt to the client unintentionally. Even when it’s not dangerous, you can very easily send huge data for no reason if you aren’t picking.

Avoid sharing state on the server, unless you have a damn good reason. This approach ties your component to a specific context, restricting its usability outside of that context and hindering debug-ability.​ It basically adds/allows for a side effect.

The alternatives that Sebastian has proposed aren’t some “bad ideas that he came up with, just to screw with us”. They literally are borrowed from other, more mature frameworks. All backend frameworks are playing a game of trying to find their own flavor of this boundary, and over the last 30 years have found design patterns or refactoring strategies that can help reduce the need for such “deep” argument passing.

Dependency Injection or Service Container is widely used by anything and everything from Unity to NestJS. Twig and Blade templating engines allow for extensions that have access to dependency injection. Some languages avoid even that, considering it a bad practice and choosing prop drilling instead, Elixir’s Phoenix for example.

Passing “context objects” into deeper props, decorators or chains of responsibility is a very common one as well. You can find it in any middleware pattern, from Express to Plug.

If you ever worked with Rust, Ruby, PHP, Python you know fully well that they don’t have just one cache, but instead rely on 2–3 different cache systems, from filesystem to apc and memcache, to Redis and varnish to share state on things like sessions. Some languages bundle their own — Erlang and Elixir come with a built-in distributed ETS cache.

Using data from the global scope, that he mentioned is present in anything from Flask to .Net to Java to Wordpress or Laravel. And funnily enough the abuse of it is often cited as “top reasons why PHP is bad” by geniuses on Reddit.

Possibly most importantly, all of them deal with some sort of API minimization-ish pattern. Data flows mostly freely in the server environment, but at a cost — we have to take great care of not leaking it towards clients. Whichever API mechanism our systems had used in the past, from RPC to gql, we always had to draw clear interface boundaries, do exhaustive scope checks and pack data into DTOs before shipping it to the public scope. Now we have a new frontier to do this with — server actions and client components.

And now, my pièce de résistance…

Mawaru Penguindrum (輪るピングドラム), 2011

Remotely Hydrated Client Context

A very rare, but very cursed pattern comes out of the necessity to populate things that might only exist in the layout, with data that may only exist in the page.

The first time I’ve seen this done was with a language switcher component. It’s easy to imagine, header usually gets rendered in the layout, and header usually contains a language switcher.

On the other hand, the data needed to calculate the appropriate alternate URLs exists only in the page. Only the page gets the needed params, queryparams, headers, url that we need to figure out “on what page are we?”.

How do you send data from the page, to an possibly-already-rendered layout? You can see the answer in the repo, or broken down, here. First, we define a normal client context inside the layout. You’ll notice that we don’t pass any attributes to the component (as we don’t know what urls we wish to show yet).

// layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<AlternateUrlContextProvider>
<HeaderComponent />
<main>{children}</main>
</AlternateUrlContextProvider>
</body>
</html>
);
}

Obviously, we have a header here, and inside header there is a component that consumes the context in order to display the data — but there is no data yet, just an empty array. So, our layout’s initial render will render with an empty array.

Now, we turn to the page. Calculating alternate URLs for a given page can either be simple, or very complex. It can even be unsafe — we have to know the params, the user, visibility scopes, fetch data from a CMS or a PIM, perform route translations etc. Either way, we don’t want that logic duplicated to the client for no reason. So, we tuck it away into a server-only function we can call getAlternateUrls.

Now we have all we need. At the very beginning of our output, we instantiate a ContextHydrator . It is a client component that will get fed with our server-based data contained in the alternateLinks which will get encoded into the page’s HTML.

export default async function Page(params: { locale: string }) {
const alternateLinks = await getAlternateUrls(...);

return (
<>
<AlternateUrlContextHydrator alternateLinks={alternateLinks} />
<p>Our pretty page content.</p>
</>
);
}

If we look more closely to the hydrator itself, it is just a silly little client component that uses the context (provided far far away in the layout) and sets the new data that it got from the server.

export function AlternateUrlContextHydrator({
alternateLinks,
}: {
alternateLinks: RouteUrlDefinition[];
}) {
const { setLinks } = useAlternateUrls();
useEffect(() => setLinks(alternateLinks), [alternateLinks, setLinks]);
return null;
}

If we want to be super smart, we can also split our switcher component into two parts. A server component, that simply renders the data that is given to it, and has no idea what contexts are — and a client wrapper for that component, that can use the data from the context. This way we can reuse the switcher anywhere in the app.

// TableContextWrapper.tsx
export function TableContextWrapper() {
const { links } = useAlternateUrls();
return (
<div>
<p>Table is client rendered:</p>
<Table alternateLinks={links} />
</div>
);
}

// Table.tsx
export function Table({alternateLinks}: {alternateLinks: RouteUrlDefinition[]}) {
return (
<table border={1}>.........

To recap, we have allowed the layout to render an empty language switcher, because the default data in the context that it uses is empty.

Later on, in the page, we have resolved the needed data on the server, and passed it to a client component as initial state. When the page loads on the client, our little component will reach for the context, and hydrate it.

Thus, our page now renders this magnificent beauty of a language switcher, that I’ve styled as a table for the sake of observability.

--

--