Language & geography

ClickStream reads the language and locale signals a browser already exposes, resolves a single primary language per visitor, and cross-references it against IP geography. The result is a set of strategic signals — who is reading in a language your geography does not expect, which languages people actively translate your pages into, and when different audiences are active — surfaced both in page code through Signals and in the dashboard.

Inference discipline. Every locale field is nullable, and ClickStream prefers silence over a guess. An unmapped timezone, a language-only tag, or an unknown country all resolve to null — never a fabricated mismatch (packages/shared-types/src/locale-heuristics.ts).

Primary-language detection

A visitor's primary language resolves through a fixed hierarchy — the language they acted on wins over the language they merely prefer (resolvePrimaryLanguage, packages/shared-types/src/posture.ts:362):

  1. translatedTo — the language the visitor actively translated the page into. If they translated, that is their language.
  2. languages[0] — the top entry of the browser's ordered preference list.
  3. language — the raw navigator.language tag.

The base tag is taken in each case (es-MXes), so es counts the same whether the visitor's browser says es-MX or es-ES.

The four cross-signals

Two of these compute a mismatch verdict; two are descriptive locale details. All are computed at snapshot time and shared by the collector and dashboard so both classify identically.

SignalWhat it tells you
Language ↔ geo mismatchThe visitor's primary language is not one their IP-geo country expects — a Spanish-primary visitor in the US, say. Directly actionable for targeting (languageGeoMismatch, locale-heuristics.ts).
Timezone ↔ geo mismatchThe device's IANA timezone disagrees with the IP-geo country — corroborates a traveler or VPN (timezoneGeoMismatch, locale-heuristics.ts).
Active hoursWhen this audience is actually on the site, as an hour-of-day histogram (stored UTC, shifted for display).
Locale formatsThe browser's hourCycle (12- vs 24-hour) and related Intl locale details, carried on the Signals locale struct.

Both mismatch verdicts are three-state: true (mismatch — the strategic signal), false (consistent), or null (unknown — missing language, or an unmapped/unknown country or timezone).

In Signals

The resolved locale rides the visitor snapshot as an optional, nullable locale object on VisitorContext — the full LocaleContext shape (language, languages, primaryLanguage, translatedTo, timezone, country, languageGeoMismatch, timezoneGeoMismatch, hourCycle) is documented under VisitorContext on the Signals API page. Read it like any other snapshot field:

import { getVisitorOrNull } from '@clickstreamhq/signals';

const visitor = await getVisitorOrNull();
if (visitor?.locale?.primaryLanguage === 'es' && visitor.locale.languageGeoMismatch) {
  // Spanish-primary visitor in a country that doesn't expect Spanish —
  // offer a Spanish path.
  showSpanishEntryPoint();
}

You can also gate on it with waitFor, which accepts primaryLanguage and languageGeoMismatch criteria:

import { waitFor } from '@clickstreamhq/signals';

const visitor = await waitFor({ primaryLanguage: 'es', languageGeoMismatch: true }, 30_000);

locale is null until the first locale-carrying event refreshes the snapshot, and older servers omit it entirely — treat missing as unknown and keep your default experience.

In analytics

The dashboard aggregates the per-event locale tokens into group-level views (GET /api/analytics/language, apps/dashboard/src/app/api/analytics/language/route.ts):

Both the language and hidden-demand charts are click-throughs: selecting a language opens the People view filtered to those visitors, and per-person language is shown as a chip in the persons list (apps/dashboard/src/components/visitors/persons-list.tsx). Geography surfaces on a self-hosted, town-level brand map that is the primary geo view, with click-through from a place into the People there.

See also