Handling anonymous users

Overview

Sometimes you need to roll out features, run experiments (or AI loops), log analytics events, or tune configuration for anonymous users, rather than logged-in users.

Hypertune is designed for flexibility so you're not constrained to a baked-in User type. Instead you can define your own input types in your schema, and then target features and run experiments on any kind of unit.

A unit can be a logged-in user, an anonymous user (i.e. a device), an IP address, a wallet address, a server, a business, or any other entity you choose.

You can set the Unit ID on each Split Control. By default, this is set to context.user.id. Hypertune will use the Unit ID to randomly assign units to arms of your split. For test splits, this assignment is stable so the same unit will always be assigned to the same arm.

When you build an analytics funnel to view results, you can set the Unit ID of each event step. This enables Hypertune to join each step of your funnel together correctly, across splits and events.

Setup

To handle anonymous users, first add an anonymousId field to your User input type:

input User {
  id: String!
  anonymousId: String!
  name: String!
  email: String!
}

Create setAnonymousIdIfNeeded and getAnonymousId helper functions:

lib/anonymousId.ts
import "server-only";

import { NextResponse, type NextRequest } from "next/server";
import {
  ResponseCookies,
  RequestCookies,
} from "next/dist/server/web/spec-extension/cookies";
import { nanoid } from "nanoid";
import { cookies } from "next/headers";

const anonymousIdCookieName = "hypertuneAnonymousId";

export function setAnonymousIdIfNeeded(
  req: NextRequest,
  res: NextResponse
): void {
  const cookie = req.cookies.get(anonymousIdCookieName);

  if (cookie) {
    console.log(`${anonymousIdCookieName} already set to:`, cookie.value);
    return;
  }

  const newAnonymousId = nanoid();

  res.cookies.set({
    name: anonymousIdCookieName,
    value: newAnonymousId,
    domain:
      process.env.NODE_ENV === "development" ? undefined : "yourdomain.com",
    path: "/",
  });

  console.log(`${anonymousIdCookieName} set to:`, newAnonymousId);

  // Apply the new cookie to the request
  applySetCookie(req, res);
}

/**
 * Copy cookies from the Set-Cookie header of the response to the Cookie header
 * of the request so that it will appear to SSR/RSC as if the user already has
 * the new cookies.
 */
function applySetCookie(req: NextRequest, res: NextResponse): void {
  // 1. Parse Set-Cookie header from the response
  const setCookies = new ResponseCookies(res.headers);

  // 2. Construct updated Cookie header for the request
  const newReqHeaders = new Headers(req.headers);
  const newReqCookies = new RequestCookies(newReqHeaders);
  setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie));

  // 3. Set up the "request header overrides" on a dummy response
  // See https://github.com/vercel/next.js/pull/41380
  // NextResponse.next will set x-middleware-override-headers and
  // x-middleware-request-* headers
  const dummyRes = NextResponse.next({ request: { headers: newReqHeaders } });

  // 4. Copy the "request header overrides" headers from the dummy response to
  // the real response
  dummyRes.headers.forEach((value, key) => {
    if (
      key === "x-middleware-override-headers" ||
      key.startsWith("x-middleware-request-")
    ) {
      res.headers.set(key, value);
    }
  });
}

export async function getAnonymousId(): Promise<string | null> {
  return (await cookies()).get(anonymousIdCookieName)?.value ?? null;
}

Call setAnonymousIdIfNeeded in middleware:

import { NextResponse, type NextRequest } from "next/server";
import { setAnonymousIdIfNeeded } from "./lib/anonymousId";

export const config = {
  matcher: "/",
};

export function middleware(req: NextRequest): NextResponse {
  const res = NextResponse.next();

  setAnonymousIdIfNeeded(req, res);

  return res;
}

Finally, pass the anonymous ID to Hypertune:

lib/getHypertune.ts
import "server-only";
import { unstable_noStore as noStore } from "next/cache";
import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { RootNode, createSource } from "@/generated/hypertune";
import { getAnonymousId } from "./anonymousId";

const hypertuneSource = createSource({
  token: process.env.NEXT_PUBLIC_HYPERTUNE_TOKEN!,
});

export default async function getHypertune(args?: {
  headers: ReadonlyHeaders;
  cookies: ReadonlyRequestCookies;
}): Promise<RootNode> {
  noStore();
  await hypertuneSource.initIfNeeded(); // Check for flag updates

  const anonymousId = (await getAnonymousId()) ?? "";

  return hypertuneSource.root({
    args: {
      context: {
        env:
          process.env.NODE_ENV === "development" ? "DEVELOPMENT" : "PRODUCTION",
        user: { id: "1", anonymousId, name: "Test", email: "[email protected]" },
      },
    },
  });
}

Now you can set context.user.anonymousId as the Unit ID for:

  • Split Controls with a split that you want to run on anonymous users

  • Event steps in funnels when viewing analytics for anonymous users

Last updated