Skip to main content

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.

bash
# .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_key

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

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      widget: {
        src: 'https://api.helpwing.app/widget.js',
        projectKey: 'pk_your_project_key',
      },
    },
  },
})
typescript
// 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.

vue
<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.
NextIdentifying users →Attach conversations to real accounts, and sign the claim so it can be trusted.