@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_vid visitor cookie on the incoming request (falling back to the legacy _cs_uid cookie), 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!, // server key (cs_srv_live_*) when prefetch: true
endpoint: 'https://t.example.com',
prefetch: true,
});
export const config = {
matcher: ['/docs', '/account/:path*', '/settings/:path*'],
};
What it does on every matched request:
- Reads the visitor id from
request.cookies—_cs_vidfirst, legacy_cs_uidas a last resort. If neither is present or the value fails validation, it returnsNextResponse.next()unchanged and does nothing else. - Validates the visitor id:
/^[A-Za-z0-9_\-:.]+$/, max 256 characters (the session id uses the same character class with a 128-character cap). - Stashes the id on
x-clickstream-visitor-id, and the_cs_sidsession id onx-clickstream-session-id, so Server Components + Route Handlers can read them without re-parsing cookies. - When
prefetch: true, round-trips to/v1/signals/:visitorIdand stashes the fullVisitorContextonx-clickstream-context(base64-encoded JSON).getServerVisitor()then returns the cached context with zero network round-trips.
The middleware never throws and never blocks a request. A failed prefetch — timeout, 401, 403, network error — simply omits the context header; the visitor-id header is still set and getServerVisitor() falls back to its own fetch.
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.
Middleware options
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | required | Only used when prefetch: true, where the server-side fetch requires a server key (cs_srv_live_*). |
endpoint | string | https://feynman.clickstream.com | Optional. Point it at the first-party tracking domain that serves your pixel, or leave the default when your Server property has no tracking domain — a cs_srv_live_* key is accepted on either host. |
prefetch | boolean | false | Round-trip to the signals endpoint on every matched request. |
prefetchTimeoutMs | number | 500 | Abort the prefetch after N ms so it doesn't stall SSR. |
getServerVisitor()
Use a server key here.
getServerVisitor()reads Signals over REST server-side, sending noOrigin/Refererheader. That request is only exempt from the collector's browser-provenance gate whenapiKeyis a server-scoped key (cs_srv_live_*, minted from a Server property in Einstein). A browser key (cs_live_*) reaching the collector from the server has no Origin and is rejected —getServerVisitor()then returns{ visitor: null, source: 'error' }. Keep your browser/account key for the in-page@clickstreamhq/signalsreads and the client<ClickStreamProvider>; use thecs_srv_live_*key for the server helper. The/v1/signals/:visitorIdREST read is available on Hobby and above — but on Hobby it also requires a session id, so a Hobby app must install the middleware (or run behind a request that carries_cs_sid) or the read returns400 session_id_requiredand you get{ visitor: null, source: 'error' }.
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!, // server key: cs_srv_live_*
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!, // server key: cs_srv_live_*
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):
x-clickstream-contextheader — set by the middleware whenprefetch: true. Zero network round-trip.x-clickstream-visitor-idheader — set by the middleware on every matched request. Adapter fetches/v1/signals/:visitorId.- Raw
_cs_vidcookie (legacy_cs_uidfallback) — used when middleware isn't installed. Adapter reads the cookie vianext/headers#cookies()and fetches. - Nothing — returns
{ visitor: null, source: 'no_cookie' }.
The session id follows the same path (x-clickstream-session-id, then the _cs_sid cookie) and is appended as ?sessionId= when found. It is worth having: with a session id the collector reads exactly one live-session partition and bills one Signals Coverage unit; without one it fans out across every partition and bills one unit per partition.
The fetch is aborted after 2 000 ms. That timeout is not configurable from getServerVisitor() — use the middleware's prefetchTimeoutMs (default 500 ms) if you need a tighter SSR budget.
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.
source | Meaning |
|---|---|
header | The middleware prefetched the context; no fetch happened here. |
fetch | Read live from /v1/signals/:visitorId. |
no_cookie | No visitor cookie or header on the request. |
not_active | The collector answered 404 (retained for older collector versions — current ones return an initializing context instead). |
error | The fetch failed. Includes 401 (bad or wrong-type key) and 403 (provenance or plan) — an unusable key is indistinguishable from a network blip in this field, so check your key type first when visitor is always null. |
stub | Called outside a request lifecycle with no resolvers — e.g. a unit test without the Pages Router resolvers argument. |
Because a browser key used server-side produces exactly the same source: 'error' as an outage, confirm the key posture before debugging anything else: a cs_srv_live_* key is what makes an Origin-less read legal.
The returned context carries the same freshness fields the browser client sees — pending, stale, coverageMode, ageMs, transport. Gate SSR personalization on them:
if (!visitor || visitor.pending || visitor.stale || visitor.coverageMode === 'degraded') {
return <DefaultDocs />;
}
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
- React adapter — client-side hooks + provider
- Signals API —
VisitorContextshape - Install — full install matrix