@clickstreamhq/nextis a developer preview: it's published to npm at 0.1.0-alpha.3 under thealphadist-tag. Every API in this tutorial is the real, current surface — but expect it to evolve before a stable release. Of the ClickStream packages, only@clickstreamhq/sdk(1.4.0) is stable today.
Personalization After the Paint Is Too Late
The browser-side @clickstreamhq/signals library reads visitor context inside page JavaScript: bot classification, identity status, and 11 behavioral scores, fetched after the page loads. That's exactly right for React client components that re-render as the visitor's state changes. But it happens after the page is rendered — too late for SSR personalization, server-side redirects, or A/B gates.
If your pricing page should open with a different offer for a high-intent visitor, deciding that client-side means the visitor watches the default page paint first, then sees it swap. The alternative — blocking render on a client fetch — trades the flash for a spinner. Neither is what Server Components promised.
@clickstreamhq/next closes the gap. It reads ClickStream's server-set first-party _cs_vid cookie (which persists up to 400 days — the browser long-term maximum) on the server, resolves the same VisitorContext the client library would return, and hands it to your Server Components, Route Handlers, and edge middleware. Because the decision is made before HTML is sent, there is zero hydration mismatch and zero client fetch on the critical path.
One boundary worth stating plainly: this package reads Signals. It does not install the tracking pixel. Keep the browser pixel installed with the static script tag or @clickstreamhq/sdk, and use your verified first-party tracking domain as the endpoint.
Setup: One Install, One Server-Scoped Key
pnpm add @clickstreamhq/next @clickstreamhq/signals
# or: npm install @clickstreamhq/next
# or: yarn add @clickstreamhq/next
The package declares next@^13 || ^14 || ^15 as a peer dependency and @clickstreamhq/signals as its runtime dependency.
Then the one piece of configuration that trips people up: you need a server-scoped API key (cs_srv_live_*). getServerVisitor() runs on the server and sends no Origin or Referer header. The ClickStream collector enforces browser provenance on browser keys, so a browser cs_live_* key used server-side is rejected with 403. Mint a Server property in ClickStream to get a provenance-exempt server key and set it as CLICKSTREAM_API_KEY. Your browser key keeps powering the in-page @clickstreamhq/signals library — the two coexist.
Step 1: Middleware with Prefetch
The middleware is optional — getServerVisitor() works without it by reading the cookie itself — but it's where the pre-paint story starts. Installing clickStreamMiddleware at middleware.ts gives you two things:
- a single canonical parse of the visitor cookie (
_cs_vid, with legacy_cs_uidas a last-resort fallback) that the rest of your app can trust, exposed via thex-clickstream-visitor-idrequest header - optional prefetching of the full
VisitorContext, so Server Components get a zero-fetch cache hit viax-clickstream-context
// middleware.ts
import { clickStreamMiddleware } from '@clickstreamhq/next/middleware';
export default clickStreamMiddleware({
apiKey: process.env.CLICKSTREAM_API_KEY!, // cs_srv_live_...
endpoint: 'https://t.example.com',
// Prefetch on routes where every request needs server-side scores.
// Leave it off on the rest of your site; the per-request /v1/signals
// round-trip adds ~50-150 ms to SSR.
prefetch: true,
prefetchTimeoutMs: 350,
});
export const config = {
matcher: ['/pricing', '/cart', '/checkout/:path*'],
};
prefetch defaults to false, and prefetchTimeoutMs defaults to 500 — the prefetch is aborted past the timeout so a slow Signals read can never hold a page render hostage. The middleware also forwards both _cs_vid and the _cs_sid session cookie, so prefetches use the same one-partition Signals read as the browser client instead of an expensive fan-out.
Step 2: Read the Visitor in a Server Component
Inside any async Server Component, call getServerVisitor(). Note the shape: it takes a required config object with apiKey, and it returns { visitor, source } — never the bare visitor, because visitor can legitimately be null.
// app/pricing/page.tsx
import { getServerVisitor } from '@clickstreamhq/next/server';
export default async function PricingPage() {
const { visitor, source } = await getServerVisitor({
apiKey: process.env.CLICKSTREAM_API_KEY!,
endpoint: 'https://t.example.com',
});
// Fail open: no cookie, an aged-out visitor, or an unreachable
// endpoint all land here as { visitor: null }.
if (!visitor) return <DefaultPricing />;
if (visitor.bot.isBot) return <DefaultPricing />;
if (visitor.scores.intent >= 70) return <HighIntentOffer />;
if (visitor.scores.frustration >= 60) return <SupportPromo />;
return <DefaultPricing />;
}
The field paths matter. Intent is visitor.scores.intent (0–100), frustration is visitor.scores.frustration (0–100), and the bot flag is visitor.bot.isBot. The thresholds aren't arbitrary either: 70 is the package's high-intent convention (DEFAULT_HIGH_INTENT_THRESHOLD) and 60 is the frustration threshold the isFrustrated() helper uses.
When this page renders on a route your middleware matched with prefetch: true, that await costs nothing: the context was already fetched at middleware time and rides in on a request header. That's the pre-paint handoff — the Server Component reads a decoded header instead of making a network call.
How the Handoff Works: Resolution Order
getServerVisitor() resolves the visitor from four sources, in order of preference. The source field in the result tells you which one won:
| Priority | Where it reads from | source |
Fetch cost |
|---|---|---|---|
| 1 | x-clickstream-context header (middleware prefetch) |
header |
Zero — decode only |
| 2 | x-clickstream-visitor-id header (set by middleware) |
fetch |
One round trip to /v1/signals/:visitorId |
| 3 | Raw _cs_vid cookie, legacy _cs_uid last (no middleware installed) |
fetch |
One round trip |
| 4 | No cookie present | no_cookie |
None — visitor is null |
Three more source values cover the failure modes: not_active (the visitor resolved but isn't active), error (unauthorized, plan-gated, or network failure), and stub (called outside a request lifecycle, such as a unit test without injected resolvers). In every one of those cases visitor is null and your default branch renders.
A detail worth knowing about the legacy fallback: _cs_uid is only consulted when _cs_vid is absent. Legacy @clickstreamhq/sdk/core installs stored their visitor id there, but on full-SDK sites that cookie holds the cross-site profile id, which is not a valid snapshot key — so the adapter deliberately prefers _cs_vid, matching the browser-side resolver in @clickstreamhq/signals.
Budgeting the 50–150 ms Honestly
Will prefetch: true slow down SSR? Yes — by the round-trip latency to /v1/signals/:visitorId, documented at roughly 50–150 ms. There is no way to read a fresh server-side score without paying for the read somewhere, and we'd rather you budget for it than discover it in a trace.
The design gives you three levers:
- Scope the matcher. The cost applies only to routes your middleware
matcheropts in. Prefetch on/pricingand/checkout, where a server-side decision changes what renders; skip it on your blog, where it doesn't. - Cap the wait.
prefetchTimeoutMsaborts the prefetch past your budget. If the read doesn't make it,getServerVisitor()falls through its resolution order and your page still renders. - Skip prefetch entirely. Without it, the middleware still stamps
x-clickstream-visitor-id, and only the Server Components that actually callgetServerVisitor()pay for a fetch.
Route Handlers Get the Same Treatment
The identical call works in a Route Handler, which is handy when the personalization decision lives behind an API your client components query:
// app/api/offer/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({
// Fail open: a null visitor (or missing scores) renders the default.
variant: (visitor?.scores.intent ?? 0) >= 70 ? 'expanded' : 'default',
});
}
Pages Router apps aren't left out: getServerVisitor works in getServerSideProps via its second resolvers argument — pass headers/cookies adapters built from req. The middleware itself is App-Router-shaped.
Fail Open, Always
The adapter's most important property is the one you never see in a demo: getServerVisitor() never throws. Unauthorized keys, plan gates, and network errors all land on { visitor: null, source: 'error' }. Fresh visitors — someone whose first scored event hasn't caught up yet — get a pending VisitorContext instead of a transient 404, so your page keeps its default experience while the score materializes. Billing and rate limits should never block page rendering, and in this design they can't.
That leads to the pattern we recommend for every gated experience: treat getServerVisitor() as a fail-open snapshot read. If it returns { visitor: null }, render the default page and let client-side Signals update after hydration. The browser library follows the same discipline — configure() must run before any read, and the helpers encode the same thresholds the server checks used:
// Client side, after hydration — the complement, not a replacement.
import { configure, getVisitor, isHighIntent, isFrustrated } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_your_browser_key', // browser key here, not cs_srv_live_*
endpoint: 'https://t.example.com',
});
const visitor = await getVisitor();
if (isHighIntent(visitor)) {
// same >= 70 convention as the server-side check
}
if (isFrustrated(visitor)) {
// scores.frustration >= 60
}
The two halves are complementary by design. The Next adapter handles the server-side snapshot at render time; the browser library tracks the visitor across client-side navigation and reactively updates. Neither replaces the other.
What "Developer Preview" Means Here
To be concrete about the alpha framing: @clickstreamhq/next is published at 0.1.0-alpha.3, and its npm dist-tags (alpha, and currently latest as well) point at the alpha build. The API documented above — clickStreamMiddleware(options), getServerVisitor(config, resolvers?), the { visitor, source } result shape — is what ships today, and it's what our own examples and tests run against. What alpha status means in practice: the surface can change between releases, and you should pin your version. What it doesn't mean: the underlying Signals read is experimental — /v1/signals/:visitorId is the same documented endpoint (available on Hobby and above) that the stable browser stack uses. The Signals Feed WebSocket, a separate live-stream capability, is Scale and above.
The Bottom Line
Pre-paint personalization in Next.js comes down to four decisions, and @clickstreamhq/next makes each one explicit:
- Where to pay for the read: in middleware, once, on the routes you opt in — roughly 50–150 ms, honestly budgeted
- How Server Components get the answer: a prefetched header, decoded with zero fetch
- What to do when there's no answer:
{ visitor: null }plus asourceyou can log — render the default, never block - Which credentials go where:
cs_srv_live_*on the server,cs_live_*in the browser
Personalization that arrives after the paint is a correction. Personalization that arrives with the paint is just the page.