CLItrail Docs

Add CLItrail to Remix

Where the CLItrail tag goes in Remix, in its own idiom, with the consent, CSP and client-side routing details and how to check it works.

Written for Remix 2 (@remix-run/*); Remix 3 is a separate release candidate

#Where the tag goes

Edit app/root.tsx. Put the tag on every page that shows your install command; site-wide is best, after your existing analytics tags.

import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
        <script defer src="https://YOUR_SERVICE/browser.js" data-website="YOUR_WEBSITE_ID" />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Use the loader in place of the tag: nothing from CLItrail loads until startClitrail() runs. Load the loader before your consent manager's script and call startClitrail() from the manager's accept callback (most managers also run it on later visits once a choice is stored; check yours). Use the marketing category when installs go to Google Ads, Meta, TikTok or X Ads, and the analytics category when they only reach GA4 or your webhooks. Put the loader in <head> in place of the tag.

Consent loader

// Loads CLItrail only after the visitor agrees. Call startClitrail() from your
// consent manager's "accepted" callback; nothing is fetched or stored before that.
window.startClitrail = () => {
  if (document.getElementById('clitrail')) return;
  const tag = document.createElement('script');
  tag.id = 'clitrail';
  tag.src = 'https://YOUR_SERVICE/browser.js';
  tag.dataset.website = 'YOUR_WEBSITE_ID';
  document.head.append(tag);
};
<script defer src="/clitrail-consent.js" />

#Client-side routing

Remix changes pages without a reload. Render <ClitrailRoutes /> once in App: it records a visit each time the path changes. The page the browser loaded is recorded by the tag (it is in the server-rendered HTML), not again.

Route helper

// Records a visit when client-side navigation opens a new page path. The tag records
// the page the browser loaded, so that page is never recorded twice; before the SDK has
// started (for example, before consent) it records nothing.
declare global {
  interface Window {
    InstallAttribution?: { init(options: { service: string; project: string }): Promise<unknown>; ready?: Promise<unknown> };
  }
}

let recorded: string | undefined;

export function recordVisit(): void {
  if (typeof window === 'undefined') return;
  recorded ??= new URL(performance.getEntriesByType('navigation')[0]?.name ?? location.href).pathname;
  const sdk = window.InstallAttribution;
  if (!sdk?.ready || location.pathname === recorded) return;
  recorded = location.pathname;
  sdk.ready = sdk.init({ service: 'https://YOUR_SERVICE', project: 'YOUR_WEBSITE_ID' });
}
import { useEffect } from 'react';
import { useLocation } from '@remix-run/react';
import { recordVisit } from './clitrail';

// Render once inside your root App: records a visit whenever the path changes.
export function ClitrailRoutes() {
  const { pathname } = useLocation();
  useEffect(() => { recordVisit(); }, [pathname]);
  return null;
}

Render it in App

import { ClitrailRoutes } from './clitrail-routes';

export default function App() {
  return (
    <>
      <ClitrailRoutes />
      <Outlet />
    </>
  );
}

#Content Security Policy

app/entry.server.tsx (responseHeaders.set('Content-Security-Policy', …)) or your host: add https://YOUR_SERVICE to script-src and connect-src, and blob: to worker-src (only Safari before 26 needs that one).

script-src 'self' https://YOUR_SERVICE;
connect-src 'self' https://YOUR_SERVICE;
worker-src 'self' blob:;

#Verify it works

  1. Run npm run dev (http://localhost:5173) or deploy.
  2. Open a page with the tag in Chrome or Firefox (accept analytics first if you gate CLItrail on consent), open the developer console and run await InstallAttribution.ready. It resolves to { ok: true, urlId, hasAnalyticsContext, expiresAt }.
  3. In the CLItrail dashboard, open Visits & identities: the visit is listed.
  4. Anything else names the cause: disabled (no consent yet, or data-enabled="false"), opfs_unavailable (the page is not served over https or from localhost), Error (the service refused the visit: add the page's origin, including a development origin such as http://localhost:3000, under Settings → Additional domains). If InstallAttribution is undefined, the tag did not load: check the Network tab and your Content-Security-Policy.

Then run your installer on the same computer and check Install events, or run the hook with --doctor.

#Notes

  • Remix 2 apps that upgrade move to React Router: see the React Router guide.
  • React renders the tag into the server HTML, where the browser runs it once; client-side navigation keeps the root layout and does not run it again.

Remix documentation: v2.remix.run/docs/file-conventions/root

Using other install paths or destinations? The setup generator puts this snippet together with your hooks and destination checklist.