Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions app/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { cookies, headers } from "next/headers";
import { Home } from "@/components/home";
import {
HERO_SLOGAN_VARIANT_HEADER,
HERO_SLOGAN_VARIANT_KEY,
getHeroSloganVariantFromCookieValue,
} from "@/lib/hero-slogan-variant";

export default function HomePage() {
return <Home />;
export default async function HomePage() {
const [cookieStore, headerStore] = await Promise.all([
cookies(),
headers(),
]);
const heroSloganVariant = getHeroSloganVariantFromCookieValue(
headerStore.get(HERO_SLOGAN_VARIANT_HEADER) ??
cookieStore.get(HERO_SLOGAN_VARIANT_KEY)?.value,
);

return <Home heroSloganVariant={heroSloganVariant} />;

Check failure on line 19 in app/(home)/page.tsx

View check run for this annotation

Claude / Claude Code Review

build:static breaks: cookies()/headers() + middleware incompatible with output:"export"

`pnpm build:static` (package.json:16, used for the Cloudflare static-export build path with maintained `lib/_headers` + `scripts/generate-cloudflare-redirects.js` infrastructure) breaks after this PR: under `STATIC_EXPORT=true` → `output: "export"` (next.config.mjs:43), Next.js does not support `middleware.ts`, and calling `cookies()`/`headers()` in `app/(home)/page.tsx` unconditionally opts the route into dynamic rendering — which the export build rejects ("`headers` was called in a route that
Comment on lines +9 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 pnpm build:static (package.json:16, used for the Cloudflare static-export build path with maintained lib/_headers + scripts/generate-cloudflare-redirects.js infrastructure) breaks after this PR: under STATIC_EXPORT=trueoutput: "export" (next.config.mjs:43), Next.js does not support middleware.ts, and calling cookies()/headers() in app/(home)/page.tsx unconditionally opts the route into dynamic rendering — which the export build rejects ("headers was called in a route that is statically generated"). The active Vercel/CI path uses plain next build and is unaffected, but the static path is silently broken. Fix by either gating the dynamic code on process.env.STATIC_EXPORT !== "true" (with a deterministic default variant for static builds, plus an analogous matcher/no-op in middleware.ts), or by removing build:static + the supporting Cloudflare scripts if that target is retired.

Extended reasoning...

What the bug is

The repo ships two build paths:

  1. pnpm build — plain next build, used by Vercel and CI.
  2. pnpm build:staticcross-env STATIC_EXPORT=true ... next build && pnpm run postbuild:static (package.json:16), which next.config.mjs:43-48 wires to output: "export". The postbuild step runs scripts/generate-cloudflare-redirects.js and cp lib/_headers out/ — these are Cloudflare Pages convention artifacts (lib/_headers literally contains "Static exports on Cloudflare should not be indexed"). next-sitemap.config.js and next.config.mjs both also branch on STATIC_EXPORT === "true" (sitemap excludes, images.unoptimized), so this is deliberately maintained infrastructure, not dead code.

This PR introduces two features that are explicitly incompatible with output: "export":

  • A new top-level middleware.ts. Next.js docs explicitly list Middleware as unsupported in static export — there is no Node/Edge runtime to execute it, and the build refuses to bundle it.

  • app/(home)/page.tsx now calls cookies() and headers() unconditionally:

    const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);

    These are dynamic Server APIs; calling them in a Server Component opts the route into dynamic rendering, which output: "export" rejects at build time with "headers was called in a route that is statically generated".

Why existing code doesn't prevent it

There is no STATIC_EXPORT gate in either the page or the middleware. middleware.ts exports a matcher of "/" unconditionally, and the page imports/awaits cookies()/headers() on every render path. Before this PR no file in the repo imported from next/headers and there was no middleware — grep-ing the tree confirms the static build was working.

Step-by-step proof

  1. Run pnpm build:static. The script sets STATIC_EXPORT=true and invokes next build.
  2. next.config.mjs:43 reads process.env.STATIC_EXPORT === "true" and merges { output: "export", trailingSlash: true, distDir: "out" } into the config.
  3. Next.js begins compiling. It sees middleware.ts at the project root and emits an error: middleware is not supported with output: "export".
  4. Even if middleware were silently dropped, the page render fails: app/(home)/page.tsx is a Server Component for a route that the export build attempts to statically render. When it hits await headers() / await cookies(), Next.js opts the route into dynamic rendering. With output: "export" set, the build aborts with the static-rendering-required error.
  5. Result: build:static exits non-zero before postbuild:static ever runs, so the Cloudflare out/ directory is never produced.

Impact

CI (pnpm build) and the Vercel production deploy use the regular build path and are unaffected — this is why no verifier flagged this as merge-blocking on the active deploy. But the static export path is a real, maintained alternate target (Cloudflare Pages noindex mirror), and this PR silently breaks it. Anyone who runs pnpm build:static locally or in a separate deploy job will get a build failure that has nothing to do with their change. There is also a secondary semantic concern: even if it built, the prerendered HTML would freeze a single variant chosen at build time and never rotate per user — defeating the A/B test for the static deployment.

How to fix

Either:

  • Gate the dynamic codepath. In app/(home)/page.tsx, check process.env.STATIC_EXPORT === "true" and short-circuit to a deterministic default variant (e.g. <Home heroSloganVariant="ai-engineering" />) without calling cookies()/headers(). In middleware.ts, either gate the matcher to be empty under STATIC_EXPORT or accept that middleware will be dropped (Next.js will still error unless the file is excluded from the build — easiest is to move the middleware behind a build-time check or rename it conditionally via a prebuild script).
  • Remove the static path. If the Cloudflare mirror is retired, delete build:static + postbuild:static from package.json, drop STATIC_EXPORT branches from next.config.mjs and next-sitemap.config.js, remove scripts/generate-cloudflare-redirects.js and lib/_headers. Either way the codebase becomes coherent.

}
52 changes: 8 additions & 44 deletions components/home/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,24 @@
import { Button } from "@/components/ui/button";
import { CornerBox } from "@/components/ui/corner-box";
import { Heading } from "@/components/ui/heading";
import { Text } from "@/components/ui/text";
import { TextHighlight } from "@/components/ui";
import { HomeSection } from "@/components/home/HomeSection";
import { EnterpriseLogoGrid } from "@/components/shared/EnterpriseLogoGrid";
import { cn } from "@/lib/utils";
import { HeroStatsStrip } from "@/components/home/HeroStatsStrip";
import { HeroSlogan } from "@/components/home/HeroSlogan";
import type { HeroSloganVariant } from "@/lib/hero-slogan-variant";

export function Hero() {
export function Hero({
heroSloganVariant,
}: {
heroSloganVariant: HeroSloganVariant;
}) {
return (
<HomeSection className="pt-5 sm:pt-8 md:pt-[60px]">
<CornerBox className="-mb-px -mt-px">
<HeroStatsStrip />
</CornerBox>
<CornerBox className="flex flex-col gap-4 sm:gap-8 md:gap-10 items-center px-4 py-8 sm:px-8 sm:py-10">
<Heading
as="h1"
size="big"
className={cn(
"flex-col items-center gap-0.5 sm:gap-1 md:gap-1.5 text-center font-medium leading-[105%] max-md:max-w-[500px]",
"[leading-trim:both] [text-edge:cap]",
)}
>
<TextHighlight
highlightClassName="mix-blend-multiply"
className="whitespace-nowrap"
>
Open Source<span className="inline max-[499px]:hidden">&nbsp;</span>
</TextHighlight>
<span className="flex min-[500px]:inline">
<TextHighlight
highlightClassName="mix-blend-multiply"
className="max-[499px]:pr-1.75"
>
AI
</TextHighlight>
<TextHighlight
highlightClassName="mix-blend-multiply"
className="min-[500px]:pr-2"
>
Engineering
</TextHighlight>
</span>
<TextHighlight highlightClassName="mix-blend-multiply">
Platform
</TextHighlight>
</Heading>
<Heading
as="h1"
size="big"
className={cn(
"flex sm:hidden flex-col items-center gap-1.5 text-center font-medium leading-[105%]",
"[leading-trim:both] [text-edge:cap]",
)}
></Heading>
<HeroSlogan initialVariant={heroSloganVariant} />
<div className="flex flex-col gap-6">
<Text className="max-w-xl">
Trace and evaluate AI Agents. Collaborate with your team to
Expand Down
167 changes: 167 additions & 0 deletions components/home/HeroSlogan.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"use client";

import { useEffect, useRef } from "react";
import { usePostHog } from "posthog-js/react";
import { Heading } from "@/components/ui/heading";
import { TextHighlight } from "@/components/ui";
import { cn } from "@/lib/utils";
import { usePostHogClientCapture } from "@/src/usePostHogClientCapture";
import {
HERO_SLOGAN_VARIANT_KEY,
type HeroSloganVariant,
} from "@/lib/hero-slogan-variant";

function readStoredVariant(): HeroSloganVariant | null {
try {
const stored = localStorage.getItem(HERO_SLOGAN_VARIANT_KEY);
if (stored === "ai-engineering" || stored === "agent-evals") {
return stored;
}
} catch {
// localStorage unavailable (e.g. private browsing restrictions)
}
return null;
}

function persistVariant(variant: HeroSloganVariant) {
try {
localStorage.setItem(HERO_SLOGAN_VARIANT_KEY, variant);
} catch {
// ignore write failures
}
}

const headingClassName = cn(
"flex-col items-center gap-0.5 sm:gap-1 md:gap-1.5 text-center font-medium leading-[105%] max-md:max-w-[500px]",
"[leading-trim:both] [text-edge:cap]",
);

const desktopWordSpacing = "max-[499px]:pr-1.75 min-[500px]:pr-2";

function AiEngineeringSlogan() {
return (
<>
<TextHighlight
highlightClassName="mix-blend-multiply"
className="whitespace-nowrap"
>
Open Source<span className="inline max-[499px]:hidden">&nbsp;</span>
</TextHighlight>
<span className="flex flex-wrap justify-center min-[500px]:inline">
<TextHighlight
highlightClassName="mix-blend-multiply"
className={desktopWordSpacing}
>
AI
</TextHighlight>
<TextHighlight
highlightClassName="mix-blend-multiply"
className="min-[500px]:pr-2"
>
Engineering
</TextHighlight>
</span>
<TextHighlight highlightClassName="mix-blend-multiply">
Platform
</TextHighlight>
</>
);
}

function AgentEvalsSlogan() {
return (
<>
<TextHighlight
highlightClassName="mix-blend-multiply"
className="whitespace-nowrap"
>
Open Source
</TextHighlight>
<span className="flex flex-wrap justify-center min-[500px]:inline">
<TextHighlight
highlightClassName="mix-blend-multiply"
className={desktopWordSpacing}
>
Agent
</TextHighlight>
<TextHighlight
highlightClassName="mix-blend-multiply"
className={desktopWordSpacing}
>
Evals
</TextHighlight>
<TextHighlight
highlightClassName="mix-blend-multiply"
className={desktopWordSpacing}
>
and
</TextHighlight>
<TextHighlight highlightClassName="mix-blend-multiply">
Tracing
</TextHighlight>
</span>
</>
);
}

function trackHeroSloganExposure(
posthog: ReturnType<typeof usePostHog>,
capture: ReturnType<typeof usePostHogClientCapture>,
variant: HeroSloganVariant,
isNewAssignment: boolean,
) {
posthog.register({ hero_slogan_variant: variant });
capture("hero_slogan_exposure", {
variant,
is_new_assignment: isNewAssignment,
});
}

export function HeroSlogan({
initialVariant,
}: {
initialVariant: HeroSloganVariant;
}) {
const posthog = usePostHog();
const capture = usePostHogClientCapture();
const trackedRef = useRef(false);

useEffect(() => {
const hadLocalStorage = readStoredVariant() !== null;
persistVariant(initialVariant);

if (trackedRef.current) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 partner posthog.register may fire before PostHog has finished initialising

usePostHog() returns the PostHog instance synchronously, but the underlying SDK's async bootstrap (feature-flag fetch, session init) may not have completed when this effect runs immediately after mount. Calling posthog.register() at this point can silently succeed but the super-property may not propagate to events captured in the same tick. A safer approach is to gate the registration on posthog.isFeatureEnabled or listen to the onFeatureFlags callback, ensuring the SDK is ready before registering super-properties.

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/home/HeroSlogan.tsx
Line: 125

Comment:
**`posthog.register` may fire before PostHog has finished initialising**

`usePostHog()` returns the PostHog instance synchronously, but the underlying SDK's async bootstrap (feature-flag fetch, session init) may not have completed when this effect runs immediately after mount. Calling `posthog.register()` at this point can silently succeed but the super-property may not propagate to events captured in the same tick. A safer approach is to gate the registration on `posthog.isFeatureEnabled` or listen to the `onFeatureFlags` callback, ensuring the SDK is ready before registering super-properties.

How can I resolve this? If you propose a fix, please make it concise.

const runTracking = () => {
if (trackedRef.current) {
return;
}
trackedRef.current = true;
trackHeroSloganExposure(
posthog,
capture,
initialVariant,
!hadLocalStorage,
);
};

Check failure on line 148 in components/home/HeroSlogan.tsx

View check run for this annotation

Claude / Claude Code Review

is_new_assignment derived from localStorage but variant comes from cookie — biased analytics

🟡 `is_new_assignment` is derived from localStorage (`hadLocalStorage = readStoredVariant() !== null` in components/home/HeroSlogan.tsx:130), but after this PR variant assignment authoritatively lives in the server-side cookie set by `middleware.ts` — localStorage is now just a write-only mirror that `persistVariant(initialVariant)` overwrites. The two stores diverge in real-world cleanup scenarios (Safari ITP cookie expiry clears cookies but not localStorage; quota eviction / dev tools / extensi
Comment on lines +129 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 🟡 is_new_assignment is derived from localStorage (hadLocalStorage = readStoredVariant() !== null in components/home/HeroSlogan.tsx:130), but after this PR variant assignment authoritatively lives in the server-side cookie set by middleware.ts — localStorage is now just a write-only mirror that persistVariant(initialVariant) overwrites. The two stores diverge in real-world cleanup scenarios (Safari ITP cookie expiry clears cookies but not localStorage; quota eviction / dev tools / extensions clear localStorage but not cookies), producing false positives and false negatives on the very A/B exposure metric this PR exists to capture. Fix by computing the flag at the middleware/SSR boundary (whether the incoming cookie was already a valid variant) and threading it through as a second prop alongside initialVariant.

Extended reasoning...

What the bug is

The PR moves variant assignment from the client to the server: middleware.ts is the source of truth — it reads the langfuse-hero-slogan-variant cookie and, only if missing or invalid, calls pickHeroSloganVariant() (which uses Math.random()) and sets the cookie. app/(home)/page.tsx reads that cookie/header and passes initialVariant to HeroSlogan. The component then unconditionally calls persistVariant(initialVariant) to mirror the value into localStorage.

But is_new_assignment is still computed the old way (HeroSlogan.tsx:129-148):

const hadLocalStorage = readStoredVariant() !== null;
persistVariant(initialVariant);
// ...
trackHeroSloganExposure(posthog, capture, initialVariant, !hadLocalStorage);

hadLocalStorage is a signal about the client-side mirror, not about whether the server just minted a fresh assignment. These two stores can and do diverge.

Step-by-step proof — false positive (returning user, localStorage cleared)

  1. A returning user visits /. The browser still has the langfuse-hero-slogan-variant cookie (it has a 1-year maxAge).
  2. But they cleared localStorage (Safari ITP can clear it on a different schedule than cookies, devtools, extensions, quota eviction, private mode).
  3. middleware.ts reads the cookie, finds existing is valid, reuses it — no new assignment, cookie not re-set.
  4. page.tsx passes that same variant as initialVariant.
  5. In HeroSlogan's effect: readStoredVariant() returns null (empty localStorage) → hadLocalStorage = false → fires is_new_assignment: true.
  6. Wrong. No new assignment happened server-side.

Step-by-step proof — false negative (returning user, cookies cleared)

  1. A returning user visits / with cleared cookies but intact localStorage (localStorage["langfuse-hero-slogan-variant"] === "ai-engineering").
  2. middleware.ts sees no cookie → calls pickHeroSloganVariant() → coin-flips to e.g. "agent-evals"a genuinely new server-side assignment that may even flip the bucket.
  3. page.tsx passes "agent-evals" as initialVariant.
  4. In HeroSlogan's effect: readStoredVariant() returns "ai-engineering"hadLocalStorage = true → fires is_new_assignment: false.
  5. Wrong. Server just made a fresh assignment, and worse: persistVariant(initialVariant) immediately overwrites localStorage with "agent-evals", silently masking the flip in any future debugging.

Why existing code doesn't prevent it

There is no signal threaded from middleware → page → component about whether the cookie was a hit or a miss. The component only sees the final initialVariant string; from its perspective, every variant looks the same regardless of whether it came from a reused cookie or a fresh Math.random() call.

Impact

This biases hero_slogan_exposure.is_new_assignment in opposite directions for the two failure modes, polluting the exposure metric the PR is specifically shipping for conversion analysis. The variant itself is still correctly assigned and shown to the user — so the visible behavior is fine — but the analytics signal that decides whether the A/B test is significant is contaminated. The practical bias is bounded to users whose cookie/localStorage state has drifted, but ITP and aggressive privacy tooling make that a non-trivial slice of Safari traffic.

Fix

Compute isNewAssignment authoritatively at the layer that knows: middleware.ts already evaluates existing && isHeroSloganVariant(existing) to decide whether to reuse vs. pick. Plumb that boolean through (e.g. via a second header x-hero-slogan-new-assignment, or via the absence/presence of the existing cookie at the page layer) and pass it as a second prop isNewAssignment alongside initialVariant. Then drop the readStoredVariant / hadLocalStorage logic from the effect entirely — localStorage is now redundant with the cookie and can either be removed or kept as a pure mirror that no longer feeds analytics decisions.


if ((posthog as { __loaded?: boolean }).__loaded) {
runTracking();
return;
}

posthog.onSessionId(runTracking);
}, [capture, initialVariant, posthog]);

return (
<Heading as="h1" size="big" className={headingClassName}>
{initialVariant === "ai-engineering" ? (
<AiEngineeringSlogan />
) : (
<AgentEvalsSlogan />
)}
</Heading>
);
}
9 changes: 7 additions & 2 deletions components/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import { Enterprise } from "./Enterprise";
import { WhyLangfuse } from "./WhyLangfuse";
import { GetStartedSection } from "./GetStartedSection";
import { FAQ } from "./FAQ";
import type { HeroSloganVariant } from "@/lib/hero-slogan-variant";

export const Home = () => (
export const Home = ({
heroSloganVariant,
}: {
heroSloganVariant: HeroSloganVariant;
}) => (
<>
<main className="overflow-hidden relative w-full hero-bg xl:px-5 2xl:px-10">
<Hero />
<Hero heroSloganVariant={heroSloganVariant} />
<FeatureTabsSection />
<RiveSection />
<AllTheTools />
Expand Down
22 changes: 22 additions & 0 deletions lib/hero-slogan-variant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant";

export const HERO_SLOGAN_VARIANT_KEY = "langfuse-hero-slogan-variant";

export type HeroSloganVariant = "ai-engineering" | "agent-evals";

export function isHeroSloganVariant(value: string): value is HeroSloganVariant {
return value === "ai-engineering" || value === "agent-evals";
}

export function pickHeroSloganVariant(): HeroSloganVariant {
return Math.random() < 0.5 ? "ai-engineering" : "agent-evals";
}

export function getHeroSloganVariantFromCookieValue(
value: string | undefined,
): HeroSloganVariant {
if (value && isHeroSloganVariant(value)) {
return value;
}
return pickHeroSloganVariant();
}
38 changes: 38 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
HERO_SLOGAN_VARIANT_KEY,
isHeroSloganVariant,
pickHeroSloganVariant,
} from "@/lib/hero-slogan-variant";

const HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant";

Check warning on line 9 in middleware.ts

View check run for this annotation

Claude / Claude Code Review

HERO_SLOGAN_VARIANT_HEADER duplicated between middleware.ts and lib/hero-slogan-variant.ts

`HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant"` is declared locally here, but `lib/hero-slogan-variant.ts` also exports the same constant (which `app/(home)/page.tsx` imports for the SSR read). The two literals must stay byte-identical for the middleware → page handshake to work, with no test or type linking them. middleware.ts already imports `HERO_SLOGAN_VARIANT_KEY`/`isHeroSloganVariant`/`pickHeroSloganVariant` from the same lib — add `HERO_SLOGAN_VARIANT_HEADER` to that import and rem

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant" is declared locally here, but lib/hero-slogan-variant.ts also exports the same constant (which app/(home)/page.tsx imports for the SSR read). The two literals must stay byte-identical for the middleware → page handshake to work, with no test or type linking them. middleware.ts already imports HERO_SLOGAN_VARIANT_KEY/isHeroSloganVariant/pickHeroSloganVariant from the same lib — add HERO_SLOGAN_VARIANT_HEADER to that import and remove the local const.

Extended reasoning...

What the bug is

lib/hero-slogan-variant.ts:1 exports HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant", and app/(home)/page.tsx:4 imports it from there to read the SSR header value. But middleware.ts:9 re-declares its own local const HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant" instead of importing it — even though the same import block already pulls HERO_SLOGAN_VARIANT_KEY, isHeroSloganVariant, and pickHeroSloganVariant from @/lib/hero-slogan-variant. The omission of HERO_SLOGAN_VARIANT_HEADER from that import list is clearly an oversight, not intentional.

Why this is not a live bug today

The two string literals are byte-identical ("x-hero-slogan-variant"), so the middleware → page handshake works correctly: middleware writes the header, page reads it, and the variant flows through. No current incorrectness.

Why it is still worth fixing

This is the textbook "two strings that must stay in sync" footgun, with nothing — no test, no shared type — linking the two literals. If one is ever edited (e.g. namespacing to x-langfuse-hero-slogan-variant) and the other is missed, middleware sets a header that page.tsx never reads. The page would then fall through to cookieStore.get(HERO_SLOGAN_VARIANT_KEY)?.value, which works for returning visitors but fails for first-time requests where the cookie is set only in the response — meaning getHeroSloganVariantFromCookieValue receives undefined and falls through to pickHeroSloganVariant(), producing a fresh Math.random() assignment on every SSR render that may disagree with the cookie middleware just set. The variant the user is bucketed into for tracking and the variant they see on first render would silently drift.

Step-by-step proof of the failure mode if literals drift

Suppose someone later edits lib/hero-slogan-variant.ts:1 to export const HERO_SLOGAN_VARIANT_HEADER = "x-langfuse-hero-slogan-variant" (a reasonable namespacing change) and does not edit middleware.ts:9:

  1. First-time visitor hits /. Middleware runs, picks "agent-evals", sets requestHeaders with key "x-hero-slogan-variant" (its local literal) and queues a Set-Cookie for langfuse-hero-slogan-variant=agent-evals.
  2. app/(home)/page.tsx runs. headerStore.get(HERO_SLOGAN_VARIANT_HEADER) — where HERO_SLOGAN_VARIANT_HEADER is the imported "x-langfuse-hero-slogan-variant" — returns null (the middleware set it under the old key). The cookie was only just queued in the response and is not visible to cookieStore.get() on this same request, which returns undefined.
  3. getHeroSloganVariantFromCookieValue(undefined) falls through to pickHeroSloganVariant() → fresh Math.random() → say "ai-engineering".
  4. The user is served "ai-engineering" HTML, but their cookie now persists "agent-evals". Next request they get "agent-evals". Variant flip on second pageview, plus a randomised SSR variant that disagrees with the persisted assignment on every first visit — directly undermining the A/B test.

Fix

Trivial — extend the existing import block at the top of middleware.ts:

import {
  HERO_SLOGAN_VARIANT_HEADER,
  HERO_SLOGAN_VARIANT_KEY,
  isHeroSloganVariant,
  pickHeroSloganVariant,
} from "@/lib/hero-slogan-variant";

and delete const HERO_SLOGAN_VARIANT_HEADER = "x-hero-slogan-variant"; on line 9. Single source of truth, no behavior change.


export function middleware(request: NextRequest) {
const existing = request.cookies.get(HERO_SLOGAN_VARIANT_KEY)?.value;
const variant =
existing && isHeroSloganVariant(existing)
? existing
: pickHeroSloganVariant();

const requestHeaders = new Headers(request.headers);
requestHeaders.set(HERO_SLOGAN_VARIANT_HEADER, variant);

const response = NextResponse.next({
request: { headers: requestHeaders },
});

if (!existing || !isHeroSloganVariant(existing)) {
response.cookies.set(HERO_SLOGAN_VARIANT_KEY, variant, {
maxAge: 60 * 60 * 24 * 365,
path: "/",
sameSite: "lax",
});
}

return response;
}

export const config = {
matcher: "/",
};
4 changes: 4 additions & 0 deletions src/usePostHogClientCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { usePostHog } from "posthog-js/react";
// This preserves existing PostHog event structure while adding type safety
interface EventDefinitions {
copy_page: { type: "copy" | "chatgpt" | "claude" | "mcp" };
hero_slogan_exposure: {
variant: "ai-engineering" | "agent-evals";
is_new_assignment: boolean;
};
}

type EventName = keyof EventDefinitions;
Expand Down
Loading