Feature Flags in React Server Components with NextJS 13 and Split.io

Arnaud Dostes
8 min readApr 28, 2023

--

React Server Components are finally available in NextJS 13 when opting in the appDir experimental feature.

In this article, I’ll show how to use feature flags in server side rendered pages, and in client side rendered components using NextJS 13 and split.io.

Prerequisite: this assumes that you have a NextJS 13 application and a split.io account with a few splits ready to go.

To use feature flags in our application, we will use the split SDK. This is a convenience library that provides a set of components and helper functions that help us access underlying SDK functionality. When using the split SDK, we need three things: an API key, a key representing the consumer, and at least one split name.

Looking at the NextJS 13 doc, we learn a few things. First we see that it’s better to use Server Components to fetch data. Then we learn that the new use keyword is not quite ready yet so a third party library like React Query or SWR is recommended to fetch data on the client. Our application needs to make an api request to split. We will look how to achieve this on both the server side (which is really the best way) but also on the client side. We will also make sure that the API key remains safely on the server, and configure our application to use the same consumer key for each request.

Getting treatments server side

First we start by installing the split SDK

pnpm install @splitsoftware/splitio

After creating a new API key on split.io, we’re going to store it in a .env.local file in our root directory.

// .env.local
SPLIT_API_KEY=<insert key here>

Then we create a new page for our experiment:

// src/app/experiment/page.tsx
function Experiment() {
return <div>Experiment</div>;
}

export default Experiment;

Then we’re going to create a function that uses the SDK to retrieve treatments for one or more splits:

// src/split/getSplits.ts

import { SplitFactory } from "@splitsoftware/splitio";
import { headers } from "next/headers";

async function getSplits(splitNames: string | string[]) {
// ensure splits is always a string array
const splits = ([] as string[]).concat(splitNames);

// read the current user from the headers
const headersList = headers();

// if the value of the splitKey cannot be retrieved,
// the SDK will return 'control'
const splitKey = headersList.get("x-split-user") ?? "";

// read the API key from the .env.local file
const authorizationKey = process.env.SPLIT_API_KEY;

// hard fail if the API key is not found
if (!authorizationKey) {
throw new Error("SPLIT_API_KEY is not defined");
}

// initialize the SDK
const factory: SplitIO.ISDK = SplitFactory({
core: {
authorizationKey,
},
});

// wait for the SDK to be ready
const client = factory.client();
await client.ready();

// fetch and return the treatments
const treatments = client.getTreatments(splitKey, splits);
return treatments;
}

export default getSplits;

We see in this code that we use an HTTP header x-split-user as a consumer key. This value needs to be unique and represents a user. If the application uses an identity provider, we can just use that, but in our case, there is no authentication. We need to generate a value and re-use it each time so our treatments are consistent. For the value to be unique, we’re going to use a universally unique identifier (uuid). To re-use it on each request, we’re going to store it as a cookie. Now, why do we need to put it on the headers? On the first request, when we generate the value and store it as a cookie for the first time, it won’t be available until the next request, so by passing it as a header, we make sure it is available immediately. To set the header and save the identifier in a cookie, we’re going to use a middleware:

// src/middleware.ts

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
const response = NextResponse.next();

// this is the cookie name that we will use to store the identifier
const SPLIT_USER_COOKIE = "splitKey";

// is there a cookie with the identifier?
let splitKeyCookieValue = request.cookies.get(SPLIT_USER_COOKIE)?.value;

// if not, create a new one
if (splitKeyCookieValue === undefined) {
splitKeyCookieValue = self.crypto.randomUUID();
response.cookies.set(SPLIT_USER_COOKIE, splitKeyCookieValue);
}

// add the identifier to the response header
response.headers.set("x-split-user", splitKeyCookieValue);

return response;
}

Now, we need to call this function from our page/server component. One great feature about Server Components, is that async/await is now supported, so it becomes very simple to call this function.

import getSplits from "@/split/getSplits";

async function Experiment() {
const { tortoise, test_split } = await getSplits(["tortoise", "test_split"]);

return (
<div>
Experiment:
<div>Tortoise is {tortoise}</div>
<div>Test_split is {test_split}</div>
</div>
);
}

export default Experiment;

Result:

A server side component rendering different treatments

Getting treatments client side

Ideally, treatments should all be loaded server side, and then passed down to client side rendered components as props. But with NextJS 13, we can just as easily load our flags on the client. We wrote this nifty getSplits function, so we’re going to reuse this. First we’re going to create an API route that our client side code can call, and this API route will call the getSplits function. Then we will use SWR to make a call to our API route. An added benefit here is that the API key remains on the server.

Let’s start by creating the route:

// src/app/api/split/route.ts
import getSplits from "@/split/getSplits";

import { NextResponse } from "next/server";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const splits = searchParams.getAll("split");
if (splits.length === 0) {
return NextResponse.json(
{ message: "No splits specified" },
{ status: 400 }
);
}

const treatments = await getSplits(splits);
return NextResponse.json(treatments);
}

In this route, we assume that we can receive one or more splits. We don’t need to worry about the user here as the getSplits method will pull it from the headers. We can test that this works by opening a browser to the url http://localhost:3000/api/split?split=tortoise&split=test_split

Our API route returns the treatments for a given list of splits

Then we can use SWR to call this API (we can also use React Query, that’s fine too). We’re going to use a custom hook to encapsulate the logic handling the api call:

pnpm install swr
// src/split/useSplits.ts

import useSWR from "swr";

const fetcher = (splits: string[] | string) => () => {
const queryString = new URLSearchParams(
([] as string[]).concat(splits).map((s) => ["split", s])
).toString();
return fetch(`/api/split?${queryString}`).then((res) => res.json());
};

const useSplits = (splits: string[] | string) =>
useSWR(splits, fetcher(splits));

export default useSplits;

To make sure this code is executed on the client side, we use the 'use client'; directive in the component, and call the useSplits hook.

// src/components/MyExperiment.tsx

"use client";

import useSplits from "@/split/useSplits";

function MyExperiment() {
const { data, error, isLoading } = useSplits(["tortoise", "test_split"]);

if (isLoading) return <div>Loading...</div>;

if (error) return <div>Error: {error.message}</div>;

const { tortoise, test_split } = data;

return (
<div>
Experiment (client side):
<div>Tortoise is {tortoise}</div>
<div>Test_split is {test_split}</div>
</div>
);
}

export default MyExperiment;

Finally, we can use the component in our page:

// src/app/experiments/page.tsx

import MyExperiment from "@/components/MyExperiment";
import getSplits from "@/split/getSplits";

async function Experiment() {
const { tortoise, test_split } = await getSplits(["tortoise", "test_split"]);

return (
<div>
Experiment:
<div>Tortoise is {tortoise}</div>
<div>Test_split is {test_split}</div>
<MyExperiment />
</div>
);
}

export default Experiment;
A page displaying the values of treatments resolved server side and client side

When loading this page, users will notice a brief “Loading…” message which is expected as in this case, we need to wait for the network request to resolve.

Using Split.io’s React SDK

We’ve seen how to handle feature flags by fetching treatments server side, and how to do the same operation on the client using SWR and an API route. There is an alternative for the client side which doesn’t involve making our own network requests or creating an API route, and that is using Split.io’s React SDK. This time we will use a browser API key, which can be retrieved in Split’s dashboard.

We’ll store the key in the .env.local file, just like we did for the server:

SPLIT_API_KEY=<paste server key here>
CLIENT_SPLIT_API_KEY=<paste client key here>

We can now access this variable in our pages. The React SDK uses a Provider at the top, but this won’t work in our pages which are server side components, so we need to create our own Provider as a client side component, and implement the logic pertaining to the React SDK there.

// src/app/providers/SplitProvider.tsx

"use client";

import React from "react";
import { SplitFactory } from "@splitsoftware/splitio-react";

function SplitProvider({
children,
authorizationKey = "",
}: {
children: React.ReactNode;
authorizationKey: string | undefined;
}) {
const splitKey: string =
document.cookie
.split("; ")
.find((row) => row.startsWith("splitKey"))
?.split("=")[1] || "anonymous-user";

const sdkConfig: SplitIO.IBrowserSettings = {
core: {
authorizationKey,
key: splitKey,
},
};

return (
<SplitFactory config={sdkConfig}>
<>{children}</>
</SplitFactory>
);
}

export default SplitProvider;

Using the 'use client' directive, we tell NextJS that this component will execute client side. We retrieve the cookie and set it in the SDK configuration. The API key is retrieved from the props. We can also use the excellent react-use and useCookie to fetch the cookie. Finally, the SplitFactory is instantiated, ensuring the Context of the SDK is available through the application. Another cool thing about NextJS is that server components can be nested in client components, so it’s not because this Provider is set as a client component that all its children are necessarily client components as well!

Now we can use the hook from the Split.io SDK:

// src/components/MyOtherExperiment.tsx

"use client";

import React from "react";
import { useTreatments, SplitContext } from "@splitsoftware/splitio-react";

function MyOtherExperiment() {
const { isReady } = React.useContext(SplitContext);
const treatments = useTreatments(["tortoise", "test_split"]);

if (!isReady) return <div>Loading...</div>;

const { tortoise, test_split } = treatments;

return (
<div>
My Other Experiment (client side):
<div>Tortoise is {tortoise.treatment}</div>
<div>Test_split is {test_split.treatment}</div>
</div>
);
}

export default MyOtherExperiment;

This can be included in our page:

// src/app/experiment/page.tsx

import MyExperiment from "@/components/MyExperiment";
import MyOtherExperiment from "@/components/MyOtherExperiment";
import getSplits from "@/split/getSplits";

async function Experiment() {
const { tortoise, test_split } = await getSplits(["tortoise", "test_split"]);

return (
<div>
Experiment:
<div>Tortoise is {tortoise}</div>
<div>Test_split is {test_split}</div>
<MyExperiment />
<MyOtherExperiment />
</div>
);
}

export default Experiment;

Just like with the other client side component, we need to handle the waiting state, this is done using the isReady flag from the SplitContext.

And that’s basically it, we’ve seen three different ways to use Split.io in a NextJS 13 application! Please leave any questions in the comments.

--

--

Arnaud Dostes

Previously Paris and Geneva, currently New York. Can also be found at https://dev.to/arnaud