Next.js

Install the package, add one client component to your root layout, and every product starts on every page.

Installed with the @userlily/js npm package · Written for Next.js 15, App Router

What works

ProductStatusNote
Analytics & custom eventsSupported
Feedback widgetSupported
Review badgeSupported
Web pushSupported
Roadmap embedSupported
Feedback, survey & roadmap linksSupported

1. Install the package

Terminal

npm install @userlily/js

2. Add your keys

Only the keys for products you use - the host is always needed. On Vercel, set them for Production and Preview, then redeploy.

.env.local

NEXT_PUBLIC_USERLILY_HOST=https://dev.userlily.com
NEXT_PUBLIC_USERLILY_TRACKING_ID=YOUR_TRACKING_ID
NEXT_PUBLIC_USERLILY_CAMPAIGN=YOUR_CAMPAIGN_KEY
NEXT_PUBLIC_USERLILY_BROADCAST_KEY=YOUR_PROJECT_KEY

3. Create the userlily component

One client component starts every product with a single init() call and renders nothing. Delete the products you do not use. React Strict Mode running it twice is fine.

components/userlily.jsx

'use client'
import { useEffect } from 'react'
import { init } from '@userlily/js'

export default function Userlily() {
  useEffect(() => {
    init({
      host: process.env.NEXT_PUBLIC_USERLILY_HOST,
      analytics: {
        trackingId: process.env.NEXT_PUBLIC_USERLILY_TRACKING_ID,
      },
      widget: {
        campaign: process.env.NEXT_PUBLIC_USERLILY_CAMPAIGN,
      },
      broadcast: {
        project: process.env.NEXT_PUBLIC_USERLILY_BROADCAST_KEY,
      },
    })
  }, [])
  return null
}

4. Add it to your root layout

Before {children}, so it starts first. The layout stays a server component - only this small component runs in the browser. Page changes are recorded automatically.

app/layout.js

import Userlily from '@/components/userlily'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Userlily />
        {children}
      </body>
    </html>
  )
}

5. Send custom events

track() runs in the browser and does nothing on the server. After a Server Action succeeds, call it from the client component that submitted it.

components/signup-button.jsx

'use client'
import { track } from '@userlily/js'

export default function SignupButton() {
  return (
    <button onClick={() => track('signup_completed')}>
      Sign up
    </button>
  )
}

6. Show the review badge

Place <ReviewBadge /> where it should appear. Several badges for different campaigns can share a page.

components/ReviewBadge.jsx

// components/ReviewBadge.jsx
// Place <ReviewBadge /> where the badge should appear.
'use client'
import { useEffect, useRef } from 'react'
import { mountSyndication } from '@userlily/js'

export default function ReviewBadge() {
  const ref = useRef(null)
  useEffect(() => {
    // Returns a cleanup that removes the badge again.
    return mountSyndication({
      // host is optional once init() has set it
      host: process.env.NEXT_PUBLIC_USERLILY_HOST,
      campaign: 'YOUR_CAMPAIGN_KEY',
      target: ref.current,
    })
  }, [])
  return <div ref={ref} />
}

7. Host the push worker

The worker must be served from your own domain at /userlily-sw.js, as JavaScript, with no redirect and over HTTPS. Each of these fails silently. Copy it into public/, and keep it out of your middleware - sign-in middleware redirecting it is the most common failure.

Terminal - re-run after updating the package

npx --no userlily copy-sw public

middleware.js - add userlily-sw.js to your matcher

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|userlily-sw.js).*)',
  ],
}

8. Ask for push permission from your own button

Optional. Resolves false when the prompt cannot be shown.

components/notify-button.jsx

'use client'
import { broadcast } from '@userlily/js'

// Shows the opt-in when "When to ask" is set to
// "Only when your own code asks".
export default function NotifyButton() {
  return (
    <button onClick={() => broadcast.prompt()}>
      Get notified
    </button>
  )
}

9. Embed the public roadmap

Turn on “Enable public roadmap” in Roadmap → Settings first.

components/roadmap-embed.jsx

'use client'
import { useEffect, useRef } from 'react'

const ORIGIN = 'https://dev.userlily.com'

export default function RoadmapEmbed() {
  const ref = useRef(null)
  useEffect(() => {
    function onMessage(event) {
      if (event.origin !== ORIGIN) return
      const data = event.data
      if (data?.type !== 'userlily:roadmap:height') return
      if (ref.current && data.height > 0) {
        ref.current.style.height = data.height + 'px'
      }
    }
    window.addEventListener('message', onMessage)
    return () => window.removeEventListener('message', onMessage)
  }, [])
  return (
    <iframe
      ref={ref}
      src={ORIGIN + '/r/YOUR_ROADMAP_KEY?embed=1'}
      title="Product roadmap"
      loading="lazy"
      style={{ width: '100%', height: 720, border: 0 }}
    />
  )
}

Notes

  • Pages Router: render <Userlily /> in pages/_app.js instead - the component is the same.
  • With a Content-Security-Policy, allow your userlily host in script-src and connect-src.

Every option, limit and troubleshooting step is in the main docs. Logos are trademarks of their owners and are shown only to indicate compatibility.