Lessons Learned from Developing DevCycle's Next.js SDK

Lessons Learned from Developing DevCycle's Next.js SDK

Building DevCycle's Next.js Feature Flagging SDK was a technical adventure filled with unique challenges and valuable learning experiences. As we navigated the intricacies of integrating feature flagging with Next.js's combination of client- and server-rendering, we encountered obstacles that pushed us to learn the inner workings of Next.js and adapt our approach multiple times.

Check out our blog post introducing the Next.js SDK for a more general introduction to how the SDK works.

To recap, the DevCycle Feature Flagging Next.js SDK can:

  • Retrieve feature flag values for a given user to be evaluated in both Server and Client components
  • Re-render in real-time when configuration is changed
  • Suspend components that need flag values when they haven't been retrieved yet

Let's dive in to how the SDK works.

Background

To understand the mechanisms the SDK uses which are described in this article, you should have a good understanding of React Server Components and how they are used in Next.js App Router. Server Components represent a new way to think about React, where page content can be rendered exclusively on the server. The result is smaller client bundle sizes, and the ability to execute server-side code (like DB queries) directly from components.

We recommend reading this article.

How the SDK Works

The SDK comprises server-side and client-side code, which works together to form the end-to-end system. On the server, a setup function returns an encapsulated set of methods used to retrieve flag values and provide bootstrapping data to the client:

import { setupDevCycle } from '@devcycle/nextjs-sdk/server'

const getUserIdentity = async () => {
  const user = await getUserFromRequest()
	return {
		user_id: user.id
	}
}

export const { getVariableValue, getClientContext } = setupDevCycle(
    process.env.NEXT_PUBLIC_DEVCYCLE_CLIENT_SDK_KEY ?? '',
    getUserIdentity,
)

When you call getVariableValue or getClientContext, the SDK asynchronously fetches the current DevCycle configuration using a cached fetch request. It also calls the provided “user getter” to obtain the user data it should use to evaluate flags for this request.

It then uses this user data to locally derive the correct set of flag values for that user. It stores the result in React’s cache memoized function mechanism, which keeps the data for the duration of the current request. This flow is shown below:

Each subsequent time the getVariableValue function is called, it retrieves the values from this cache for use in a server-rendered component. Flag values can then be used anywhere in the rendering tree while respecting the current request’s user data and context.

The server’s context (configuration and user data) is also passed along to the client via the DevCycleClientsideProvider component. This component uses a React context to provide the DevCycle flag values to the client-side component tree. On the client, the useVariableValue hook retrieves values from that context.

The process of rendering on the server and client while using feature flags is shown below:

When the user’s browser receives the page, the SDK establishes a real-time Server-Sent Event (SSE) connection to DevCycle. The connection receives any updates made to the project configuration. The SDK will then trigger an in-place page update of the server and client components. It does so by:

  1. Submitting a server action which invalidates the cache for the configuration on the server-side
  2. Triggers router.refresh(), a Next API which efficiently re-renders the server-side and client-side page contents while preserving state and scrolling position.

This process is illustrated below:

We encountered several challenges while developing the SDK, which we’ll dive into below.

Challenge 1: Sharing Context in Server Components

A significant hurdle we faced was the lack of React Context API support in server components, which is 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.

We had specific requirements for the SDK interface that made this necessary:

  • a single location in your code where the SDK is configured and options are set
  • a single location where the user for the current request is identified and provided to the SDK
  • a way to get a flag value anywhere in the component tree by passing only its name and default value

In our initial solution, a component called DevCycleServersideProvider wrapped the server component tree in the root layout (or as early as possible), which aimed to centralize DevCycle’s setup and user identification. When the Provider is rendered, it performs side effects like fetching the configuration, and those results are stored in the React cache. The getVariableValue function would then be able to retrieve those results from the same cache anywhere down the tree. At the same time, the DevCycleServersideProvider component would also render a client-side provider that uses a regular React context to supply the same data to client components.

This approach appeared to work initially, but several problems soon became apparent.

Evolving the Approach: Setup Function-Based Solution

In Next.js, a page's layout is not rendered before the page contents inside it. In reality, 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, 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.

Even worse, we soon realized that the layout doesn’t always render when a request for new server-rendered content comes in.

One scenario where this happens is when a user navigates in their browser using a Link component, taking them to a different application page. To make navigation as fast as possible, Next only renders the contents that are different between this page and the last and efficiently sends that “diff” to the browser. As it happens, the root layout is shared by every page and only renders on the initial page load request, not on any following client-side navigations. That meant that the parts that were rendered ended up looking for data expected to be in the cache, but it was never put there by the layout.

It became apparent that there needed to be a better strategy than relying on a layout being rendered. A possible solution to our problem was requiring the user to initialize DevCycle in every page file they wanted to use it, but we decided that would be too onerous and negatively affect the experience of using the SDK. It currently does not seem possible to consistently execute a piece of code before any rendering takes place in Next.js App Router.

Our breakthrough came with a setup function that returns the whole SDK interface, including the getVariableValue function. Doing so effectively encapsulates the user and SDK context so that any call to those methods is able to access the cache and also populates it if it has not already been populated. Thus, no matter where or when the functions are called, the SDK setup will be performed. We no longer have to rely on having one location that triggers the setup code; instead, it’s triggered by any component that needs flag values!

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: 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 anywhere a flag value is used and unblock the non-flagged content that can be delivered to the client. Meanwhile, the server would continue fetching the flag values and stream the completed server-rendered content to the client. On the client-side, the SDK still needed to trigger Suspense boundaries while the server was obtaining the flag values, and only when the server streamed its contents could the client render its flagged content. It should still be able to render non-flagged client-side content without being blocked.

To make this work, the SDK takes advantage of several new features of React & Next.js 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 client-side with a promise sent by the server, it automatically suspends that component client-side until the promise resolves and re-renders with the resolved value.

The DevCycle SDK passes its initialization promise to the client-side 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!

The whole process is illustrated below:

Other Challenges

We encountered other difficulties during development:

  • Lack of waitUntil support to keep the server backend open while background work is completed. Due to this, the SDK does not support sending events from the server-side
  • Server Actions in third-party libraries were unsupported. This was caused by a bug in the Next.js bundler, which we reported during development and was fixed in their 14.1 release.

Conclusion

Developing the DevCycle Next.js SDK was a journey marked by challenges that demanded learning the inner workings of Next.js 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 Next.js applications. We're excited to see how developers will harness these capabilities in their Next.js applications, and we will continue to iterate and enhance our Next.js 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!