Vue & Nuxt
The script tag on its own is a working installation. Loading it from your application instead buys you two things: the key lives in your environment config, and the widget can be told who is signed in.
1. Configure the project
Both values are public. Your own key is on the Chat widget screen; add http://localhost:5173 to the allowed origins there too, or the widget will stay invisible while you develop.
# .env.local — both values are safe in browser code.
VITE_HELPWING_SRC=https://api.helpwing.app/widget.js
VITE_HELPWING_PROJECT_KEY=pk_your_project_key2. A typed loader
One module owns the script tag, the call queue and the types. The script is loaded async, so window.Support usually does not exist yet when your app first calls it. Rather than waiting for onload, push the calls onto window.Support.q: the widget replays that queue the moment it boots, so nothing is lost and nothing has to be retried.
// 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. It 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 almost never exists 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. Load it after mount
The widget is not part of your UI, so it goes last and blocks nothing.
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { loadSupportWidget } from './support'
createApp(App).mount('#app')
// After mount: the widget is not part of your UI and should never delay it.
loadSupportWidget()4. Follow the signed-in user
A watcher rather than a one-off call at boot: your user is usually still loading when the app mounts, and the same watcher then covers signing out and switching accounts.
// src/support-identity.ts
import { watch } from 'vue'
import { storeToRefs } from 'pinia'
import { support } from './support'
import { useAuthStore } from './stores/auth'
/** Call once, from a component that lives for the whole session. */
export function useSupportIdentity(): void {
const { user } = storeToRefs(useAuthStore())
watch(
user,
(current) => {
// identify(null) is how the widget forgets someone. There is no
// separate reset, and the visitor's history stays in their browser.
if (!current) return support.identify(null)
support.identify({
id: current.id,
email: current.email,
name: current.name,
userHash: current.supportUserHash,
metadata: { plan: current.plan, accountId: current.accountId },
})
},
{ immediate: true },
)
}userHash is what makes the claim trustworthy, and your server has to produce it — Identifying users covers how, and what changes when you turn verification on. Leave the field out until you have it; the widget will simply treat the identity as unproved.
Nuxt
Same idea, one file, and one rule: client only. A .client.ts plugin runs in the browser alone. Adding the tag through useHead instead would put it in the server-rendered HTML, where it is cached for every visitor and boots before the app knows who is reading.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
widget: {
src: 'https://api.helpwing.app/widget.js',
projectKey: 'pk_your_project_key',
},
},
},
})// plugins/support.client.ts — .client, never on the server
export default defineNuxtPlugin(() => {
const { widget } = useRuntimeConfig().public
if (!widget.projectKey) return
window.Support ??= { q: [] }
const script = document.createElement('script')
script.async = true
script.src = widget.src
script.dataset.project = widget.projectKey
document.head.appendChild(script)
const auth = useAuthStore()
watch(
() => auth.user,
(user) => {
const payload = user
? {
id: user.id,
email: user.email,
name: user.name,
userHash: user.supportUserHash,
metadata: { plan: user.plan },
}
: null
const identify = window.Support?.identify
if (identify) identify(payload)
else window.Support?.q?.push(['identify', payload])
},
{ immediate: true },
)
})Opening it from your own UI
A support link in a menu, an empty state, a failed payment — anywhere the launcher is not the obvious next step.
<script setup lang="ts">
import { support } from '@/support'
</script>
<template>
<button type="button" @click="support.open()">
Talk to us
</button>
</template>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 do not use it to drive UI of your own.
Before you ship
- One tag per page. The script refuses to boot twice, but a second tag is still downloaded and parsed. Guard on document.querySelector('script[data-project]') if a hot reload can run your loader again.
- Never on the server. There is no document during SSR, and identity belongs to a request, not to a render cache.
- The secret stays on the server. Ship the hash, never the key that produced it. Anything in globalThis._importMeta_.env.VITE_* 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.