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
| Product | Status | Note |
|---|---|---|
| Analytics & custom events | Supported | |
| Feedback widget | Supported | |
| Review badge | Supported | |
| Web push | Supported | |
| Roadmap embed | Supported | |
| Feedback, survey & roadmap links | Supported |
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 }}
/>
)
}10. Share links
Every campaign, survey and public roadmap also has a hosted page - nothing to install. Find each one under Share in the dashboard.
Hosted pages
Feedback form https://dev.userlily.com/f/YOUR_CAMPAIGN_KEY Survey https://dev.userlily.com/s/YOUR_SURVEY_KEY Roadmap https://dev.userlily.com/r/YOUR_ROADMAP_KEY
Notes
- Pages Router: render
<Userlily />inpages/_app.jsinstead - the component is the same. - With a Content-Security-Policy, allow your userlily host in
script-srcandconnect-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.