Headless (bring your own UI)
The widget renders a launcher button and a panel with our copy. If you want full control, a modal that matches
your design system, a feedback form embedded in your settings page, your own copy, use the headless core instead. You get the same
submission pipeline, identity handling, and plugin support as the widget, with zero UI. @usero/sdk/headless is its own subpath
export with no widget CSS, no React, and no rrweb: about 3KB gzipped.
There is deliberately no open(), close(), or isOpen. When you own the UI, you own its state.
Install
npm install @usero/sdkRequires @usero/sdk v1.3.0 or later.
Vanilla quickstart
Create one controller at startup and wire submit to your own form:
import { createUseroFeedback } from '@usero/sdk/headless'
const usero = createUseroFeedback({
clientId: 'YOUR_CLIENT_ID',
environment: import.meta.env.MODE,
})
async function handleSubmit(rating: 1 | 2 | 3 | 4, comment: string, files: File[]) {
const screenshots = await Promise.all(files.map(file => usero.uploadScreenshot(file)))
const result = await usero.submit({ rating, comment, screenshots })
if (!result.success) {
showError(result.error)
return
}
showThanks()
}submit() validates the payload, captures page context (URL, title, referrer) automatically, runs the plugin pipeline, and POSTs
to Usero. A submission needs a rating or a non-empty comment; everything else is optional.
uploadScreenshot(file) uploads one image (10MB max) and resolves with a ScreenshotData you include in a later
submit({ screenshots: [...] }). Unlike submit, it rejects with an Error on failure, since an upload UI usually wants
try/catch per file.
React
import { useUseroFeedback } from '@usero/sdk/headless/react'
import { useState, type FormEvent } from 'react'
function FeedbackModal({ onDone }: { onDone: () => void }) {
const usero = useUseroFeedback({ clientId: 'YOUR_CLIENT_ID' })
const [comment, setComment] = useState('')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
const result = await usero.submit({ comment })
if (!result.success) {
setError(result.error ?? 'Something went wrong')
return
}
onDone()
}
return <form onSubmit={handleSubmit}>{/* your design system here */}</form>
}The hook is SSR-safe (the controller is created in an effect, never on the server), StrictMode-safe, and destroys the controller
on unmount. Options are captured on first render, except user, which stays reactive: pass the current user object (or null on
logout) and the SDK re-identifies when it changes by value. @usero/sdk/headless/react also re-exports the full headless surface,
so one import path covers the hook and the types.
Error handling
submit() resolves with { success: false, error } instead of throwing, for validation and network failures alike, so your form
can render the message directly. No try/catch needed around submit; reserve that for uploadScreenshot.
Identify your users
Declaratively, pass user (React) or getUser (vanilla) in the options. getUser is re-resolved at submit time, so a login that
happens after the controller was created is picked up without extra wiring:
createUseroFeedback({
clientId: 'YOUR_CLIENT_ID',
getUser: () => (auth.user ? { id: auth.user.id, email: auth.user.email } : null),
})Or imperatively:
usero.identify({ id: user.id, email: user.email, traits: { plan: 'pro' } })
usero.identify(null) // logout: rotates the anonymousIdPassing null is a logout: it rotates the anonymous id so the next visitor's trail does not merge into the previous person.
Identify calls are deduped, so calling on every render or route change is free when nothing changed.
Session replay with your custom UI
Pass sessionReplay() into plugins and every submission from your custom UI deep-links to the exact moment in the recording,
the same as it does with our widget. The plugin pipeline runs on every submit(), so the replay plugin attaches sessionReplayId
and replayOffsetMs for you. See Session replay for recording options and privacy defaults.
import { createUseroFeedback } from '@usero/sdk/headless'
import { sessionReplay } from '@usero/sdk/replay'
const usero = createUseroFeedback({
clientId: 'YOUR_CLIENT_ID',
plugins: [sessionReplay()],
})Warning
Custom UI inside a ShadowRoot? Call usero.notifyShadowMount(root) after attaching it, so the recorder
re-snapshots and captures your UI. Without it, your feedback form is invisible in the replay. Light-DOM UIs are recorded without
any extra call.
Options
| Option | Type | Default | Description |
|---|---|---|---|
clientId |
string |
required | Your Usero client id. See Find your clientId. |
apiUrl |
string |
'https://usero.io' |
Override the API host (self-hosted Usero). |
environment |
string |
undefined | Tag feedback with an environment. Omit for your default environment, same rule as the widget. |
metadata |
Record<string, unknown> |
undefined | Instance-wide metadata attached to every submission. Per-submission metadata passed to submit() is deep-merged over it (one level). |
plugins |
UseroPlugin[] |
undefined | Same plugin API as the widget, for example session replay. |
user |
UseroUser | null |
undefined | Declarative identity. In React this is the one reactive option. |
getUser |
() => UseroUser | null | undefined |
undefined | Callback re-resolved at submit time. Pass at most one of user / getUser. |
Controller
createUseroFeedback(options) and useUseroFeedback(options) both return a UseroFeedbackController:
| Method | Returns | What it does |
|---|---|---|
submit(payload?) |
Promise<SubmissionResponse> |
Validate, run plugins, POST. Resolves { success: false, error } on failure, never throws. |
uploadScreenshot(file) |
Promise<ScreenshotData> |
Upload one image for a later submit. Rejects on failure. |
identify(user | null) |
void |
Imperative identify. null logs out and rotates the anonymousId. Deduped. |
whenReady() |
Promise<void> |
Resolves once every plugin's onInit has settled. Immediate when there are no plugins. |
notifyShadowMount(root) |
void |
Tell recording plugins a ShadowRoot hosting your UI was mounted. |
destroy() |
void |
Run every plugin's onDestroy and inert the controller. Further submits resolve unsuccessful. |
Exported types
Everything is typed. From @usero/sdk/headless (and re-exported by @usero/sdk/headless/react):
UseroFeedbackOptions,UseroFeedbackControllerSubmitFeedbackPayload,SubmissionResponse,ScreenshotData,FeedbackSubmission,FeedbackRatingUseroUser,UseroUserTraits,UseroUserTraitValueUseroPlugin,PluginContext,PluginLogger- Helpers for advanced UIs:
validateFeedbackSubmission(and itsValidationResult) to run the same validation assubmit()before you POST, andmergePluginPatchesif you orchestrate plugins yourself.
Next
- Session replay: record sessions and deep-link each submission into the recording
- POST /api/feedback: the raw API, if you do not want the SDK at all
- Widget: the drop-in path when you do not need a custom UI