Docs
Add userlily to your site with a script tag, or install @userlily/js from npm in an app with a bundler. Every example below uses placeholder keys in capitals — your dashboard shows the same snippets with your real keys filled in.
Getting started
Pick one way to install, then copy the key for each product you use.
| Install with | Best for | Updates |
|---|---|---|
| Script tag | Any website — HTML, WordPress, Webflow, Shopify. No build step. | Automatic |
| npm — @userlily/js | Apps with a bundler — Next.js, Vue, Nuxt, React, Svelte. | Automatic — the package loads the same hosted scripts |
One or the other
Use the script tag or the package on a site, never both. A second copy of a product is ignored, with a warning in the browser console.
Keys
Keys are public identifiers, not secrets — they appear in your page source. What limits where they work is the Allowed domain you set on each project or campaign.
| Product | Key | Where to find it | npm env var |
|---|---|---|---|
| All products | Host | https://dev.userlily.com | NEXT_PUBLIC_USERLILY_HOST |
| Analytics | Tracking ID | Analytics → your project → Code | NEXT_PUBLIC_USERLILY_TRACKING_ID |
| Feedback widget | Campaign key | Campaigns → your campaign → Widget | NEXT_PUBLIC_USERLILY_CAMPAIGN |
| Review badge | Campaign key | Campaigns → your campaign → Syndication | Passed per badge |
| Web push | Project key | Broadcast → your project → Settings | NEXT_PUBLIC_USERLILY_BROADCAST_KEY |
These values are built into your site when it is compiled. Set them for Production and Preview in your host (Vercel, Netlify...) and redeploy after changing them. With npm, host is required: without it the package logs an error and starts nothing — your site keeps working.
Analytics
Pageviews, visitors, referrers, devices and countries — plus clicks, recordings and friction signals when you turn them on.
Paste into your site's <head>
<script src="https://dev.userlily.com/v1/tracker.js" data-tracking-id="YOUR_TRACKING_ID" async> </script>
Recorded automatically
- Pageviews, with referrer and UTM parameters, time on page and scroll depth.
- Route changes in single-page apps — links, back and forward, and redirects to a new path (router.replace).
- Link and button clicks, with “Track link and button clicks” on in the project settings.
- Clicks on links leaving your site, with “Track outbound link clicks” on.
- Rage clicks, errors after a click, abandoned forms and load times, with “Detect friction signals” on.
Settings that change what is sent
- Allowed domain — the tracker only runs on that domain (and its www. form).
- Exclude paths — nothing is recorded on matching pages, custom events included.
- Honor browser Do Not Track — visitors with it on are not tracked at all.
Not seeing data?
Add data-debug="1" to the script tag, or debug: true to the analytics options of init(). The tracker then logs each request and the server’s reply to the browser console.
Custom events
Record the moments that matter — a signup, a plan choice, an invite. Every name you send becomes a funnel step. Needs analytics installed.
With the script tag
// Call this when the thing happens - in a click
// handler, on submit, or after a successful save.
window.userlily.track('signup_completed')Tied to the thing that happened
document
.querySelector('#signup-form')
.addEventListener('submit', function () {
window.userlily.track('signup_completed')
})Properties
Optional. Up to 20 keys, strings trimmed at 200 characters. Strings, numbers and true/false only. Funnels match on the event name, not on properties.
With properties
// Optional. Strings, numbers and true/false only -
// nested objects and arrays are dropped on save.
window.userlily.track('plan_selected', {
plan: 'pro',
seats: 5,
trial: false
})Naming
- Use the same name every time — matching is exact and case-sensitive, so
signup_completedandSignup_Completedare two different events. - No spaces at the start or end. They are kept exactly as sent, and a name with a stray space can never match a funnel step.
- Lowercase with underscores reads best and avoids the case trap:
checkout_started,plan_selected,invite_sent. - Keep the name fixed. Anything that varies per visitor — an id, an email, a price — belongs in the properties, not in the name.
- At most 100 characters, and not one of the tracker's own names (click, outbound_click, rage_click, error_click, form_abandon, page_performance). Either is dropped, with a warning in your browser console.
Already sent for you
| Event | What it is | Needs |
|---|---|---|
click | A click on a link or a button. | Click tracking |
outbound_click | A click on a link leaving your domain. | Click tracking |
rage_click | Three or more clicks on the same element within a second. | Friction signals + click tracking |
error_click | A JavaScript error within two seconds of a click. | Friction signals |
form_abandon | A form someone typed into and left without submitting. | Friction signals |
page_performance | Load timings, once per page load. | Friction signals |
Timing and excluded paths
A call made while the tracker is still starting is held and sent once it is ready. With npm it can even come before init(). Events sent from a page that matches one of this project's excluded paths are dropped, the same as pageviews. If an event never arrives, check the page it fires on against your excluded paths in settings.
Recordings & privacy
Session replays use the analytics install — no extra code. Turn on “Enable session recordings” in the project settings and choose a sample rate.
What is hidden
- “Mask input values” — typed values are replaced with blocks. On by default.
- “Mask all text” — every word on the page is replaced with blocks.
- “Custom mask selectors” — mask only the elements you list.
- “Discard recordings shorter than” and “Maximum recording length” bound what is stored.
Keep an element out entirely
Add data-userlily-block to any element
<!-- Never captured: replaced by an empty box
of the same size in every recording -->
<div data-userlily-block>
Account number, medical details...
</div>Feedback widget
A floating Feedback button that opens your campaign’s form — with optional screenshots. Fields, colour and position are set on the campaign.
Paste before the closing </body> tag
<script src="https://dev.userlily.com/v1/widget.js" data-campaign-id="YOUR_CAMPAIGN_KEY" async> </script>
Or share a link
Every campaign also has a hosted feedback page — nothing to install.
Hosted feedback page
https://dev.userlily.com/f/YOUR_CAMPAIGN_KEY
Web push
Send notifications to visitors who opt in. Two steps, and both are needed before anyone can subscribe.
1. Host the service worker on your own domain
Browsers only register a service worker served from the page’s own domain, so this one file lives on your server — whichever way you install. Each rule below fails silently if it is not met:
- At your site root: https://your-site/userlily-sw.js.
- Served as JavaScript (Content-Type text/javascript).
- No redirect. Sign-in middleware (Next.js, Clerk, NextAuth) is the usual cause — exclude /userlily-sw.js from it.
- Over HTTPS. Plain http only works on localhost.
Script tag: download it from
https://dev.userlily.com/v1/userlily-sw.js
@userlily/js: copy it out of the package (re-run after updates)
npx --no userlily copy-sw public
2. Add it to every page
Paste before the closing </body> tag
<script defer data-project="YOUR_PROJECT_KEY" src="https://dev.userlily.com/v1/broadcast.js"></script>
3. Choose when visitors are asked
Set on the project’s Settings page, under “When to ask”:
- As soon as the page loads
- After a delay
- After scrolling down the page
- Only when your own code asks
To ask from your own code, keep the prompt switched on and choose “Only when your own code asks”:
With the script tag
var button = document.querySelector('#notify-me')
button.addEventListener('click', function () {
// Exists once broadcast.js has started, and only
// when "When to ask" is "Only when your own code asks".
if (window.userlilyBroadcast) window.userlilyBroadcast.prompt()
})Public roadmap
Show your roadmap with voting, comments and idea submissions. Turn on “Enable public roadmap” in Roadmap → Settings, then use Share on the board.
Embed: paste where the roadmap should appear
<iframe
data-userlily-roadmap
src="https://dev.userlily.com/r/YOUR_ROADMAP_KEY?embed=1"
title="Product roadmap"
style="width:100%;height:720px;border:0"
loading="lazy"
></iframe>
<script>
(function () {
window.addEventListener("message", function (event) {
if (event.origin !== "https://dev.userlily.com") return;
var data = event.data;
if (!data || data.type !== "userlily:roadmap:height") return;
var frame = document.querySelector("iframe[data-userlily-roadmap]");
if (frame && data.height > 0) frame.style.height = data.height + "px";
});
}());
</script>An iframe, so there is no npm step — it works the same in any framework. The small script only resizes the frame to fit; in a React or Vue component you can render the iframe with a fixed height instead. An Allowed domain on the roadmap limits which sites can embed it.
Or share the link
https://dev.userlily.com/r/YOUR_ROADMAP_KEY
Surveys
Surveys are shared as a link — open your survey and use Share. There is no embed or npm step.
Survey link
https://dev.userlily.com/s/YOUR_SURVEY_KEY
Frameworks and languages
A complete setup for your stack — where the keys go, the one file that starts userlily, and every product.
Server-rendered
Websites & CMS
Logos are trademarks of their owners and are shown only to indicate compatibility.
@userlily/js reference
Everything the package exports. It never throws into your app unless you ask it to, and does nothing during server rendering.
init(options)
| Option | Required | What it does |
|---|---|---|
host | Yes | Your userlily server, here https://dev.userlily.com. https only (http for localhost); no query string; must not end in /v1. |
analytics.trackingId | — | Starts analytics. |
analytics.debug | — | Logs every tracker request and reply to the console. |
widget.campaign | — | Starts the feedback widget. |
broadcast.project | — | Starts web push. Needs host at a domain root, not a path. |
broadcast.swPath | — | Where you serve the worker, if not /userlily-sw.js. |
strict | — | Throw instead of logging when something is wrong. Default false. |
Returns { ok: true, skipped: [] }. A product configured without its key is skipped with a warning and listed in skipped while the rest start. When nothing can start it returns { ok: false, reason } with one of missing_host, invalid_host, host_mismatch or server. Calling it again is safe — a running product is never started twice.
Other exports
| Export | What it does |
|---|---|
track(name, properties?) | Sends a custom event. Safe before init(); never throws. |
mountSyndication({ campaign, target, host? }) | Renders a review badge into target (emptied first) and returns a cleanup that removes it. |
broadcast.prompt() | Shows the push opt-in. Resolves true when shown, false when it cannot be. Never rejects. |
userlily copy-sw [dir] | Command line: copies userlily-sw.js into dir (default public). |
Troubleshooting
Most problems announce themselves in the browser console with a [userlily] prefix.
| You see | Why | Fix |
|---|---|---|
| “host is required” or “host … is not an absolute URL” | The npm package was given no host, or a malformed one. | Set NEXT_PUBLIC_USERLILY_HOST to the full URL, including https://, then rebuild. |
| “domain mismatch” | The page’s domain is not the project’s Allowed domain. Preview deployments (*.vercel.app) hit this. | Correct the Allowed domain, or leave the host unset on preview builds. |
| “loaded twice … ignoring the second copy” | The script tag and the npm package are both installed. | Keep one of them. |
| Nothing at all, not even a console message | A Content-Security-Policy or an ad blocker stopped the script loading. | Allow your userlily host in script-src and connect-src. |
| A custom event never arrives | It fired on an excluded path, used a reserved name, or the visitor has Do Not Track on. | Check the console warning and your project’s excluded paths. |
| The push prompt never appears | The worker is not at the root, is redirected, or the page is not HTTPS — or the visitor already blocked notifications. | Check the three serving rules above; Broadcast → Settings can test your worker. |
| Changed an env var, nothing changed | NEXT_PUBLIC_* and VITE_* values are built in when the site compiles. | Redeploy after changing them. |