Author:
    Creation:2026-07-08Last update:2026-09-27

    Intlayer Analytics Documentation

    @intlayer/analytics is an optional companion package that tells you which content is actually shown to your visitors (which page, in which locale, and which specific piece of translated content) so you can understand your audience and run A/B tests on content.

    Table of Contents

    What it tracks

    @intlayer/analytics batches three kinds of anonymous events:

    EventCaptured whereWhat it tells you
    page_viewProvider level (the Intlayer provider)Which page and locale a session viewed, on first load, route change, or locale switch.
    content_exposureNode level (useIntlayer / interpreter plugins)Which dictionary key / key path was actually resolved and displayed, and, when part of an experiment, which variant.
    conversionWherever you call useConversion()A goal reached (signup, click, purchase…) attributed to the A/B variant the session was exposed to.

    Events are collected in memory and sent as a single batched request roughly every 20 seconds, never on every keystroke or render, so analytics never impacts first render time or adds a request per interaction.

    How it powers A/B testing on content

    Intlayer already lets you declare content Variants (e.g. a hero-banner dictionary with a default and a black_friday variant). @intlayer/analytics closes the loop:

    1. useExperiment(experimentKey, variants) deterministically assigns each anonymous session to a variant, a pure function of the session id and the experiment key, so the assignment is stable across the session and requires no server round-trip before first render (no flicker, no layout shift).
    2. Every content_exposure event carries the variant that was shown.
    3. useConversion() lets you attribute a goal (e.g. "cta_click") to that variant.
    4. The dashboard's experiment results endpoint compares conversion rates per variant, including statistical significance (a z-test).

    Installation

    @intlayer/analytics is an optional dependency of every framework package (react-intlayer, next-intlayer, vue-intlayer, …), so most projects already have it. Install it explicitly if your setup skips optional dependencies (npm install --no-optional, NODE_ENV=production installs of some package managers, …):

    bash
    npm install @intlayer/analytics
    

    Installing the package is all it takes to turn analytics on: analytics.enabled defaults to true, and Intlayer resolves it to false whenever the package cannot be found in your project. If you don't install it, every integration point resolves to a no-op. See Zero-cost when not installed below.

    Configuration

    Analytics needs no configuration to start: it is enabled by default and reuses the existing editor configuration block for its endpoint and project key.

    intlayer.config.ts
    import type { IntlayerConfig } from "intlayer";
    
    const config: IntlayerConfig = {
      editor: {
        backendURL: "https://back.intlayer.org", // Also used as the analytics ingestion endpoint
        clientId: "your-client-id", // Also used as the analytics project key
        clientSecret: "your-client-secret",
      },
    };
    
    export default config;
    
    • editor.backendURL: the base URL analytics events are sent to (POST {backendURL}/api/analytics/events).
    • editor.clientId: the public project key. It identifies the project when the SDK requests an ingest token, and acts as an enable switch: analytics stays fully disabled (and tree-shaken, see below) until clientId is configured.
    • editor.clientSecret: never used by analytics, and never sent to the browser. It is a server-only credential; see How events are authenticated.

    If you self-host Intlayer, analytics automatically points at your own instance since it shares editor.backendURL.

    Calling the API from the browser

    The same token backs a small credential-free client, so a static site or SPA can read its CMS content at runtime with no server, no server action, and no secret in the bundle:

    content.ts
    import { createPublicClient } from "@intlayer/api/public";
    
    const client = createPublicClient();
    
    const keys = await client.getDictionaryKeys();
    const [navbar] = await client.getDictionaries(["navbar"]);
    

    It authenticates itself from editor.clientId, the exchange, caching and renewal are handled internally. The scopes bound what it can reach: published dictionary content and analytics ingestion. Anything else (pushing dictionaries, reading a project, spending AI credits) needs a real credential, and therefore a server or a signed-in user.

    Opting out

    The optional analytics block tunes, or turns off, the collection:

    intlayer.config.ts
    import type { IntlayerConfig } from "intlayer";
    
    const config: IntlayerConfig = {
      analytics: {
        enabled: false, // Default: true, opts the whole integration out of the bundle
        flushInterval: 20_000, // Milliseconds between two batched flushes
        sampleRate: 1, // Fraction of sessions to record, from 0 (none) to 1 (all)
      },
    };
    
    export default config;
    

    Uninstalling @intlayer/analytics has the same effect as enabled: false. See the Configuration reference for the full field list.

    Usage

    Automatic provider-level tracking

    No code changes are required. Once @intlayer/analytics is installed and editor.clientId is configured, the Intlayer provider you already mount automatically:

    • initializes the analytics client on mount,
    • records a page_view on initial load,
    • records a page_view on every locale change,
    • starts the ~20s flush loop and flushes any remaining events on unmount / tab close (via navigator.sendBeacon, falling back to fetch(..., { keepalive: true })).

    The entry point differs per framework, but in every case it is the same one you already use to set Intlayer up, so there is nothing extra to add:

    IntlayerProvider mounts the analytics provider internally.

    App.tsx
    import { IntlayerProvider } from "react-intlayer";
    
    const App = () => (
    <IntlayerProvider>
      <Router />
    </IntlayerProvider>
    );
    

    next-intlayer re-exports React's IntlayerProvider, so analytics is wired the same way.

    app/[locale]/layout.tsx
    import { IntlayerProvider } from "next-intlayer";
    
    const LocaleLayout = ({ children }) => (
    <IntlayerProvider>{children}</IntlayerProvider>
    );
    
    export default LocaleLayout;
    

    The intlayer plugin registers the analytics hooks on the root component's lifecycle.

    main.js
    import { createApp } from "vue";
    import { intlayer } from "vue-intlayer";
    import App from "./App.vue";
    
    const app = createApp(App);
    
    app.use(intlayer);
    
    app.mount("#app");
    
    With Nuxt, nuxt-intlayer installs the plugin for you, nothing to do.

    setupIntlayer() starts analytics from the component that sets Intlayer up.

    src/routes/[[locale=locale]]/+layout.svelte
    <script lang="ts">
    import { setupIntlayer } from "svelte-intlayer";
    import type { Snippet } from "svelte";
    
    let { children, data }: { children: Snippet, data: LayoutData } = $props();
    
    $effect(() => {
      setupIntlayer(data.locale);
    });
    </script>
    
    {@render children()}
    

    IntlayerProvider mounts the analytics provider internally.

    app.tsx
    import { IntlayerProvider } from "preact-intlayer";
    
    const App = () => (
    <IntlayerProvider>
      <Router />
    </IntlayerProvider>
    );
    

    IntlayerProvider lazily mounts the analytics provider, so the chunk stays off the critical path.

    App.tsx
    import { IntlayerProvider } from "solid-intlayer";
    
    const App = () => (
    <IntlayerProvider>
      <Router />
    </IntlayerProvider>
    );
    

    provideIntlayer() already includes provideIntlayerAnalytics().

    app.config.ts
    import { provideIntlayer } from "angular-intlayer";
    import type { ApplicationConfig } from "@angular/core";
    
    export const appConfig: ApplicationConfig = {
    providers: [provideIntlayer()],
    };
    
    Use provideIntlayerAnalytics() on its own only if you manage providers individually.

    Automatic node-level tracking

    Every time useIntlayer resolves a piece of content for display, the interpreter reports a content_exposure event for that exact dictionaryKey + key path + locale, again, no code changes required. Repeated exposures of the same node within a flush window are coalesced into a single event with a count, so a list re-rendering 50 times doesn't send 50 events.

    Tracking conversions for A/B tests

    useConversion() returns a callback that attributes a goal to the variant a session saw. It is exported from every framework package, with the same signature:

    CTAButton.tsx
    import { useConversion } from "react-intlayer";
    
    const CTAButton = () => {
    const trackConversion = useConversion();
    
    return (
      <button
        onClick={() =>
          trackConversion({
            experimentKey: "homepage-hero",
            variant: "black_friday",
            goal: "cta_click",
          })
        }
      >
        Get started
      </button>
    );
    };
    
    CTAButton.tsx
    "use client";
    
    import { useConversion } from "next-intlayer";
    
    const CTAButton = () => {
    const trackConversion = useConversion();
    
    return (
      <button
        onClick={() =>
          trackConversion({
            experimentKey: "homepage-hero",
            variant: "black_friday",
            goal: "cta_click",
          })
        }
      >
        Get started
      </button>
    );
    };
    
    useConversion is a client hook, mark the component "use client".
    CTAButton.vue
    <script setup lang="ts">
    import { useConversion } from "vue-intlayer";
    
    const trackConversion = useConversion();
    </script>
    
    <template>
    <button
      @click="
        trackConversion({
          experimentKey: 'homepage-hero',
          variant: 'black_friday',
          goal: 'cta_click',
        })
      "
    >
      Get started
    </button>
    </template>
    
    CTAButton.svelte
    <script lang="ts">
    import { useConversion } from "svelte-intlayer";
    
    const trackConversion = useConversion();
    </script>
    
    <button
    onclick={() =>
      trackConversion({
        experimentKey: "homepage-hero",
        variant: "black_friday",
        goal: "cta_click",
      })}
    >
    Get started
    </button>
    
    CTAButton.tsx
    import { useConversion } from "preact-intlayer";
    
    const CTAButton = () => {
    const trackConversion = useConversion();
    
    return (
      <button
        onClick={() =>
          trackConversion({
            experimentKey: "homepage-hero",
            variant: "black_friday",
            goal: "cta_click",
          })
        }
      >
        Get started
      </button>
    );
    };
    
    CTAButton.tsx
    import { useConversion } from "solid-intlayer";
    
    const CTAButton = () => {
    const trackConversion = useConversion();
    
    return (
      <button
        onClick={() =>
          trackConversion({
            experimentKey: "homepage-hero",
            variant: "black_friday",
            goal: "cta_click",
          })
        }
      >
        Get started
      </button>
    );
    };
    
    cta-button.component.ts
    import { Component } from "@angular/core";
    import { useConversion } from "angular-intlayer";
    
    @Component({
    selector: "app-cta-button",
    template: `<button (click)="onClick()">Get started</button>`,
    })
    export class CtaButtonComponent {
    private trackConversion = useConversion();
    
    onClick() {
      this.trackConversion({
        experimentKey: "homepage-hero",
        variant: "black_friday",
        goal: "cta_click",
      });
    }
    }
    

    Resolving a variant client-side

    useExperiment() assigns the session to a variant and records the exposure that becomes the denominator of the conversion rate. Gate the variant-aware subtree on isAssigned so no visitor sees the control flash before the assignment resolves:

    variant is a plain string.

    Hero.tsx
    import { useExperiment } from "react-intlayer";
    import { HeroBanner } from "./HeroBanner";
    
    export const Hero = () => {
    const { variant, isAssigned } = useExperiment("homepage-hero", [
      "default",
      "black_friday",
    ]);
    
    if (!isAssigned) return null;
    
    return <HeroBanner variant={variant} />;
    };
    

    variant is a plain string. Assignment happens in the browser, so the component must be a client component.

    Hero.tsx
    "use client";
    
    import { useExperiment } from "next-intlayer";
    import { HeroBanner } from "./HeroBanner";
    
    export const Hero = () => {
    const { variant, isAssigned } = useExperiment("homepage-hero", [
      "default",
      "black_friday",
    ]);
    
    if (!isAssigned) return null;
    
    return <HeroBanner variant={variant} />;
    };
    

    variant and isAssigned are Refs.

    Hero.vue
    <script setup lang="ts">
    import { useExperiment } from "vue-intlayer";
    import HeroBanner from "./HeroBanner.vue";
    
    const { variant, isAssigned } = useExperiment("homepage-hero", [
    "default",
    "black_friday",
    ]);
    </script>
    
    <template>
    <HeroBanner v-if="isAssigned" :variant="variant" />
    </template>
    

    variant and isAssigned are stores. Read them with the $ prefix.

    Hero.svelte
    <script lang="ts">
    import { useExperiment } from "svelte-intlayer";
    import HeroBanner from "./HeroBanner.svelte";
    
    const { variant, isAssigned } = useExperiment("homepage-hero", [
      "default",
      "black_friday",
    ]);
    </script>
    
    {#if $isAssigned}
    <HeroBanner variant={$variant} />
    {/if}
    

    variant is a plain string.

    Hero.tsx
    import { useExperiment } from "preact-intlayer";
    import { HeroBanner } from "./HeroBanner";
    
    export const Hero = () => {
    const { variant, isAssigned } = useExperiment("homepage-hero", [
      "default",
      "black_friday",
    ]);
    
    if (!isAssigned) return null;
    
    return <HeroBanner variant={variant} />;
    };
    

    variant and isAssigned are Accessors. Call them to read the value.

    Hero.tsx
    import { useExperiment } from "solid-intlayer";
    import { Show } from "solid-js";
    import { HeroBanner } from "./HeroBanner";
    
    export const Hero = () => {
    const { variant, isAssigned } = useExperiment("homepage-hero", [
      "default",
      "black_friday",
    ]);
    
    return (
      <Show when={isAssigned()}>
        <HeroBanner variant={variant()} />
      </Show>
    );
    };
    

    variant and isAssigned are Signals. Call them to read the value.

    hero.component.ts
    import { Component } from "@angular/core";
    import { useExperiment } from "angular-intlayer";
    import { HeroBannerComponent } from "./hero-banner.component";
    
    @Component({
    selector: "app-hero",
    imports: [HeroBannerComponent],
    template: `@if (experiment.isAssigned()) {
      <app-hero-banner [variant]="experiment.variant()" />
    }`,
    })
    export class HeroComponent {
    experiment = useExperiment("homepage-hero", ["default", "black_friday"]);
    }
    

    Weights are optional. Pass one per variant to skew the split, e.g. useExperiment("homepage-hero", ["default", "black_friday"], [9, 1]).

    The child then reads the Variant of the dictionary that matches:

    HeroBanner.tsx
    import { useIntlayer } from "react-intlayer";
    
    export const HeroBanner = ({ variant }: { variant: string }) => {
      const { headline, cta } = useIntlayer("hero-banner", { variant });
    
      return (
        <section>
          <h1>{headline}</h1>
          <a>{cta}</a>
        </section>
      );
    };
    
    Reading the variant in a child is what makes this work outside React: in Vue, Svelte, Solid, and Angular the selector passed to useIntlayer is captured when the component sets up, so the read has to happen in a component that only mounts once the variant is known.

    If the experiment covers a whole page rather than a single dictionary, hoist the variant onto the provider instead. See Ambient variant. Every useIntlayer below then resolves against it with no call-site change.

    If you need the raw assignment outside of a component, reach for the client directly:

    heroVariant.ts
    import { getGlobalAnalyticsClient } from "@intlayer/analytics/client";
    
    const client = getGlobalAnalyticsClient();
    const variant = client?.getVariant("homepage-hero", [
      "default",
      "black_friday",
    ]);
    
    getVariant only assigns. It does not record the exposure. Prefer useExperiment(), otherwise the conversion rate has no denominator.

    Privacy & performance

    • Anonymous by design: sessions are identified by a rotating id; the backend only ever stores a SHA-256 hash of that id, never the raw id, never an IP address.
    • Location is coarse: only a country code, derived from CDN geolocation headers (cf-ipcountry, x-vercel-ip-country, …), no IP is read or stored.
    • URLs exclude search params by default, so query strings are never captured.
    • Sampling: sampleRate lets you keep only a fraction of content-exposure events on high-traffic apps.
    • Batched: one request roughly every 20 seconds (flushInterval), or earlier if the buffer fills up (maxBufferSize), never one request per event.

    Zero-cost when not installed

    @intlayer/analytics follows the exact same optional-dependency pattern as @intlayer/editor:

    • every integration point loads the package via a dynamic import() wrapped in try/catch, an app that never installs @intlayer/analytics never pays a bundle-size or runtime cost, and never sees an error;
    • a compile-time env var (INTLAYER_ANALYTICS_ENABLED), automatically set to 'false' whenever the package is not installed, analytics.enabled is false, or editor.clientId is not configured, lets bundlers dead-code-eliminate the whole integration;
    • analytics is disabled inside the Intlayer editor/CMS preview iframe, so editor sessions are never counted as real traffic.

    Dashboard: Analytics page

    Once your project has collected events, the Analytics page in the Intlayer dashboard (visible in the sidebar once a project is selected) shows:

    • Active users: distinct visitors over the selected rolling window (7 / 30 / 90 days).
    • Users today and users over the last 7 days.
    • Page views over the selected window.
    • An evolution graph of daily distinct visitors.
    • Locales and Location breakdown tabs, ranking your audience by locale and by country.

    Backend API reference

    All read endpoints require authentication; the token exchange and ingestion are public.

    MethodEndpointDescription
    POST/api/public/tokenExchange the public clientId for a short-lived, scoped browser token.
    POST/api/analytics/eventsIngest a batch of events (public, analytics:ingest scope).
    GET/api/analytics/overviewPage/locale totals for the authenticated project.
    GET/api/analytics/audience?days=30Distinct visitors, page views, daily series, locale + country breakdowns.
    GET/api/analytics/content-statsPer-content exposure totals, grouped by dictionary key / key path / locale.
    GET/api/analytics/experiments/:experimentKeyPer-variant conversion rates and statistical significance for an A/B experiment.

    You can also call these programmatically with the CMS SDK:

    analytics.ts
    import { createIntlayerCMS } from "@intlayer/api";
    import { analyticsEndpoint } from "@intlayer/api/analytics";
    
    const cms = createIntlayerCMS();
    
    const { data: audience } = await analyticsEndpoint(cms).getAudience(30);
    
    Server-side only. createIntlayerCMS() authenticates with clientId + clientSecret, and the secret is never available in the browser. This snippet would issue unauthenticated requests if it ran there. Keep it in a route handler, server action, or script.