little-balloon-64875
09/10/2025, 5:36 AM@growthbook/growthbook Node SDK alongside Segment to make a node call when the middleware loads. It fetches or creates an ID (at this point matching with Segment's anonymous ID), then sets up Growthbook, gets the features/experiments, and then performs the redirect. However I'm only getting about half of the traffic redirecting to the other URL. Code in ๐งตlittle-balloon-64875
09/10/2025, 5:38 AMimport { GrowthBook } from "@growthbook/growthbook";
import { configureServerSideGrowthBook } from "@lib/growthbook-server";
import { Analytics } from "@segment/analytics-node";
import { NextRequest, NextResponse } from "next/server";
export async function handleGrowthBookExperiments(
request: NextRequest,
stableId: string,
analytics: Analytics,
): Promise<NextResponse | null> {
configureServerSideGrowthBook();
const gb = new GrowthBook({
clientKey: process.env.GROWTHBOOK_CLIENT_KEY,
enableDevMode: true,
});
await gb.init({ timeout: 1000 });
await gb.setAttributes({
id: stableId,
url: request.url,
query: request.nextUrl.search,
host: request.nextUrl.hostname,
path: request.nextUrl.pathname,
});
if (["/features", "/features-overview"].includes(request.nextUrl.pathname)) {
const featureFlagId = "features-overview-redirect";
const variation = gb.getFeatureValue(featureFlagId, false);
if (request.nextUrl.pathname === "/features" && variation) {
const url = request.nextUrl.clone();
url.pathname = "/features-overview";
gb.destroy();
return NextResponse.redirect(url, {
status: 302,
});
}
analytics.track({
anonymousId: stableId,
event: "Experiment Viewed",
properties: {
experimentId: "features-overview-redirect-test",
variationId: Number(variation).toString(),
},
} as any);
}
gb.destroy();
return null;
}
I'm not really sure what I'm missing here. I swear I've read every ounce of the documentation five times over ๐
little-balloon-64875
09/10/2025, 5:58 AMimport { Analytics } from "@segment/analytics-node";
import { getStableId } from "@utils/get-stable-id";
import { handleGrowthBookExperiments } from "@utils/middleware/growthbook";
import { NextRequest, NextResponse } from "next/server";
export default async function middleware(request: NextRequest) {
let response = NextResponse.next();
const analytics = new Analytics({
writeKey: process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY,
});
// Get unified stable ID that consolidates all ID systems
const { value: stableId, isFresh } = await getStableId();
if (isFresh || !request.cookies.get("ajs_anonymous_id")) {
response.cookies.set("ajs_anonymous_id", stableId);
}
// Use unified stable ID for GrowthBook UUID (backward compatibility)
if (isFresh || !request.cookies.get("gbuuid")) {
response.cookies.set({
name: "gbuuid",
value: stableId,
httpOnly: false,
secure: true,
path: "/",
expires: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000),
});
}
// Process GrowthBook experiments
const experimentResponse = await handleGrowthBookExperiments(
request,
stableId,
analytics,
);
if (experimentResponse) {
return experimentResponse;
}
return response;
}
export const config = {
matcher: "/((?!api|static|.*\\..*|_next).*)",
};
then how we're doing IDs:
import { cookies, headers } from "next/headers";
import { dedupe } from "flags/next";
import { customAlphabet } from "nanoid";
// Use the same custom alphabet as current middleware for backward compatibility
const customNanoid = customAlphabet('123456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', 24);
/**
* Unified stable ID that consolidates all existing ID systems
* Priority order: header > ajs_anonymous_id > gbuuid > new ID
*/
export const getStableId = dedupe(async () => {
const cookiesStore = await cookies();
const header = await headers();
// 2. Check Segment anonymous ID (backward compatibility)
const segmentId = cookiesStore.get("ajs_anonymous_id")?.value;
if (segmentId) {
return { value: segmentId, isFresh: false };
}
// 3. Check GrowthBook UUID (backward compatibility)
const gbId = cookiesStore.get("gbuuid")?.value;
if (gbId) {
return { value: gbId, isFresh: false };
}
// 4. Generate new unified ID using same format as existing systems
return { value: customNanoid(), isFresh: true };
});little-balloon-64875
09/10/2025, 6:09 AMlittle-balloon-64875
09/10/2025, 6:24 AMstrong-mouse-55694
09/11/2025, 6:57 PM/features and then either continue to that URL or be redirected to /features-overview depending on the experiment result?
Isn't the expected result that half of the traffic would redirected and half not? Or what am I missing?
Oh, and Flags isn't a requirement.strong-mouse-55694
09/11/2025, 6:59 PMlittle-balloon-64875
09/16/2025, 10:02 AMexport default async function middleware(request: NextRequest) {
const headers = new Headers(request.headers);
// Get unified stable ID that consolidates all ID systems
const { value: stableId } = await getStableId();
let response = NextResponse.next();
response.headers.set("x-generated-stable-id", stableId);
const urlPath = new URL(request.url);
const analytics = new Analytics({
writeKey: process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY,
});
if (!request.cookies.get("ajs_anonymous_id")) {
response.cookies.set({
name: "ajs_anonymous_id",
value: stableId,
httpOnly: false,
secure: true,
path: "/",
expires: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000),
});
}
// Use unified stable ID for GrowthBook UUID (backward compatibility)
if (!request.cookies.get("gbuuid")) {
response.cookies.set({
name: "gbuuid",
value: stableId,
httpOnly: false,
secure: true,
path: "/",
expires: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000),
});
}
...rest of middleware
Then for the stable ID:
import { cookies, headers } from "next/headers";
/**
* Unified stable ID that consolidates all existing ID systems
* Priority order: header > ajs_anonymous_id > gbuuid > new ID
*/
export const getStableId = async () => {
const cookiesStore = await cookies();
const headersStore = await headers();
// First we check for header from previous middleware execution (for redirects)
// This might happen because of a split URL redirection where
// the stable ID needs to be passed on, but the response cookies
// may not be set or haven't been able to be set.
// We pass this stable ID header through and then here we
// check for that ID. If it exists, we can properly set up all
// the Segment and growthbook cookies
const headerStableId = headersStore.get("x-generated-stable-id");
if (headerStableId) {
return { value: headerStableId };
}
// If we make it this far it means the header was not set, so then
// we check for the segment ID which is stored as a cookie
const segmentId = cookiesStore.get("ajs_anonymous_id")?.value;
if (segmentId) {
return { value: segmentId };
}
// If we make it this far it means neither the header nor the segment ID was set as a cookie, so then
// we check for the growthbook ID which is stored as a cookie
const gbId = cookiesStore.get("gbuuid")?.value;
if (gbId) {
return { value: gbId };
}
// Of course if there is no header, no Segment anon ID, or
// a GB UUID, we generate a new one
return { value: crypto.randomUUID() };
};
This sets the Segment anon ID and the GB UUID as the same (which as I understand isn't totally necessary, but that's how it's going), and now if there is a redirect we have to pass in the response with the new stable ID header in order to pass that on to the redirected URL
import { GrowthBook } from "@growthbook/growthbook";
import { configureServerSideGrowthBook } from "@lib/growthbook-server";
import { Analytics } from "@segment/analytics-node";
import { NextRequest, NextResponse } from "next/server";
export async function handleGrowthBookExperiments(
request: NextRequest,
stableId: string,
analytics: Analytics,
response: NextResponse,
): Promise<NextResponse | null> {
configureServerSideGrowthBook();
const gb = new GrowthBook({
clientKey: process.env.GROWTHBOOK_CLIENT_KEY,
enableDevMode: true,
});
await gb.init({ timeout: 1000 });
await gb.setAttributes({
id: stableId,
url: request.url,
query: request.nextUrl.search,
host: request.nextUrl.hostname,
path: request.nextUrl.pathname,
});
if (["/features", "/features-overview"].includes(request.nextUrl.pathname)) {
const featureFlagId = "test-feature-flag";
const result = gb.evalFeature(featureFlagId);
if (!result.experimentResult) {
return null;
}
analytics.track({
anonymousId: stableId,
event: "Experiment Viewed",
properties: {
experimentId: "test-feature-experiment",
variationId: result.experimentResult.variationId,
},
} as any);
if (
request.nextUrl.pathname === "/features" &&
result.experimentResult.variationId === 1
) {
const url = request.nextUrl.clone();
url.pathname = "/features-overview";
const newResponse = NextResponse.redirect(url, {
status: 302,
headers: response.headers,
});
return newResponse;
} else if (
request.nextUrl.pathname === "/features-overview" &&
result.experimentResult.variationId === 0
) {
const url = request.nextUrl.clone();
url.pathname = "/";
const newResponse = NextResponse.redirect(url, {
status: 302,
headers: response.headers,
});
return newResponse;
}
}
gb.destroy();
return null;
}little-balloon-64875
09/16/2025, 10:05 AMlittle-balloon-64875
09/16/2025, 10:27 AMlittle-balloon-64875
09/16/2025, 11:36 AMlittle-balloon-64875
09/17/2025, 1:42 PMvariation_id | unique_users | percentage
0 58883 49.40
1 60309 50.60
It's firing events pretty consistently but is obviously over-assigning to the variant.little-balloon-64875
09/17/2025, 1:48 PMstrong-mouse-55694
09/17/2025, 2:21 PM/features , you track the exposure, then redirect them to the /features-overview where they're tracked again. Is that how the code's working?
Can users land on /features-overview first? Or do they always go through /features first?little-balloon-64875
09/17/2025, 3:36 PM_const_ result = gb.evalFeature(featureFlagId);, then based on the result of result.experimentResult.variationId it either keeps them on the page or redirects them. So if variationId is 0 but you're on /features-overview, it's redirecting, and vice versa. However the bucketing has already happened by that point.little-balloon-64875
09/17/2025, 3:38 PMevalFeature, then that's what's passed through to Segment regardless of the redirection logic. I checked the database and there's no user that has both variants logged to their ID.little-balloon-64875
09/17/2025, 3:46 PMgb?strong-mouse-55694
09/17/2025, 7:10 PM/features-overview. Won't the analytics then fire again?little-balloon-64875
09/18/2025, 6:33 AMevalFeature is doing what it's supposed to, then the variation ID will be 1 in both instances, which is de-duped later anyways as a single user.little-balloon-64875
09/18/2025, 12:18 PMlittle-balloon-64875
09/19/2025, 4:43 AM