Feature Flags in Next.js with iron-session

Server side, Middleware and API route feature flags in Next.js with iron-session

By Anton Ball

Feature Flags are a powerful technique, allowing us to toggle features during testing, gradually roll out features without disrupting deployment, test new ideas on a subset of users, and fine-tune the user experience with A/B testing.

As of writing, Next.js lacks built-in support for Feature Flags, but they can be implemented in a myriad of ways, leveraging third-party tools. Our use case is to control page and feature visibility to A/B test ideas and understand how they perform. In this post, we’ll share our approach and experience implementing Feature Flags in Next.js using third-party libraries.

For our Next.js sites, we don’t use client-side navigation, e.g. Next’s Link component and client-side hydration. There are reasons for this, including localisation and data fetching, but it meant we couldn’t maintain flag state between pages like you otherwise might. This difference also excluded access to the Web Storage API options, like local and session storage, as we need to check the flag state in Middleware and Next API routes.

Using Next.js’ getServerSideProps we could have initialised the flag service and evaluated the flags like in this contrived example:

export async function getServerSideProps(ctx) {
    const flags = await FlagService.initialise({})
    if (!flags.betaPage.evaluate()) {
        return {
            notFound: true,
        }
    }
    return {}
}

This could work for just feature flags, but it wasn’t ideal for a few reasons:

So we had some challenges, but we knew the goal. Minimise calls to the flag service while maintaining the flag state for the session and beyond with A/B tests.

Session storage

Without the options for storing and maintaining the state outlined above, we started to investigate Next.js session storage libraries. Next-Session and Iron-Session are two of the popular options that integrate well with Next.js.

We tested both options and decided that Iron-Session better fit our needs:

Iron-Session setup

With the choice made, here’s how we set up Iron-session in our Next.js sites.

First up is to install the dependencies so we can use them.

npm install -—save iron-session

If we update our contrived example from earlier, we can see how Iron-Session wraps getServerSideProps’s handler and introduces the session object on the request.

import { withIronSessionSsr } from 'iron-session/next'

export const getServerSideProps = withIronSessionSsr(
    async function getServerSideProps({ req }) {
        // session is now available
        const flags = req.session.flags

        if (!flags.betaPage) {
            return {
                notFound: true,
            }
        }

        return {}
    },
    {
        // iron-session configuration
    },
)

Let’s start taking this beyond a contrived example and into a working solution. We need to initialise the flag service, evaluate the flags and store them in the session, and they must be serialised because we are storing them on the cookie. Therefore, the flag needs to be evaluated before storage.

Feature flag session

We decided to create a Feature Flag session function that would handle initialising our flag service, evaluating flags against the session and expiration (more on why that’s important in a bit).

We have abstracted the feature flag service out for the situation where we change providers. In that scenario, we need to support the initFeatureFlags call and checkFlagState and not worry about the specific details of each service.

Here’s what our Feature Flag session method looks like:

// Flag service is an abstraction for whatever flag service provider you're using to keep a consistent API
import { checkFlagState, initFeatureFlags } from './flag-service'

export async function featureFlagSession(
    session: IronSession,
    {
        evaluateFlags = [],
        evaluateABTests = [],
    }: { evaluateFlags?: FlagKeys[]; evaluateABTests?: ABTestKeys[] },
) {
    try {
        await initFeatureFlags()
    } catch (err) {
        // flag service failed to initialise
        // user will receive the default values
    }

    // Determine if the expiry has passed and if not return the flags from the store, otherwise an empty object
    const flagsExpired = session.flagExpiry ? session.flagExpiry < Date.now() : true
    const sessionFlagState: FlagSession = !flagsExpired && session.flags ? session.flags : {}
    const abTestFlags = session.abTestFlags ?? {}

    // If there are flags to evaluate, that aren't already in our store, do so against the service
    if (evaluateFlags) {
        for (const flag of evaluateFlags) {
            if (sessionFlagState[flag] === undefined) {
                sessionFlagState[flag] = checkFlagState(flag)
            }
        }
    }
    // If there are A/B tests to evaluate that aren't already in the store, do so against the flag service
    if (evaluateABTests) {
        for (const flag of evaluateABTests) {
            if (abTestFlags[flag] === undefined) {
                abTestFlags[flag] = checkFlagState(flag)
            }
        }
    }
    // Add the evaluated flags to the session
    session.flags = sessionFlagState
    session.abTestFlags = abTestFlags
    // If the flags have expired, set a new expiry date for one hour
    if (flagsExpired) {
        session.flagExpiry = Date.now() + 3600000 // 60,000 * 60 // 1 hour in ms
    }
}

Our flag session has the following params:

We could then use the Flag session within our getServerSideProps handler.

import { withIronSessionSsr } from 'iron-session/next'

export const getServerSideProps = withIronSessionSsr(
    async function getServerSideProps({ req }) {
        featureFlagSession(req.session)
        const flags = req.session.flags

        if (!flags.betaPage) {
            return {
                notFound: true,
            }
        }

        return {}
    },
    {
        // iron-session configuration
    },
)

Expiry

You’ll notice that our Feature Flags have an expiry of 1 hour. We needed to do this because Feature Flags should be flexible, balancing the need to cache and update them. While once a user is assigned an A/B test group, they should remain in that group.

Iron-Session supports a single session store. While they can expire the cookie after some time, we couldn’t do that for the A/B tests because users would be reassigned to a different test group.

To work around that, we added an expiry to the flag session. When that time passes, we will ensure that we reevaluate the flags against the service.

We use an hour but will adjust this as we go and find an optimal value.

Session wrappers

What this approach hasn’t solved is that we still need to handle the session in each route’s getServerSideProps handler. Including adding the iron-session configuration! This could definitely lead to bugs.

Ircon-Session has a recommendation for the setup called Session Wrappers, which are like handlers in Next.js for getServerSideProps and API Routes, and that’s what we will implement next to tie this all together.

export function withSessionSsr<P extends { [key: string]: unknown } = { [key: string]: unknown }>(
    handler: (
        context: GetServerSidePropsContext,
    ) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>,
    {
        evaluateFlags,
        evaluateABTests,
    }: { evaluateFlags?: FlagKeys[]; evaluateABTests?: ABTestKeys[] } = {},
) {
    return withIronSessionSsr(
        async function (context) {
            await featureFlagSession(context.req.session, {
                evaluateFlags,
                evaluateABTests,
            })
            await context.req.session.save()
            return handler(context as GetServerSidePropsContext)
        },
        {
            // session options - this is where you'll have your passkey and other configuration for iron-session
        },
    )
}

The Session Wrapper will wrap the getServerSideProps handler like what withIronSessionSsr is doing now

export const getServerSideProps = withSessionSsr(
    async function getServerSideProps(ctx) {
        const featureFlags = featureFlags: context?.req?.session?.flags ?? {},
        if (!featureFlags?.betaPage) {
            return {
                notFound: true,
            }
        }

        return {
            props: {},
        }
    },
    {
        evaluateFlags: ['betaPage'],
    },
)

The SessionWrapper handles configuring Iron-Session, so we don’t have to each route and accepts params for evaluating flags and A/B tests and stores the data on the session by calling save. We can then access the flags on the session and continue using getServerSideProps as we do now.

We can add our feature flags to React Context if we want Client Side access via Next’s custom App. Most of the time, we found that validating within the data loading is sufficient.

Using a similar setup, we can also check the flags in Middleware and Next API Routes. There’s more information in Iron-Session’s documentation but here’s a Middleware example:

export const middleware = async (req: NextRequest) => {
  const res = NextResponse.next();
  const session = await getIronSession(req, res, {
    // iron-session config
  });

  const { flags } = session;

  if (!flags?.betaPage) {
    return NextResponse.redirect(new URL('/home', req.url))
  }

  return res;
};

Wrapping up

And there we have it. We have feature flags within Next.js that we can access across pages, API Routes and Middleware without repeatedly calling a Flag Service. The approach worked well for our needs but wasn’t without tradeoffs.

As mentioned, if we are AB testing, we have locked them into a group. Typically this has been okay, but you lose some of the flexibility of flipping an AB test group all to true or false. We need to deploy this change. I am okay with being strict with A/B tests this way; we should use tests for a short period, make decisions, and move on, not leave them sitting around indefinitely.

There is a maximum size that a cookie can be, which is 4096 bytes. We have been okay with the small amount of information used in the session store, but it’s worth keeping that limit in mind if you start to go beyond a few feature flags.

Third and as discussed in the expiry section, having multiple session stores would have been nice. It would have simplified our approach between A/B tests and Feature Flags expiring by setting the expiry on the feature flag storage cookie instead. There are issues and discussions in the iron-session repo regarding this idea, and the maintainers explain well why it wasn’t done. We worked around it, as shown above, but it would still be nice.

This approach has worked well for Doist’s Feature Flags and A/B test requirements. We have had it in place for several months now. We use it frequently to test pages internally, limit access to certain site areas, and A/B test different marketing approaches to understand the most successful direction.

It would be great to see a native feature flag solution from the Next.js team, but until then, iron-session and a feature flag service are covering our needs nicely.