Install on Next.js

View as Markdown

Add one next/script tag to your root layout, deploy, and open the site. This page covers the App Router and the Pages Router. If your site shows a cookie or consent banner, read Consent before you deploy.

Platform steps checked against the official Next.js documentation on 2026-11-03: Script component, Optimizing scripts, Content Security Policy.

Quick install

Settings › Channels in the Telltale dashboard shows the tag with your real key already filled in. In Next.js you write the same tag with next/script.

HTML
<script src="https://app.trytelltale.dev/sdk/v1.js" data-telltale-key="YOUR_SITE_PUBLISHABLE_KEY"></script>

App Router

  1. Open app/layout.tsx (your root layout).
  2. Import Script from next/script and add the tag with strategy="afterInteractive".
  3. Deploy.
TSX
// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://app.trytelltale.dev/sdk/v1.js"
          data-telltale-key="YOUR_SITE_PUBLISHABLE_KEY"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

Put it in the root layout, not in one page, so every page loads it. It runs once per full page load; client-side navigation keeps the same SDK running.

Pages Router

Add the same Script to pages/_app.tsx:

TSX
// pages/_app.tsx
import type { AppProps } from 'next/app';
import Script from 'next/script';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Script
        src="https://app.trytelltale.dev/sdk/v1.js"
        data-telltale-key="YOUR_SITE_PUBLISHABLE_KEY"
        strategy="afterInteractive"
      />
      <Component {...pageProps} />
    </>
  );
}

The publishable key (ao_pk_...) is meant for the browser, so writing it in the layout is fine. Never put the server key (ao_sk_...) in a client file or in a NEXT_PUBLIC_ variable.

The tag records from page load. That is right for sites without a cookie or consent banner.

If your site shows a banner, hold the tag: add data-telltale-wait-for-consent to the Script. A held tag sends nothing at all until you call optIn().

TSX
<Script
  src="https://app.trytelltale.dev/sdk/v1.js"
  data-telltale-key="YOUR_SITE_PUBLISHABLE_KEY"
  data-telltale-wait-for-consent=""
  strategy="afterInteractive"
/>

Then call window.Telltale.getClient().optIn() when the banner allows measurement, and window.Telltale.getClient().optOut() when the visitor refuses or withdraws. Signing in is not consent.

A consent manager can decide before the SDK has loaded. To keep its latest decision, use the page-owned bridge below (With a consent manager), the Consent bridge guide, and the OneTrust, Cookiebot or Sourcepoint recipe. Use either the held tag or the bridge, not both.

Identify the signed-in user

Telltale is most useful when each session is linked to the user and the account it belongs to. Send opaque, stable internal IDs, such as your database IDs. Never send e-mail addresses, names or phone numbers.

From your server (recommended). After sign-in, a route handler or server action links the session to the user it authenticated. This is the identity Telltale trusts. See Quickstart step 2 and Server API › Associate identity.

In the browser (a label). From a client component, once your auth knows who is signed in. afterInteractive scripts can finish loading after your component's first effect, so wait for the tag's load event:

TSX
// app/telltale-identity.tsx
'use client';
import { useEffect, useRef } from 'react';
import { useSession } from './auth'; // your own auth hook

type TelltaleClient = {
  identify(identity: { userId?: string | null; accountId?: string | null }): Promise<void>;
  reset(): Promise<void>;
};
declare global {
  interface Window { Telltale?: { getClient(): TelltaleClient } }
}

function telltaleClient(): Promise<TelltaleClient> {
  return new Promise((resolve, reject) => {
    const ready = () => { try { resolve(window.Telltale!.getClient()); } catch (error) { reject(error); } };
    if (window.Telltale) return ready();
    const tag = document.querySelector('script[src$="/sdk/v1.js"]');
    if (!tag) return reject(new Error('Telltale tag not on this page'));
    tag.addEventListener('load', ready, { once: true });
    tag.addEventListener('error', () => reject(new Error('Telltale did not load')), { once: true });
  });
}

export default function TelltaleIdentity() {
  const { user } = useSession(); // null when signed out
  const linked = useRef<string | null>(null);
  useEffect(() => {
    const current = user ? `${user.id}:${user.accountId}` : null;
    if (current === linked.current) return;
    const wasSignedIn = linked.current !== null;
    linked.current = current;
    telltaleClient()
      .then(client => user
        ? client.identify({ userId: user.id, accountId: user.accountId })
        : wasSignedIn ? client.reset() : undefined)
      // Rejects while the tag is held for consent, when it was blocked, or when the Telltale server is unreachable.
      .catch(() => {});
  }, [user?.id, user?.accountId]);
  return null;
}

Render <TelltaleIdentity /> once in the root layout, inside your auth provider. It calls identify() on sign-in and account switch, and reset() on logout. A held tag forgets identity until optIn(), so identify again after the visitor allows measurement. With the consent bridge, use connectIdentity instead (Reconnect identity on consent changes).

Content Security Policy

If your app sends a Content Security Policy, allow the Telltale server for scripts and for connections:

HTTP
Content-Security-Policy: script-src 'self' https://app.trytelltale.dev; connect-src 'self' https://app.trytelltale.dev

With Next.js's nonce setup (script-src 'nonce-...' 'strict-dynamic'), pass the per-request nonce to the Script as nonce={nonce} and keep connect-src https://app.trytelltale.dev. Never add 'unsafe-inline' or 'unsafe-eval'. The Content Security Policy guide has both options in full.

Check it worked

  1. In the Telltale dashboard, open Settings › Channels and leave the Is it working? check open.
  2. Choose Open my site to test it, or open your deployed site in a new tab yourself. Browse a page or two.
  3. Watch the check move through Script loaded on your address, First session received and, if you set up identify, Signed-in user linked. When the first session arrives, it links to it.

You can also look in your browser's DevTools › Network: sdk/v1.js should answer 200, and so should api/sdk/init. If it stays at the first step, see It's not working.

Common mistakes

This section replaces the held tag above when a consent manager (CMP) decides. Load the SDK once, paused until consent, through the page-owned bridge from the Consent guide (/telltale-consent.js, a file you save on your own origin from that guide), connect your CMP recipe to it, and reconnect identity from one root component. Keep browser globals out of server rendering.

The bridge creates the client with waitForConsent: true. Your CMP recipe calls applyConsent(true), which runs client.optIn(), or applyConsent(false), which runs client.optOut(); an unknown choice calls nothing.

React single-page application

Load the bridge as a classic script in index.html, before your application entry and before your CMP loader:

HTML
<!-- index.html -->
<script src="/telltale-consent.js"></script>
<script src="/telltale-onetrust.js" data-group="C0002"></script>
<script type="module" src="/src/main.jsx"></script>

The OneTrust line stands for whichever recipe you use. Both files are ones you create from the Consent guide and the recipe pages and serve from your own origin, for example from public/ in Next.js; the Telltale server does not serve them. The recipe files send decisions through the bridge when it is present.

Next.js App Router

next/script with strategy="beforeInteractive" must be placed in the root layout (App Router, app/layout.tsx) or in pages/_document (Pages Router). Next.js injects it before hydration, so the bridge and its held client exist before client effects run. If you use a nonce-based CSP, read the per-request nonce and pass it to each Script; the bridge copies its own nonce to the SDK script it inserts.

TSX
// app/layout.tsx
import Script from 'next/script';
import { headers } from 'next/headers';
import TelltaleIdentity from './telltale-identity';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? undefined;
  return (
    <html lang="en">
      <body>
        <Script src="/telltale-consent.js" strategy="beforeInteractive" nonce={nonce} />
        <Script src="/telltale-onetrust.js" data-group="C0002" strategy="beforeInteractive" nonce={nonce} />
        <TelltaleIdentity />
        {children}
      </body>
    </html>
  );
}

Do not add onLoad to a beforeInteractive script; that strategy does not support it, and the bridge's ready promise already covers SDK loading. Next.js's CSP guide generates the nonce in its request proxy (proxy.ts in current releases, middleware.ts in older ones), sets script-src 'nonce-...' 'strict-dynamic', and requires dynamic rendering for nonced pages. See the CSP guide for the Telltale server's connect-src entry.

The bridge is your own script, so TypeScript does not know window.telltaleConsent. Declare it once, in any .d.ts file the project includes (for example types/telltale.d.ts). The adapter files are plain JavaScript, so either enable allowJs in tsconfig.json or add a matching declare module for '../lib/telltale/browser.js':

TypeScript
// types/telltale.d.ts
type TelltaleClient = {
  getSessionToken(): Promise<string>;
  optIn(): Promise<void>;
  optOut(): Promise<void>;
  // Exactly userId and/or accountId; any other key, or positional arguments, throws a TypeError.
  identify(identity: { userId?: string | null; accountId?: string | null }, options?: { signal?: AbortSignal }): Promise<void>;
  reset(): Promise<void>;
  getConsent(): TelltaleConsentState;
  // Called synchronously with the current state, then only when `allowed` changes. Not a boolean: read state.allowed.
  onConsentChange(listener: (state: TelltaleConsentState) => void): () => void;
};
type TelltaleConsentState = { allowed: boolean; storage: 'persistent' | 'memory' };

declare global {
  interface Window {
    telltaleConsent?: {
      applyConsent(allowed: boolean): void;
      ready: Promise<TelltaleClient>;
    };
  }
}

export {};

For types, save https://app.trytelltale.dev/adapters/v1/browser.d.ts and https://app.trytelltale.dev/adapters/v1/sdk.d.ts beside it (or install the adapters package, see Server adapters › Install as a package): sdk.d.ts declares the SDK's browser global, so the hosted client is typed without any any casts.

Mount one client component at the root of your auth owner. It waits for the bridge's client, then hands identity to connectIdentity (from the browser adapter at https://app.trytelltale.dev/adapters/v1/browser.js, saved as lib/telltale/browser.js), which invalidates pending auth reads on withdrawal and reads current authentication again after every grant:

TSX
// app/telltale-identity.tsx
'use client';
import { useEffect } from 'react';
import { connectIdentity } from '../lib/telltale/browser.js';
import { readCurrentIdentity, onAuthChange } from '../lib/auth';

export default function TelltaleIdentity() {
  useEffect(() => {
    const bridge = window.telltaleConsent;
    if (!bridge) return; // bridge blocked by CSP, an extension or the network: stay inert
    let disposed = false;
    let identity: { dispose(): void } | undefined;
    bridge.ready.then(client => {
      if (disposed) return;
      identity = connectIdentity(client, {
        readIdentity: ({ signal }) => readCurrentIdentity({ signal }), // {userId, accountId} or null
        subscribe: onAuthChange // login, logout and account switch; returns an unsubscribe function
      });
    }).catch(() => {});
    // Cleanup removes only this subscription and cancels stale auth reads; it never withdraws consent.
    return () => {
      disposed = true;
      identity?.dispose();
    };
  }, []);
  return null;
}

The same component works in a React SPA: render it once near the root, inside your auth provider. React Strict Mode runs the effect twice in development; the cleanup makes that safe, and the bridge itself is never duplicated. Logging in never grants consent, and connectIdentity never calls optIn(). If you use Better Auth, use connectBetterAuth from the Authentication guide instead of this component.

Protected actions

Read await client.getSessionToken() in the browser just before the protected request (const client = await window.telltaleConsent!.ready, or check for undefined first) and send it to your own route handler or server action. Verify it there with POST /api/v1/verify and the Site server key from server-only environment configuration; never prefix that key with NEXT_PUBLIC_.