Feature Flagging with NextJS: Lessons Learned from Developing DevCycle's SDK
Building DevCycle's NextJS SDK was a technical adventure filled with unique challenges and valuable learning experiences. As we navigated the intricacies of integrating feature flagging with NextJS's combination of client- and server-rendering, we encountered obstacles that pushed us to learn the inner workings of NextJS and adapt our approach multiple times.
Challenge 1: Sharing Context on in Server Components
A significant hurdle we faced was the lack of React Context API support in server components, which is usually crucial for sharing context in a component tree. In this case, the context we need to share is the set of flag values we've obtained for the user and the instance of the DevCycle client that knows how to use those values.
In our initial solution, a component called DevCycleServersideProvider wrapped the server component tree, which aimed to centralize DevCycle’s setup and populate the React cache with flag values. This approach appeared to work initially, but several problems soon became apparent.
In NextJS, a page's layout is not rendered before the page contents inside it. In actuality, they are rendered in reverse order. That means any side-effect performed by a layout will not have taken place when a page starts rendering. In our case, this meant that calls to retrieve variable values from the cache did not work within a page because the cache had yet to be populated with those values by the layout.
Our breakthrough came with a setup function that returns the getVariableValue function, effectively encapsulating the user and SDK context. This approach allowed us to maintain an effective request-level cache of flag values accessible across the component tree, thus resolving the context-sharing dilemma.
Challenge 2: Server Actions in 3rd-Party Libraries
For the new "server actions" feature to work, NextJS performs a lot of heavy lifting (read: magic) with its bundler.
NextJS produces several bundles of code when building an application. There is a server bundle containing all the serverside code that will never be sent to the client, and you guessed it: a client bundle that includes the safe code to send to the client. The client bundle is built by following the "use client" directives in your source code to make something a client component. Any file containing that "use client" is included in the client bundle and any file imported by that file.Server actions are a particular case. They have the unique property that they are intended to be imported on both the server and the client, and calling the function on the client triggers a network request to perform that action on the server. But how does that work?
Wherever you see a directive like "use server" in a file or function, that indicates to the compiler that the file or function is a "server action". To the client bundler, this means "don't include the source code of this action, instead transform it". In this case, "transform it" means "replace this function with a function of the same name, which performs a network request to an automatically generated API on the server, which contains the real code for this function". The server bundle then keeps the actual source code of this function and Next automatically registers it to an API that the client bundle knows about.
The issue in our case is that the bundler did not perform any of this magic for 3rd party libraries. There was a bug in the Next build system that did not consider the use of server directives when bundling 3rd party libraries. The effect of this is that in our attempts to use a server action in our SDK, we had the implementation code for that action shipped to the client and executed there instead, where it simply threw errors.
We reported this bug with the compiler, which has since been fixed. It is the reason why the SDK requires a minimum Next version of 14.0.5
Challenge 3: Consistency in User Data and Rendering Results
To accurately render a page on both the server and the client, you must ensure all the feature flag values are the same in both contexts. This presents a challenge when the client holds its own state or if the user data can be updated on the client.
Initially, we tried to solve this consistency problem by using a unique cookie that the server and client could exchange to keep their information in sync. Doing so allowed you to call identifyUser on the client to change the user data, and it would sync back to the server on the subsequent request by reading the contents of that cookie.
Unfortunately, this system had a few flaws:
- Users needed cookies enabled for the basic identification process to work
- Once the cookie is set, it becomes ambiguous who had the "latest" version of the user
- If the server provides its own copy of the user, should that be used over the user in the incoming request cookie?
- When do we update that cookie with newer server data?
Ultimately, we decided to eliminate the ability to change user data on the client side. We believe that in a server-rendered environment, the server should always be able to provide the latest version of the user's data on a page request. If they do something clientside that should modify their DevCycle data, the server should be notified through some external means outside the scope of our SDK so that the latest user data can be provided on the next request for content.
Challenge 4: Adapting to Missing Next Features for Event Publishing
Next’s App Router is still very new, and as such, some of the functionality is incomplete or rough around the edges.
We encountered limitations due to missing functionalities, particularly the absence of the waitUntil method. This limited our ability to send events to DevCycle servers in a non-blocking manner while delivering rendered pages quickly. Consequently, our SDK does not currently support sending events from the NextJS backend, highlighting the need to adapt to the platform's ongoing development.
Challenge 5: Supporting Streaming and Suspense Boundaries
Streaming in the SDK allows the rendering of page content to be suspended any time a flag value that has not yet been obtained is needed while still rendering and serving any content that does not require flag values.
To properly support this, the SDK needed to ensure it could properly trigger a suspense boundary on the server where a flag value is used and unblock the non-flagged content that can be sent to the client. Meanwhile, the server would continue fetching the flag values and stream the completed server-rendered content to the client. On the clientside, the SDK still needed to trigger suspense while the server was obtaining the flag values, and only when the server streamed its contents could the client render its flagged content.
To make this work, the SDK takes advantage of several new features of React & NextJS designed for streaming use cases. First, passing a Promise directly into the props of a client component from the server is now possible. The promise is treated as an open stream waiting for the result of the resolved promise.
To receive the results of the promise on the client, the React function use can be…used. When this function is called clientside with a promise sent by the server, it automatically suspends that component clientside until the promise resolves and re-renders with the resolved value.
The DevCycle SDK passes its initialization promise to the clientside Provider component when in streaming mode. That allows suspense to take action in every useVariableValue hook call, all of which are calling use on that promise to suspend their respective components until data is available.
Whenever initialization is complete, the client receives the rendering results of all the suspended server components and its own client components, all with the newly determined flag values taken into account!
Developing the DevCycle NextJS SDK was a journey marked by challenges that demanded learning the inner workings of NextJS and iterating on our approaches. Each obstacle provided us with valuable insights and led to the creation of a feature flagging SDK that integrates natively with modern NextJS applications. We're excited to see how developers will harness these capabilities in their NextJS applications, and we will continue to iterate and enhance our NextJS SDK for DevCycle.
We're eager to hear your feedback and experiences. If you've encountered similar challenges or have insights on alternative solutions, we'd love to hear from you!