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.
<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.
pnpm add @littlefriend/trackerimport { 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.
| Attribute | init option | What it does | Default |
|---|---|---|---|
data-site | site | Your public site key. Required. | Not set |
data-mode | mode | Set to journey for journey mode. See Privacy modes. | aggregate |
data-consent | consentRequired: true | Set to required to stay in aggregate mode until you call lf('consent', 'journey'). | Not set |
data-routes | routes | Route templates, like ["/docs/:slug", "/files/*"], as a JSON array in the tag. First match wins. | None |
data-scroll | scroll: true | Send scroll depth at 25, 50, 75 and 90 percent, once per page view. | Off |
data-utm-extended | utmExtended: true | Also keep utm_term and utm_content. | Off |
data-test | test: true | Mark everything as test traffic. It shows in the live install panel and never in reports. | Off |
data-api | api | The 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.
<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 shell | Origin to add |
|---|---|
| Capacitor on iOS | capacitor://localhost |
| Capacitor on Android | https://localhost |
| Ionic with its Cordova web view | ionic://localhost on iOShttp://localhost on Android |
| Cordova | app://localhost on iOS, with the scheme preference set to apphttps://localhost on Android |
| Tauri | tauri://localhost on macOS, iOS and Linuxhttp://tauri.localhost on Windows and Android, or https://tauri.localhost with useHttpsScheme |
| Electron | The scheme and host you serve the app from, such as app://myapp |
| React Native WebView | The origin of the site it loads, such as https://app.example.com |
| WKWebView with a custom scheme | The scheme and host you register, such as app://localhost |
| WKWebView from a file URL | No origin: leave the list empty or serve from a scheme |
| Android WebView with the asset loader | https://appassets.androidplatform.nethttps://localhost, with setDomain("localhost") |
| Android WebView from a file | No 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:
{
"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.
script-src https://cdn.littlefriend.io
connect-src https://in.littlefriend.ioCheck 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.