@clickstream/next

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

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

Why you want this

@clickstream/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 — show <HighIntentOffer /> server-side when intent >= 70, render the default pricing 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 @clickstream/next @clickstream/react @clickstream/signals

Edge middleware (optional but recommended)

Install at middleware.ts in your app root:

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

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

export const config = {
  matcher: ['/pricing', '/cart', '/checkout/: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 ~50–150 ms per request. On the rest of your site, omit prefetch and let getServerVisitor() fetch on demand.

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/pricing/page.tsx
import { getServerVisitor } from '@clickstream/next/server';

export default async function PricingPage() {
  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 <DefaultPricing />;

  if (visitor.scores.intent >= 70) return <HighIntentOffer />;
  if (visitor.scores.frustration >= 60) return <SupportPromo />;
  return <DefaultPricing />;
}

Or in a Route Handler:

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

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

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. Unauthorized / plan-gated / network errors all land on { visitor: null, source: 'error' } so page code can fall back to defaults without a try/catch.

Client-side hydration

The server adapter and the React adapter compose cleanly. Wrap your root in <ClickStreamProvider> from @clickstream/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 '@clickstream/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 '@clickstream/next/middleware';
import { getServerVisitor } from '@clickstream/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 '@clickstream/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