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
npm install @usero/sdk

Requires @usero/sdk v1.3.0 or later.

Vanilla quickstart

Create one controller at startup and wire submit to your own form:

Vanilla
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

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:

Vanilla
createUseroFeedback({
	clientId: 'YOUR_CLIENT_ID',
	getUser: () => (auth.user ? { id: auth.user.id, email: auth.user.email } : null),
})

Or imperatively:

Vanilla
usero.identify({ id: user.id, email: user.email, traits: { plan: 'pro' } })
usero.identify(null) // logout: rotates the anonymousId

Passing 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.

Vanilla
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, UseroFeedbackController
  • SubmitFeedbackPayload, SubmissionResponse, ScreenshotData, FeedbackSubmission, FeedbackRating
  • UseroUser, UseroUserTraits, UseroUserTraitValue
  • UseroPlugin, PluginContext, PluginLogger
  • Helpers for advanced UIs: validateFeedbackSubmission (and its ValidationResult) to run the same validation as submit() before you POST, and mergePluginPatches if 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