Install the script

One script tag or one npm package, the settings they read, what they send, and how to add them to phone and hybrid apps.

Add the script

Put this tag in the head of every page.

In your site's <head>
<script defer src="https://cdn.littlefriend.io/lf.js" data-site="lf_YOUR_SITE_KEY"></script>

defer keeps it off the critical path. Your site key starts with lf_. It's public: it names your site and unlocks nothing.

Install with npm

Pick the npm package over the tag when your app has a bundler, like React, Next.js or Vue: the same tracker ships inside your bundle, and you import typed functions.

Terminal
pnpm add @littlefriend/tracker
Once, when your app starts
import { init, track } from '@littlefriend/tracker';

init({ site: 'lf_YOUR_SITE_KEY' });

// Anywhere in your app
track('signup.start', { plan: 'pro' });

Calls made before init wait and are sent once it runs. Every function does nothing on the server, so importing it in server-rendered code is safe. Each lf('command', ...) call the script takes has a function of the same name: track, page, route, formError, consent, optout and optin. lf('identify', ...) belongs to session replay. If the tag is also on the page, only one of them runs, so a visit is never counted twice.

Settings

The script reads its settings from the tag. With npm, pass the same settings to init.

Attributeinit optionWhat it doesDefault
data-sitesiteYour public site key. Required.Not set
data-modemodeSet to journey for journey mode. See Privacy modes.aggregate
data-consentconsentRequired: trueSet to required to stay in aggregate mode until you call lf('consent', 'journey').Not set
data-routesroutesRoute templates, like ["/docs/:slug", "/files/*"], as a JSON array in the tag. First match wins.None
data-scrollscroll: trueSend scroll depth at 25, 50, 75 and 90 percent, once per page view.Off
data-utm-extendedutmExtended: trueAlso keep utm_term and utm_content.Off
data-testtest: trueMark everything as test traffic. It shows in the live install panel and never in reports.Off
data-apiapiThe collector origin, if you proxy it through your own domain.https://in.littlefriend.io

Calls before the script loads

Add this stub above the tag if you call lf before the script arrives. Calls wait in a queue until it loads.

Optional queue stub
<script>
  window.lf = window.lf || function () { (lf.q = lf.q || []).push(arguments) };
</script>

Single-page apps

History API navigations (push, replace, back and forward) count as page views on their own. If your router doesn't use the History API, call lf('page') after each navigation. Never call it for a screen a History API navigation or the page load already recorded; it counts the screen again. To name the current route yourself, call lf('route', '/users/:id') before or with the navigation. Name routes without values: a template such as /users/:id, never a URL or a fragment.

Apps

The script and the npm package run in any web page, so they work in mobile browsers and in apps whose screens are web pages. Other screens drawn natively send their outcomes from your backend.

A native app uses the SDK for its platform instead, which sends its screens, events and deep link campaigns from the app: the iOS and macOS SDK for SwiftUI, UIKit and AppKit.

Mobile browsers

Mobile browsers need nothing extra. Safari on iPhone and iPad, Chrome on Android and the rest run the same script. A tap fires the same click a mouse does, so data-lf clicks, outbound links and downloads count the same way. Session replay records taps and scrolling, and the player shows the recording at the phone's screen size, scaled to fit. A browser without CompressionStream sends recordings uncompressed, under the same size limits.

Hybrid apps

Capacitor, Ionic, Cordova, Tauri, Electron and React Native WebView apps show web pages, and so does an app with its own WKWebView or Android WebView. Add the tag or the npm package to the app's web code as you would on a site. If your project lists allowed origins (Settings, Project), add the origin the app's web view sends. Events and recordings from an origin that is not on the list are refused. An empty list accepts every origin.

App shellOrigin to add
Capacitor on iOScapacitor://localhost
Capacitor on Androidhttps://localhost
Ionic with its Cordova web viewionic://localhost on iOShttp://localhost on Android
Cordovaapp://localhost on iOS, with the scheme preference set to apphttps://localhost on Android
Tauritauri://localhost on macOS, iOS and Linuxhttp://tauri.localhost on Windows and Android, or https://tauri.localhost with useHttpsScheme
ElectronThe scheme and host you serve the app from, such as app://myapp
React Native WebViewThe origin of the site it loads, such as https://app.example.com
WKWebView with a custom schemeThe scheme and host you register, such as app://localhost
WKWebView from a file URLNo origin: leave the list empty or serve from a scheme
Android WebView with the asset loaderhttps://appassets.androidplatform.nethttps://localhost, with setDomain("localhost")
Android WebView from a fileNo origin: leave the list empty or serve from a scheme

If you changed the scheme or host name in the shell's config, add the ones you set. Every app built with the same shell sends the same origin, so capacitor://localhost on your list accepts any Capacitor app that carries your site key.

A page loaded from a file sends no origin: a WKWebView that opens it with loadFileURL on iOS, an Android WebView on file:///android_asset, or an Electron app that loads its pages from files. No list can name it. Leave the list empty, or serve the app from a scheme of its own: a WKURLSchemeHandler on iOS, the asset loader on Android, or in Electron a scheme registered with protocol.registerSchemesAsPrivileged as standard and secure, or a package such as electron-serve, which serves app://-. From a page opened from a file the script sends only the file name as the route, such as /index.html. The folders above it can hold the user's name, and they never leave the device. A route your app names with lf('route', ...) is stored as named, cleaned the way the next paragraph describes.

A page opened from a file can't use pushState to move to a new path, only to a new query or fragment. If your app routes by hash, name the starting route in the queue stub, before the script loads, as the template your router matches, such as lf('route', '/users/:id'), or the route part of the hash alone, lf('route', '/' + location.hash.replace(/^#\/?/, '').split(/[?&=]/)[0]), so the first page view carries it. Never pass the whole fragment: it can carry a query or a sign-in token. The script cuts a named route at the first ? or #, redacts a segment holding =, and replaces id-like segments, but name routes without values. After each navigation, once the hash has changed, call lf('route', '/settings'), then lf('page'). Call lf('page') for navigations only, since a hook that fires for the starting screen would count it twice.

App pages count like any other visit. In reports, the browser reads as the web view, such as iOS WebView, Android WebView, macOS WebView or Electron. UTM tags on a deep link count like any landing: pass the link's query on to the page URL, such as index.html?utm_source=newsletter&utm_medium=email, and the visit lands in that channel with those values.

Apps on iOS send no referrer. When an Android app opens a link in Chrome, Chrome names the app, and a known app counts under its web address, such as mail.google.com for Gmail, in that app's channel. The full list is on How we count.

Events waiting to be sent go out as soon as the app moves to the background. An app killed while it is on screen, by a crash or a force stop, loses the events queued in its last 5 seconds. Opening it again starts a new session.

To test against a collector on your computer, an https page (the asset loader, or Capacitor on Android) can only reach plain http at localhost. On the Android emulator, forward the port with adb reverse. Little Friend's own collector is https, so a real app needs neither.

Native screens

The Swift SDK tracks native screens on iOS and macOS. Native Android screens, and Flutter and React Native views, have no web page for the script to run in, so they send what they lead to from your backend: the signup, purchase or upgrade your server confirms, with @littlefriend/node or one HTTP request from any other language. These count toward goals. Keep the server key on your server; it never goes inside the app.

What one request looks like

Events go out in batches: every 5 seconds, every 10 events, and when the page is hidden. Each batch is JSON sent as text/plain, so it never needs a CORS preflight. This one is a landing page view and a named click:

POST https://in.littlefriend.io/v1/e
{
  "v": 1,
  "k": "lf_3kd9Qm2xWb7TzL4pN8vR1cYe",
  "m": "a",
  "sv": "js-0.1.2",
  "e": [
    {
      "i": "Vh2kQ9xLp3Nw7aZc",
      "n": "$pageview",
      "t": 1790346600000,
      "q": 0,
      "u": "/pricing",
      "r": "news.ycombinator.com",
      "l": 1
    },
    {
      "i": "Rt5mB8yKd1Jf4sGh",
      "n": "$click",
      "t": 1790346608000,
      "q": 1,
      "u": "/pricing",
      "p": {
        "id": "pricing.enterprise_cta",
        "plan": "enterprise"
      }
    }
  ]
}

u is the route without its query string, r is the referrer's hostname only, and m: "a" means aggregate mode, with no session key.

Content Security Policy

If your site sets a CSP, allow the script and the collector. With the npm package, the tracker is part of your own bundle, so only connect-src is needed.

CSP directives to add
script-src https://cdn.littlefriend.io
connect-src https://in.littlefriend.io

Check it works

Open Settings in your dashboard and visit your site. The live install check shows your first page view with every field we kept, every property we dropped and why.