@clickstreamhq/next

Official Next.js adapter. Bridges the gap between the browser-side @clickstreamhq/signals library and Next.js's server surface — Server Components, Route Handlers, and edge middleware.

Peer dep: next ^13 || ^14 || ^15. Runtime dep: @clickstreamhq/signals.

This package reads Signals. It does not install the tracking pixel by itself. Keep the browser pixel installed through the static script tag or @clickstreamhq/sdk, and wrap client components with @clickstreamhq/react when they need live updates after hydration.

Why you want this

@clickstreamhq/signals runs in the browser, which means the visitor snapshot isn't available until after the page hydrates. If you want to gate SSR output on a score — render <DetailedDocs /> server-side when intent >= 70, render the default view otherwise — you need the snapshot at render time.

This adapter reads the first-party _cs_uid cookie on the incoming request, fetches the VisitorContext from your first-party collector, and returns it synchronously (well, async-ly) inside your Server Component.

Install

pnpm add @clickstreamhq/sdk @clickstreamhq/next @clickstreamhq/react @clickstreamhq/signals

Install the browser pixel in your client provider:

// app/providers.tsx
'use client';

import { useEffect } from 'react';
import { installClickstreamPixel } from '@clickstreamhq/sdk';
import { ClickStreamProvider } from '@clickstreamhq/react';

export function Providers({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    installClickstreamPixel({
      apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!,
      endpoint: 'https://t.example.com',
      replay: true,
    });
  }, []);

  return (
    <ClickStreamProvider
      apiKey={process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!}
      endpoint="https://t.example.com"
    >
      {children}
    </ClickStreamProvider>
  );
}

Edge middleware (optional but recommended)

Install at middleware.ts in your app root:

// middleware.ts
import { clickStreamMiddleware } from '@clickstreamhq/next/middleware';

export default clickStreamMiddleware({
  apiKey: process.env.CLICKSTREAM_API_KEY!,
  endpoint: 'https://t.example.com',
  prefetch: true,
});

export const config = {
  matcher: ['/docs', '/account/:path*', '/settings/:path*'],
};

What it does on every matched request:

  1. Reads the _cs_uid cookie from request.cookies.
  2. Validates the visitor id (regex + length cap against header-injection shenanigans).
  3. Stashes the id on x-clickstream-visitor-id so Server Components + Route Handlers can read it without re-parsing cookies.
  4. When prefetch: true, round-trips to /v1/signals/:visitorId and stashes the full VisitorContext on x-clickstream-context (base64-encoded JSON). getServerVisitor() then returns the cached context with zero network round-trips.

Only enable prefetch: true on routes where every SSR render actually gates on server-side scores — the prefetch adds a network round-trip per request. On the rest of your site, omit prefetch and let getServerVisitor() fetch on demand.

Use your first-party tracking domain for endpoint. The default collector host exists for compatibility, but production apps should use the verified https://t.example.com domain from Einstein.

Middleware options

OptionTypeDefaultNotes
apiKeystringrequiredClickStream API key.
endpointstringrequiredYour first-party tracking domain.
prefetchbooleanfalseRound-trip to the signals endpoint on every matched request.
prefetchTimeoutMsnumber500Abort the prefetch after N ms so it doesn't stall SSR.

getServerVisitor()

Read the visitor context in any async Server Component:

// app/docs/page.tsx
import { getServerVisitor } from '@clickstreamhq/next/server';

export default async function DocsPage() {
  const { visitor, source } = await getServerVisitor({
    apiKey: process.env.CLICKSTREAM_API_KEY!,
    endpoint: 'https://t.example.com',
  });

  // Fallback to default UI when no cookie is set, the visitor's DO instance
  // aged out, or the endpoint couldn't be reached.
  if (!visitor) return <DefaultDocs />;

  if (visitor.scores.intent >= 70) return <DetailedDocs />;
  if (visitor.scores.frustration >= 60) return <HelpPanel />;
  return <DefaultDocs />;
}

Or in a Route Handler:

// app/api/variant/route.ts
import { getServerVisitor } from '@clickstreamhq/next/server';

export async function GET() {
  const { visitor } = await getServerVisitor({
    apiKey: process.env.CLICKSTREAM_API_KEY!,
    endpoint: 'https://t.example.com',
  });
  return Response.json({
    variant: (visitor?.scores.intent ?? 0) >= 70 ? 'expanded' : 'default',
  });
}

Resolution order

getServerVisitor() reads from (in order of preference):

  1. x-clickstream-context header — set by the middleware when prefetch: true. Zero network round-trip.
  2. x-clickstream-visitor-id header — set by the middleware on every matched request. Adapter fetches /v1/signals/:visitorId on your first-party domain.
  3. Raw _cs_uid cookie — fallback when middleware isn't installed. Adapter reads the cookie via next/headers#cookies() and fetches.
  4. Nothing — returns { visitor: null, source: 'no_cookie' }.

Return value

interface ServerVisitorResult {
  visitor: VisitorContext | null;
  source: 'header' | 'fetch' | 'no_cookie' | 'not_active' | 'error' | 'stub';
}

Never throws. Fresh visitors return a pending VisitorContext instead of a transient 404, so page code can keep its default experience while the first scored event catches up. Unauthorized / plan-gated / network errors all land on { visitor: null, source: 'error' }.

Client-side hydration

The server adapter and the React adapter compose cleanly. Wrap your root in <ClickStreamProvider> from @clickstreamhq/react so client components can keep the snapshot warm after hydration:

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/providers.tsx
'use client';
import { ClickStreamProvider } from '@clickstreamhq/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ClickStreamProvider
      apiKey={process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!}
      endpoint="https://t.example.com"
    >
      {children}
    </ClickStreamProvider>
  );
}

Subpath imports

import { clickStreamMiddleware } from '@clickstreamhq/next/middleware';
import { getServerVisitor } from '@clickstreamhq/next/server';

Two separate entries because middleware runs on the edge runtime (no next/headers) while the server helper runs on the node runtime (uses next/headers via dynamic import()). Keeping them in separate entry files prevents edge bundlers from pulling in node-only modules.

Pages Router

getServerVisitor() works in getServerSideProps via the resolvers argument:

import { getServerVisitor } from '@clickstreamhq/next/server';

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  const { visitor } = await getServerVisitor(
    {
      apiKey: process.env.CLICKSTREAM_API_KEY!,
      endpoint: 'https://t.example.com',
    },
    {
      headers: () => ({ get: (name) => ctx.req.headers[name.toLowerCase()] as string | null }),
      cookies: () => ({
        get: (name: string) => (ctx.req.cookies[name] ? { value: ctx.req.cookies[name] as string } : undefined),
      }),
    },
  );
  return { props: { visitor } };
}

The middleware factory is App Router-shaped — Pages Router doesn't have an equivalent edge middleware surface.

See also