TestaDocs
Docs/Integration/Next.js

Next.js integration

Two files. Grab your project ID from the dashboard (Get your script) and replace {YOUR_PROJECT_ID} below.

bash
npm install @testa-soft/next

1. Proxy

proxy.ts at the project root (or under src/). Buckets the visitor, writes the cookie, and issues split-URL redirects before any HTML is sent.

ts
// proxy.ts — Next.js 16
import type { NextFetchEvent, NextRequest } from "next/server";
import { createTestaProxy } from "@testa-soft/next";

const testa = createTestaProxy({
  projectId: "{YOUR_PROJECT_ID}",
  secureCookies: process.env.NODE_ENV === "production",
});

export function proxy(request: NextRequest, event: NextFetchEvent) {
  return testa(request, event);
}

Next.js 13–15: name the file middleware.ts and the export middleware.

Pages Router? Same proxy, different client half — see Next.js (Pages Router).

Already have middleware? Put your logic in the handler option, or await the proxy and use the response it returns.

2. Layout

tsx
// app/layout.tsx
import { TestaGuard, TestaProvider } from "@testa-soft/next/server";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <TestaGuard />
      </head>
      <body>
        {children}
        <TestaProvider projectId="{YOUR_PROJECT_ID}" />
      </body>
    </html>
  );
}

<TestaProvider/> is the client half of every experiment type: it applies HTML changes, tracks goals, reports which variation the visitor saw, re-applies on soft navigation, and decides for itself on the rare pageview the server couldn't. Mount it even if you only run split-URL tests — that's how their conversions get counted.

<TestaGuard/> hides the page until the variant is on it, and only renders when this visitor has something to hide on this page — split-URL-only pages are never hidden. It reveals itself after 4 seconds regardless, so a failed load can't leave a page blank.

Done. That's a working integration.

Optional: faster publishes

By default the proxy caches the config for 60s and refreshes in the background. To pick up dashboard changes on the next page load instead:

ts
createTestaProxy({ projectId: "{YOUR_PROJECT_ID}", cache: "per-pageload" });

Goals

Create goals in the dashboard (Goals tracking). Page-view and click goals need no code. Custom-event goals fire from yours:

ts
import { pushEvent } from "@testa-soft/next";

pushEvent("signup_completed", { plan: "pro" });

Also window.testa.pushEvent(...) and window.Analytica.pushEvent(...). Each goal counts once per visitor.

Analytics events

Whenever a visitor is shown a variation, Testa fires variation_applied in the browser — subscribe to forward it wherever your analytics live.

Subscribe from a client component, in an effect. onVariationApplied returns its own unsubscribe function, so returning it from the effect is the whole cleanup — without that, Fast Refresh and StrictMode's double mount stack duplicate handlers:

tsx
// app/components/TestaAnalytics.tsx
"use client";

import { onVariationApplied } from "@testa-soft/next";
import { useEffect } from "react";

export function TestaAnalytics(): null {
  useEffect(
    () =>
      onVariationApplied((data) => {
        // Send `data` wherever your analytics live.
        console.log(data);
        // {
        //   project_id: 123,                      // your project ID
        //   experiment: 456,                      // experiment ID
        //   variation: 1,                         // variation ID (0 = control)
        //   uuid: "0198f2c1-4b7a-7f3e-9d21-...",  // the visitor's unique ID
        //   title: "Homepage Hero Test",          // experiment name, when set
        //   url: "https://example.com/pricing",   // page the variation applied on
        // }
      }),
    [],
  );
  return null;
}

Render it once, anywhere under your root layout.

experiment and variation are the identifiers you see in the dashboard — variation 0 is always the control. title is present only when the experiment has a name. Forward the whole object, or pick fields out of it:

tsx
// `track` here is whatever you already use — your analytics SDK, or your own fetch.
useEffect(
  () =>
    onVariationApplied((data) =>
      track("Experiment Viewed", {
        experiment: data.experiment,
        variation: data.variation,
        name: data.title,
      }),
    ),
  [],
);

// Or forward the payload as it comes.
useEffect(() => onVariationApplied((data) => track("Experiment Viewed", data)), []);

Register as many handlers as you like. Two guarantees make this safe to wire into a tag manager:

  • A handler registered after the event fired still receives it, so a slow-loading analytics script never misses an exposure.
  • Each handler sees a given experiment/variation once, so re-renders and client-side navigations can't double-count.

Outside a bundle — GTM Custom HTML, or any plain <script> — use the global instead. (window.testa is also importable as import { testa } from "@testa-soft/next" if you prefer a namespace to named imports; it is the same two functions.)

html
<script>
  window.testa.onVariationApplied(function (d) {
    // send d to your analytics
  });
</script>

GTM dataLayer — pushed automatically on every exposure, no setup:

js
window.dataLayer.push({
  event: "Analytica", // the trigger name
  ExperimentId: 456,
  ExperimentName: "Homepage Hero Test", // "" when the experiment has no name
  VariationId: 1,
  VariationName: "Variation1", // "Control" for variation 0
});

Add a Custom Event trigger on Analytica.

The bucketing moment

onVariationAssigned fires when the visitor is bucketed, with the same payload and the same subscribe-in-an-effect shape. In the browser it fires only when the client made the decision — a cold proxy instance, or a soft navigation. When the proxy decided (the normal case), the assignment happened server-side and the browser never sees it, so use the proxy's own hook instead:

ts
createTestaProxy({
  projectId: "{YOUR_PROJECT_ID}",
  onVariationAssigned(event, ctx) {
    // event = { experimentId, variationId, title?, redirected, destinationUrl?,
    //           visitorId, url, firstAssignment }
    // firstAssignment is true only on the visit that bucketed them.
    ctx.waitUntil(warehouse.track(event));
  },
});

variation_applied is the one to use for exposure tracking — it fires wherever the decision was made.