Userz

@userz-ai/react

Idiomatic React bindings around @userz-ai/browser. A provider, a hook, and a component-targeting wrapper.

Install

Bash
pnpm add @userz-ai/react @userz-ai/browser
# Optional peer for the in-widget annotation editor
pnpm add tldraw

React 18 and 19 are both supported.

Five-minute quick start

Wrap your tree, then call methods from anywhere underneath:

TSX
// app/Userz.tsx, Next.js client component
'use client';
import { UserzProvider } from '@userz-ai/react';

export function Userz({ children }: { children: React.ReactNode }) {
  return (
    <UserzProvider publicKey="pub_GLLHdYCLb2A2d9ieY2LmmwRJ">
      {children}
    </UserzProvider>
  );
}
TSX
// Anywhere underneath:
import { useUserz } from '@userz-ai/react';

function Header() {
  const userz = useUserz();
  return <button onClick={() => userz.open()}>Send feedback</button>;
}

<UserzProvider>

Wraps your React tree, owns one Userz instance for the lifetime of the component, tears it down on unmount.

Props

Accepts every UserzConfig field from @userz-ai/browser (publicKey, getUserToken, bubble, showEmailField, consoleCapacity, captureErrors, targetingChord, mode, initialUser, plus the four theming props brandColor / brandForeground / backgroundColor / textColor — see browser docs › Theming) plus React's children.

Config changes after the first render are ignored, same pattern as Sentry / Datadog SDKs. Use setUser() / setMetadata() via the useUserz() hook for runtime updates.

useUserz()

Returns the singleton Userz instance. Throws if called outside a <UserzProvider>.

TSX
import { useUserz } from '@userz-ai/react';

function FeedbackButton() {
  const userz = useUserz();
  return <button onClick={() => userz.open()}>Send feedback</button>;
}

<UserzTarget>

Mark a child element as a "feedback target" the end-user can click on while the targeting overlay is active (Ctrl+Shift+U by default). On click, the panel opens pre-filled with the target's name and an opt-in meta payload, plus a cropped screenshot of just that element.

TSX
import { UserzTarget } from '@userz-ai/react';

<UserzTarget name="CheckoutButton" meta={{ variant, plan: 'pro' }}>
  <button onClick={onCheckout}>Checkout</button>
</UserzTarget>

Props

NameTypeDescription
name*stringDisplay name surfaced in feedback as the targeted-component label.
metaRecord<string, unknown>Opt-in metadata forwarded with feedback. Keep it small + non-sensitive, it ships to our backend in plaintext.
children*ReactNodeA SINGLE child element. We clone it and attach a ref, no wrapper div, layout is unchanged.
The child must accept a ref. DOM elements (<button>, <div>) and forwardRef components are fine. Plain function components without forwardRef get an invisible display: contents wrapper, your layout still works, but you should forwardRef when possible.

Recipe: bridging your auth state

The setUser() call associates submissions with your user record in public mode. Mount this once near the top of your app:

TSX
import { useEffect } from 'react';
import { useUserz } from '@userz-ai/react';

export function UserzIdentityBridge({ user }: { user: User | null }) {
  const userz = useUserz();
  useEffect(() => {
    userz.setUser(
      user ? { externalUserId: user.id, email: user.email, name: user.name } : null,
    );
  }, [user, userz]);
  return null;
}

Recipe: route + build metadata

Stamping every submission with the current route, build SHA, and feature-flag set gives the AI agent better context when reproducing the issue.

TSX
import { useEffect } from 'react';
import { useLocation } from 'react-router';        // or 'next/navigation'
import { useUserz } from '@userz-ai/react';

export function UserzRouteMetadata() {
  const userz = useUserz();
  const location = useLocation();
  useEffect(() => {
    userz.setMetadata({
      route: location.pathname,
      build: import.meta.env.VITE_BUILD_SHA,
      flags: featureFlags.activeIds(),
    });
  }, [location.pathname, userz]);
  return null;
}

Recipe: programmatic submit from an error boundary

Pair Userz with your existing error boundary so a "Report this" button just works:

TSX
import { useUserz } from '@userz-ai/react';

function ErrorFallback({ error }: { error: Error }) {
  const userz = useUserz();
  const report = async () => {
    await userz.submit({
      text: `Crash: ${error.message}\n\n${error.stack ?? ''}`,
      severity: 'high',
      withScreenshot: true,
    });
  };
  return (
    <div>
      <p>Something went wrong.</p>
      <button onClick={report}>Report this</button>
    </div>
  );
}

Recipe: private mode with refresh-on-demand

Have your /me endpoint return a freshly-minted Userz token alongside the user, and feed it through getUserToken:

TSX
'use client';
import useSWR from 'swr';
import { UserzProvider } from '@userz-ai/react';

export function Userz({ children }: { children: React.ReactNode }) {
  const { data } = useSWR('/me', fetcher);
  return (
    <UserzProvider
      publicKey="pub_..."
      getUserToken={() => data?.userzToken}
    >
      {children}
    </UserzProvider>
  );
}

See @userz-ai/node for the matching mintUserToken server snippet.

SSR / Next.js

The widget is browser-only. Mount the provider in a client component:

TSX
// app/Userz.tsx
'use client';
import { UserzProvider } from '@userz-ai/react';
export function Userz({ children }: { children: React.ReactNode }) {
  return (
    <UserzProvider publicKey="pub_...">
      {children}
    </UserzProvider>
  );
}

// app/layout.tsx, server component is fine here
import { Userz } from './Userz';
export default function Layout({ children }: { children: React.ReactNode }) {
  return <html><body><Userz>{children}</Userz></body></html>;
}
The Pages Router works the same way, wrap <Component /> in pages/_app.tsx with the same provider. No special config needed.

<ScreenshotEditor>

Annotated screenshot editor, wraps tldraw to give the end-user a quick "circle the broken thing, drop an arrow, save" workflow before submitting feedback. The tldraw bundle is dynamic-imported on first mount, so apps that never open the editor don't pay for it.

TSX
import { useState } from 'react';
import { ScreenshotEditor, useUserz } from '@userz-ai/react';

function CapturedShot({ blob }: { blob: Blob }) {
  const userz = useUserz();
  const [open, setOpen] = useState(true);
  if (!open) return null;
  return (
    <ScreenshotEditor
      blob={blob}
      onSave={async (annotated) => {
        // Upload + submit using the widget's pipeline.
        await userz.submit({
          text: 'See annotation',
          attachments: [
            // Hand the annotated PNG into your own upload-url + submit flow,
            // or pass it back through userz.captureScreenshot()/submit(),
            // see @userz-ai/browser for the full SubmitInput shape.
          ],
        });
        setOpen(false);
      }}
      onCancel={() => setOpen(false)}
    />
  );
}

Props

NameTypeDescription
blob*BlobPNG / JPEG blob to seed the canvas with (typically from userz.captureScreenshot()).
widthnumberOverride the canvas width. Defaults to the blob's natural pixel width.
heightnumberOverride the canvas height. Defaults to natural height.
onSave*(annotated: Blob) => void | Promise<void>Receives the annotated PNG. Plug into userz.submit() or your own upload flow.
onCancel() => voidOptional dismiss handler, renders a Cancel button when supplied.
styleCSSPropertiesInline style override on the editor container.
classNamestringCSS class on the container.
brandColorstringSave-button background. Defaults to Userz mint #5ccfb4. Matches the bubble widget theming token.
brandForegroundstringSave-button text color. Defaults to #0c2a24.
backgroundColorstringCancel-button surface. Defaults to white.
textColorstringCancel-button text color. Defaults to #111.
tldraw is declared as an optional peer dependency. If your app uses <ScreenshotEditor> you need to install it explicitly: pnpm add tldraw. If it's missing, the component renders an instructive fallback instead of throwing, apps that never open the editor pay no bundle cost.

Testing

The provider creates real DOM and intercepts console.*. For unit tests, replace the provider with a stub so your components don't try to mount the widget under jsdom:

TSX
// test-utils/userz.tsx
import { createContext } from 'react';
import type { Userz } from '@userz-ai/browser';
import { vi } from 'vitest';

const MockCtx = createContext<Userz | null>(null);
export const mockUserz: Userz = {
  open: vi.fn(), close: vi.fn(), toggle: vi.fn(),
  setUser: vi.fn(), setMetadata: vi.fn(),
  submit: vi.fn().mockResolvedValue({ id: 'feedback_test', status: 'received' }),
  captureScreenshot: vi.fn(),
  setTargetingEnabled: vi.fn(), registerTarget: vi.fn(() => () => {}),
  destroy: vi.fn(),
};

export function MockUserzProvider({ children }: { children: React.ReactNode }) {
  return <MockCtx.Provider value={mockUserz}>{children}</MockCtx.Provider>;
}

Bundle impact

@userz-ai/react is ~3 KB gzip on top of @userz-ai/browser. The screenshot path (modern-screenshot) is dynamic-imported on first call to captureScreenshot() or submit({ withScreenshot: true }), so apps that never take a screenshot don't pay for it.