<- All posts

Usero Journal

How to Add a Feedback Widget to a React App (Build vs Buy)

Will Smith··7 min read

A feedback widget looks like a twenty-line React component until you actually ship one. Then the CSS collides, the server render disagrees with the client, and you realize nobody decided where the submission goes.

This is a build-versus-buy guide for adding a feedback widget to a React app. It is framework-generic: Vite, Create React App, or React 18 and 19. We will name the moving parts, build a real working widget from scratch so you can see what the “buy” path is actually charging for, then look at when a drop-in SDK earns its place. If you are on Next.js specifically, the App Router has its own server-component wrinkles; the Next.js feedback widget tutorial covers those.

The Four Moving Parts

Every feedback widget, hand-rolled or bought, is the same four pieces. Once you can name them, the build cost stops being mysterious and each one comes with a React-specific gotcha.

1. The trigger

A button the user clicks, usually anchored bottom-right. In React this is one fixed-position element. The gotcha is the panel it opens: render it with createPortal into document.body so a parent with overflow: hidden or a low z-index does not clip it.

2. The form

A controlled textarea, maybe a category select, a submit button. The React-specific cost is CSS isolation. Your app styles and the widget styles will fight unless you scope them. A portal alone does not isolate CSS; for real isolation you reach for a shadow DOM, which is where vendor widgets earn part of their weight.

3. Identity and attachment

Feedback with no name attached is half useful. You want the current user ID, the URL, maybe their plan. In a hand-rolled widget you pass these in as props. In a vendor SDK you call an identify() method and every later submission arrives tagged.

4. Routing

Where the submission lands after the user hits send. This is the part people skip and then regret. A POST to your own endpoint is the whole job, but the endpoint has to do something a human will actually see: a Slack message, a row in a table someone reads, a GitHub issue. Collected-but-unread feedback is worse than none, because it lets you believe you are listening.

The form is the easy 80 percent. CSS isolation, identity, and routing are the 20 percent that takes the other afternoon.

Build It Yourself

Here is a real, working widget. It is a floating button, a portal panel, a controlled textarea, Escape to close, and a fetch POST. Strict TypeScript, no shortcuts. Drop it in any React 18 or 19 app.

import { useState, useEffect, type FormEvent } from 'react'
import { createPortal } from 'react-dom'

type Props = { userId?: string }

export function FeedbackWidget({ userId }: Props) {
  const [open, setOpen] = useState(false)
  const [message, setMessage] = useState('')
  const [sending, setSending] = useState(false)
  const [done, setDone] = useState(false)

  // Esc to close. Only bind the listener while the panel is open.
  useEffect(() => {
    if (!open) return
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false)
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [open])

  async function submit(e: FormEvent) {
    e.preventDefault()
    if (!message.trim()) return
    setSending(true)
    try {
      await fetch('/api/feedback', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message,
          userId,
          url: window.location.href,
          userAgent: navigator.userAgent,
        }),
      })
      setDone(true)
      setMessage('')
    } finally {
      setSending(false)
    }
  }

  return (
    <>
      <button
        type='button'
        onClick={() => setOpen(true)}
        style={{ position: 'fixed', bottom: 20, right: 20 }}
      >
        Feedback
      </button>
      {open &&
        createPortal(
          <div role='dialog' aria-modal='true' style={{ position: 'fixed', bottom: 70, right: 20 }}>
            {done ? (
              <p>Thanks, we read every one of these.</p>
            ) : (
              <form onSubmit={submit}>
                <textarea
                  autoFocus
                  value={message}
                  onChange={e => setMessage(e.target.value)}
                  onKeyDown={e => {
                    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit(e)
                  }}
                  placeholder='What is on your mind?'
                />
                <button type='submit' disabled={sending}>
                  {sending ? 'Sending...' : 'Send'}
                </button>
                <button type='button' onClick={() => setOpen(false)}>
                  Cancel
                </button>
              </form>
            )}
          </div>,
          document.body,
        )}
    </>
  )
}

That is a usable widget. The textarea submits on Cmd or Ctrl plus Enter, the form submits on the button, Escape closes, and the panel is portalled out of any clipping parent. The matching server endpoint is just as short:

// app/api/feedback/route.ts (or any POST handler)
export async function POST(request: Request) {
  const body = await request.json()
  // Decide where it goes. The simplest honest option:
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text: `Feedback from ${body.userId ?? 'anon'}: ${body.message}` }),
  })
  return new Response(null, { status: 204 })
}

Be honest about what this widget does not do, because the gaps are the whole build-vs-buy argument:

  • No CSS isolation. Those inline styles will not collide, but the moment you want a real designed panel, your app’s global CSS reaches in. Real isolation means a shadow DOM, which is a meaningful chunk of extra code.
  • No dedup or clustering. Fifty users reporting the same bug means fifty Slack messages. You triage by hand.
  • No spam defense. That open endpoint is a public POST. You will want rate limiting, a honeypot field, or a signed token before bots find it.
  • No screenshot, no replay. You get text and a URL. For a vague “it’s broken,” that is often not enough to reproduce.

For a single static text box on a side project, none of that matters and you should not pay a vendor a cent. Ship the forty lines above and move on. The build-it case is real. Do not let anyone, including me, talk you out of it when the job is small.

Buy It: The Three-Line Version

When the gaps above start costing you real time, a drop-in SDK earns its keep. Here is the same widget using @usero/sdk, which I build, so weigh that accordingly.

import { FeedbackWidget } from '@usero/sdk/react'

export function App() {
  return (
    <>
      {/* your app */}
      <FeedbackWidget projectId='your-project-id' user={{ id: currentUser.id }} />
    </>
  )
}

The core is 7.6 KB gzipped, it lazy-mounts the panel so the form code never hits first paint, it is open source on npm, and it is strict-mode safe on React 17 through 19. The CSS isolation, the identity passthrough, the spam handling, and the clustering are all done. If you want the copy-paste version with your real keys filled in, the install the React widget page has it.

Where Usero Actually Differs

Plenty of widgets isolate CSS and cluster duplicates. The reason I built another one is downstream of collection. Usero can take a clustered feature request and open a draft pull request against your GitHub repo with a first-pass implementation. The request ends as code, not as a row on a board.

It opens as a draft. You read the diff, you decide, you hit merge. Nothing ships without you, and for a hand-rolled widget there is no equivalent: the submission lands in Slack and the rest is your afternoon. If you want to see how that path is wired, the feedback-to-PR walkthrough is the long version.

The wrong-fit case, said plainly: if your roadmap is not code, or you mostly need a polished public voting board, the PR angle buys you nothing and a board-only tool is cleaner. The honesty is the point. A wider survey of options lives in the roundup of user feedback tools.

The Rule of Thumb

Build it yourself when the widget is a text box that posts to Slack and you will read every message by hand. Buy it the moment you need CSS isolation you will not maintain, identity you will not wire, dedup you will not write, or a path from the submission to a shipped fix. The form was never the hard part. Where the feedback goes, and whether anything happens to it, always was.

Related Reading

Frequently Asked Questions

Does a feedback widget work with React 19 and the compiler?

Yes. A widget is ordinary client state plus a portal, so the React Compiler memoizes it like any other component without special handling. Keep the panel state local (useState in the widget root) so the compiler can reason about it. The one thing to avoid is reading window or document during render; do that in an effect or an event handler so server rendering and the compiler both stay happy.

How big is a feedback widget in the bundle?

A hand-rolled one is as small as the code you write, often under 2 KB, because it has no analytics, no replay, and no dedup. A full vendor SDK that adds identity, clustering, and routing is heavier: @usero/sdk is 7.6 KB gzipped for the core, and it lazy-mounts the panel so the form code never touches first paint. Anything bundling session replay by default will be much larger, so check before you install.

How do I avoid an SSR hydration mismatch?

Render the trigger button on the server (it has no dynamic state) but keep the open/closed panel state false on the first client render so the server HTML and the first client render agree. Never branch render output on window, localStorage, or a media query during the initial pass. For the App Router specifics, see the Next.js feedback widget tutorial linked below.

Does a feedback widget work in a client-only React app (Vite or CRA)?

It is the easiest case. With no server render there is no hydration to mismatch, so you can read window freely and mount the widget wherever you like. Lazy-import the panel so a user who never clicks the button never downloads the form code.

Where should feedback submissions actually go?

A hand-rolled widget POSTs to your own endpoint, and you decide: write a row to your database, post to a Slack webhook, or open a GitHub issue from the server. A vendor widget POSTs to their backend and routes from there. The routing is the part that turns collected feedback into acted-on feedback, so design it before you build the form.

Build a feedback loop your team actually uses

Usero collects, clusters, and turns user feedback into shipped fixes.

Get started free