React
The script tag in your index.html is a complete installation and needs none of this. Loading it from React instead buys you two things: the key lives in your environment config, and the widget knows which of your users is reading.
1. Configure the project
Both values are public. Your own key is on the Chat widget screen of your workspace — copy it from there, along with the script URL for the deployment you are on. While you are there, add http://localhost:5173 to the allowed origins, or the widget will stay invisible for the whole time you are developing.
# .env.local — both values are public, and both end up in the bundle.
VITE_HELPWING_SRC=https://api.helpwing.app/widget.js
VITE_HELPWING_PROJECT_KEY=pk_your_project_key2. The loader
Nothing React-specific: one module owning the script tag, the call queue and the types. The tag is async, so window.Support usually does not exist yet when your first effect runs — pushing onto window.Support.q is what makes that a non-problem. The JavaScript guide explains the queue in full.
// src/support.ts
export interface SupportIdentity {
/** Your own user id. Max 120 characters. */
id: string
email?: string
/** Max 150 characters. */
name?: string
/** Hex HMAC-SHA256 of `id`, computed on your server. Never in the browser. */
userHash?: string
metadata?: Record<string, string | number | boolean>
}
interface SupportApi {
identify(identity: SupportIdentity | null): void
open(): void
close(): void
show(): void
hide(): void
/** Calls made before the script booted. The widget replays them on load. */
q?: [string, unknown][]
}
declare global {
interface Window {
Support?: Partial<SupportApi>
}
}
const SRC = globalThis._importMeta_.env.VITE_HELPWING_SRC
const PROJECT_KEY = globalThis._importMeta_.env.VITE_HELPWING_PROJECT_KEY
/** Adds the script tag. Safe to call twice; the second call does nothing. */
export function loadSupportWidget(): void {
if (typeof document === 'undefined' || !PROJECT_KEY) return
if (document.querySelector('script[data-project]')) return
// The tag is async, so window.Support usually does not exist yet when the
// first identify() runs. Seed the queue the widget drains on boot.
window.Support ??= { q: [] }
const script = document.createElement('script')
script.async = true
script.src = SRC
script.dataset.project = PROJECT_KEY
document.head.appendChild(script)
}
function invoke(method: keyof SupportApi, payload?: unknown): void {
const support = window.Support
if (!support) return
const fn = support[method]
if (typeof fn === 'function') (fn as (arg?: unknown) => void)(payload)
else support.q?.push([method, payload])
}
export const support = {
identify: (identity: SupportIdentity | null) => invoke('identify', identity),
open: () => invoke('open'),
close: () => invoke('close'),
show: () => invoke('show'),
hide: () => invoke('hide'),
}3. A hook
Loading on mount and identifying on change are two different lifetimes, so they are two effects.
// src/useSupportWidget.ts
import { useEffect } from 'react'
import { loadSupportWidget, support } from './support'
import type { SupportIdentity } from './support'
/**
* Call once, from a component that lives for the whole session — the shell
* around your router, not a page.
*
* Two effects rather than one: the script is added on mount and never again,
* while the identity is re-sent whenever it changes. Neither returns a
* cleanup. There is nothing to unmount — the widget is not part of your tree,
* and removing its tag would throw away the visitor's open conversation.
*/
export function useSupportWidget(identity: SupportIdentity | null): void {
useEffect(() => {
loadSupportWidget()
}, [])
useEffect(() => {
// identify(null) is how the widget forgets someone. There is no separate
// reset, and the visitor keeps their history either way.
support.identify(identity)
}, [identity])
}Strict Mode
In development, React mounts every component twice, so loadSupportWidget is called twice on the first render. The querySelector('script[data-project]') guard in the loader is what keeps that from adding a second tag. The widget also refuses to boot twice on its own, so the worst case is a wasted download rather than two launchers — but the guard costs nothing and removes the question.
4. Use it once, at the top
// src/App.tsx
import { useMemo } from 'react'
import { useAuth } from './auth'
import { useSupportWidget } from './useSupportWidget'
export default function App() {
const { user } = useAuth()
// Memoised: a fresh object literal on every render is a new dependency
// every render, and the effect above would fire each time. The widget
// deduplicates identical payloads before it talks to the server, so this
// is about your own render loop rather than about request volume.
const identity = useMemo(
() =>
user
? {
id: user.id,
email: user.email,
name: user.name,
userHash: user.supportUserHash,
metadata: { plan: user.plan },
}
: null,
[user],
)
useSupportWidget(identity)
return <Router />
}userHash is what turns a claimed identity into a proved one, and your server has to produce it — Identifying users covers how. Leave the field out until you have it; the widget treats the identity as unproved rather than rejecting it.
Opening it from your own UI
// Anywhere the launcher is not the obvious next step — a support entry in a
// menu, an empty state, a failed payment.
import { support } from '../support'
export function TalkToUs() {
return (
<button type="button" onClick={() => support.open()}>
Talk to us
</button>
)
}open() is ignored until the widget has loaded its configuration, and a disabled widget or a blocked origin never loads one. The call is always safe, but never drive UI of your own from it — there is no callback saying the panel opened.
Next.js
Two rules, and the rest follows. The component that touches window is a client component, and the tag goes in the root layout so one boot covers every route. next/script with strategy="lazyOnload" replaces the loader module: it adds the tag after the page is interactive, which is exactly where chat belongs.
// app/support-widget.tsx
'use client'
import Script from 'next/script'
import { useEffect } from 'react'
import { useSession } from './session'
export function SupportWidget() {
const { user } = useSession()
useEffect(() => {
const payload = user
? { id: user.id, email: user.email, name: user.name, userHash: user.supportUserHash }
: null
// The Script below has almost certainly not run yet on first paint, so
// seed the queue here rather than assuming window.Support is the API.
window.Support ??= { q: [] }
const identify = window.Support.identify
if (identify) identify(payload)
else window.Support.q?.push(['identify', payload])
}, [user])
return (
<Script
src={process.env.NEXT_PUBLIC_HELPWING_SRC!}
data-project={process.env.NEXT_PUBLIC_HELPWING_PROJECT_KEY!}
strategy="lazyOnload"
/>
)
}// app/layout.tsx — a server component, which is why the one above says
// 'use client'. Mounted in the layout, the widget survives every navigation.
import { SupportWidget } from './support-widget'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<SupportWidget />
</body>
</html>
)
}In the Pages Router the same component goes in pages/_app.tsx and the 'use client' line is unnecessary. Either way, do not render the tag from _document or from a server component: it would land in server-rendered HTML that is cached for every visitor and booted before your app knows who is reading.
Before you ship
- Mount it once. At the shell, above your router — not in a page, where every navigation would remount it.
- Memoise the identity. A fresh object literal each render is a new dependency each render.
- No cleanup function. The widget outlives your tree on purpose; removing its tag would take an open conversation with it.
- The secret stays on the server. Ship the hash, never the key that produced it. Anything in globalThis._importMeta_.env.VITE_* or process.env.NEXT_PUBLIC_* is public by definition.
- Allowed origins include your dev host. A blocked origin fails silently and deliberately — the widget stays invisible rather than breaking the page it is on.