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):
translatedTo— the language the visitor actively translated the page into. If they translated, that is their language.languages[0]— the top entry of the browser's ordered preference list.language— the rawnavigator.languagetag.
The base tag is taken in each case (es-MX → es), 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.
| Signal | What it tells you |
|---|---|
| Language ↔ geo mismatch | The 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 mismatch | The device's IANA timezone disagrees with the IP-geo country — corroborates a traveler or VPN (timezoneGeoMismatch, locale-heuristics.ts). |
| Active hours | When this audience is actually on the site, as an hour-of-day histogram (stored UTC, shifted for display). |
| Locale formats | The 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):
- Languages — visitor share per primary language, using the same
translatedTo > languages[0] > languagehierarchy per row. Legacy rows without locale tokens count asunknownand are excluded from percentages, so denominators stay honest. - Hidden demand — the languages visitors actively translated your pages into. Because they took an action, this is the strongest signal that a language market is underserved by your site.
- Active hours — an hour-of-day activity histogram for the group.
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
- Signals API — the
VisitorContextandLocaleContextshapes - Traffic classification — how visitors become lanes
- Optional enrichment — attaching richer detail to an identified person