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 withBest forUpdates
Script tagAny website — HTML, WordPress, Webflow, Shopify. No build step.Automatic
npm — @userlily/jsApps 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.

ProductKeyWhere to find itnpm env var
All productsHosthttps://dev.userlily.comNEXT_PUBLIC_USERLILY_HOST
AnalyticsTracking IDAnalytics → your project → CodeNEXT_PUBLIC_USERLILY_TRACKING_ID
Feedback widgetCampaign keyCampaigns → your campaign → WidgetNEXT_PUBLIC_USERLILY_CAMPAIGN
Review badgeCampaign keyCampaigns → your campaign → SyndicationPassed per badge
Web pushProject keyBroadcast → your project → SettingsNEXT_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_completed and Signup_Completed are 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

EventWhat it isNeeds
clickA click on a link or a button.Click tracking
outbound_clickA click on a link leaving your domain.Click tracking
rage_clickThree or more clicks on the same element within a second.Friction signals + click tracking
error_clickA JavaScript error within two seconds of a click.Friction signals
form_abandonA form someone typed into and left without submitting.Friction signals
page_performanceLoad 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

Review badge

A live rating badge built from your campaign’s reviews. Switch on “Enable syndication” and pick a style on the campaign’s Syndication page.

Paste where the badge should appear

<script src="https://dev.userlily.com/v1/syndication.js" data-token="YOUR_CAMPAIGN_KEY" async></script>

The script tag renders the badge exactly where the tag sits; with npm it renders inside the element you pass as target. Several badges for different campaigns can share a page.

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.

JavaScript frameworks

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)

OptionRequiredWhat it does
hostYesYour 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

ExportWhat 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 seeWhyFix
“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 messageA 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 arrivesIt 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 appearsThe 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 changedNEXT_PUBLIC_* and VITE_* values are built in when the site compiles.Redeploy after changing them.