Next.js integration

Add an AI chatbot to Next.js

Load Heeya's embed script with next/script. There is no Heeya npm package. Use strategy="lazyOnload" in the App Router root layout or in pages/_app, keep the /agent/YOUR_AGENT_ID/embed.js URL intact, then confirm the loader and the .heeya-widget node.

By Anas R.

Create the agent used in this tutorial

What you are adding

This is a hosted, docs-grounded chat widget on your Next.js site. It is not a guide to building a streaming chat route with the Vercel AI SDK, and it is not a first-party React component library.

After you create an agent, Heeya's connect screen gives you this snippet:

<script async src="https://heeya.fr/agent/YOUR_AGENT_ID/embed.js" data-agent-name="Support"></script>

That file is a small loader. It stores document.currentScript, injects the agent's language and colors, derives the Heeya origin by splitting the script URL on /agent/, then appends /static/js/embed/bundle.js to document.body. The bundle reads the agent id from that same /agent/YOUR_AGENT_ID/embed.js path and paints a position: fixed bubble. Colors and the logo are configured on the agent, not as npm props.

If you still need to create the agent and its sources, use the no-code AI chatbot build guide first. Product fit for a SaaS help center belongs on AI customer support for SaaS. WordPress, Shopify, and Wix paste steps live on the WordPress, Shopify, and Wix embed guide.

  1. Create an agent and copy the embed URL from the connect screen.
  2. Add next/script to the App Router layout or pages/_app.
  3. Set strategy="lazyOnload" and forward data-agent-name.
  4. Confirm embed.js loaded, then query .heeya-widget.
  5. Ask a question that exists in the sources you added.

App Router with next/script

Next.js recommends next/script for third-party tags and lists chat support plugins under lazyOnload. Extra attributes such as data-agent-name are forwarded onto the real <script> element. A stable id lets Next.js load the file once when the user moves between routes that share the layout.

Root layout for every route

Put the component in app/layout.tsx when the bubble should appear on the whole site. Replace YOUR_AGENT_ID with the UUID from the connect screen. Do not rewrite the path: the loader splits script.src on /agent/.

import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          id="heeya-embed"
          src="https://heeya.fr/agent/YOUR_AGENT_ID/embed.js"
          strategy="lazyOnload"
          data-agent-name="Support"
        />
      </body>
    </html>
  )
}

Client component when you need onLoad

onLoad and onError only work in a Client Component. Keep the layout as a Server Component and render this child. Store the UUID in NEXT_PUBLIC_HEEYA_AGENT_ID so it is available in the browser bundle.

'use client'

import Script from 'next/script'
import { useState } from 'react'

export function HeeyaChatbot() {
  const agentId = process.env.NEXT_PUBLIC_HEEYA_AGENT_ID
  const [loadState, setLoadState] = useState<
    'idle' | 'script' | 'widget' | 'error'
  >('idle')

  if (!agentId) {
    return null
  }

  return (
    <>
      <Script
        id="heeya-embed"
        src={`https://heeya.fr/agent/${agentId}/embed.js`}
        strategy="lazyOnload"
        data-agent-name="Support"
        onLoad={() => {
          setLoadState('script')
          const started = Date.now()
          const timer = window.setInterval(() => {
            if (document.querySelector('.heeya-widget')) {
              window.clearInterval(timer)
              setLoadState('widget')
            } else if (Date.now() - started > 8000) {
              window.clearInterval(timer)
            }
          }, 50)
        }}
        onError={() => {
          setLoadState('error')
        }}
      />
      {loadState === 'error' ? (
        <p className="sr-only">Heeya chat widget failed to load.</p>
      ) : null}
    </>
  )
}

Import HeeyaChatbot from the root layout and render it after the layout children. loadState is for your own checks during development. The snippet only surfaces a visually hidden message when the loader request fails.

Store the agent id in an env var

In .env.local:

NEXT_PUBLIC_HEEYA_AGENT_ID=your-agent-uuid

Pages Router

Same script and the same lazyOnload strategy. Pages Router does not need 'use client'. Next.js documents application-wide tags in pages/_app, not in a custom Document, unless you are using beforeInteractive.

All routes from _app

import type { AppProps } from 'next/app'
import Script from 'next/script'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Component {...pageProps} />
      <Script
        id="heeya-embed"
        src="https://heeya.fr/agent/YOUR_AGENT_ID/embed.js"
        strategy="lazyOnload"
        data-agent-name="Support"
      />
    </>
  )
}

One page only

Drop the same Script into that page module instead of _app. Keep the id. If a visitor then client-navigates to another Pages Router route, the bubble stays on document.body until a full load of a document that never included the script.

import Script from 'next/script'

export default function DocsPage() {
  return (
    <>
      <main>{/* your docs UI */}</main>
      <Script
        id="heeya-embed"
        src="https://heeya.fr/agent/YOUR_AGENT_ID/embed.js"
        strategy="lazyOnload"
        data-agent-name="Support"
      />
    </>
  )
}

Confirm the script and the widget loaded

Two different events get mixed up. Capture both if you are checking a deploy.

onLoad is the loader, not the bubble

onLoad runs when embed.js has executed. That file is a few hundred bytes. It then creates another script tag for bundle.js (about 76 KB in the current build) and appends it to document.body. The chat UI does not exist until that second file runs. There is no documented Heeya callback when the bubble is ready.

Query .heeya-widget

After onLoad, poll for document.querySelector('.heeya-widget'). That class is what the bundle assigns to the fixed container. Use a timeout so a blocked bundle.js request does not loop forever. onError on Script only covers the loader URL, for example a bad agent id that 404s or a CSP that forbids https://heeya.fr.

If you set a Content-Security-Policy, allow https://heeya.fr in script-src and connect-src. The widget injects a <style> tag, fetches greeting and chat endpoints under /api/chat/, and may load Inter from Google Fonts.

Core Web Vitals

Do not paste a Lighthouse number from another site. Measure this origin with CrUX or your RUM. The choices below follow Next.js Script docs and how the widget is built.

LCP and lazyOnload

Largest Contentful Paint is the largest image or text block in the viewport. Google's LCP guide treats third-party work on the critical path as a delay. Next.js's lazyOnload strategy injects the tag during idle time, after other resources, which is why their API reference names chat widgets there. afterInteractive loads sooner and can contend with first-party hydration. beforeInteractive is for bot detectors and cookie banners, not this embed.

CLS

The bundle positions the launcher with position: fixed and a high z-index. It does not insert a document-flow block, so it should not shift your page's layout. The welcome popup is also an overlay. Fonts loaded for the widget affect the bubble, not your article column, unless you already share Inter.

INP

Interaction to Next Paint still sees the main thread. When bundle.js evaluates, and later when a visitor types, that work runs in the page's JavaScript context. lazyOnload moves the start of that work off first paint. It does not isolate the widget. Next.js's experimental worker strategy is unstable, does not work with the App Router, and would be the wrong place for a script that must touch document.body and document.currentScript.

If you are weighing a custom RAG stack in the Next.js app instead of a widget, use the custom AI chatbot build vs buy guide. That decision is not this install.

FAQ about adding an AI chatbot to Next.js

Does Heeya ship a Next.js npm package?

No. Heeya is a website widget: one script tag that loads the chat UI. In Next.js you load that script with next/script. There is no public Heeya package to install.

Which next/script strategy should I use?

lazyOnload. Next.js documents chat support plugins as a lazyOnload example: the script is injected during browser idle time after other resources. afterInteractive also works if you want the bubble sooner. Do not use beforeInteractive.

Why not beforeInteractive?

Heeya's loader reads document.currentScript, then appends bundle.js to document.body. beforeInteractive scripts are injected into document head and run before page hydration. document.body can be missing at that moment, and a chat widget is not a critical script Next.js lists for that strategy.

How do I know the chatbot loaded?

next/script onLoad (in a Client Component) fires when embed.js, the loader, has finished. The loader then requests bundle.js. The visible bubble is a .heeya-widget node on document.body. Treat onLoad as loader fetched and querySelector('.heeya-widget') as UI mounted. There is no official Heeya ready event.

Will this hurt Core Web Vitals?

Measure on your site. lazyOnload keeps the widget off the critical path, which is the lever for LCP. The UI is position:fixed, so it should not shift page layout (CLS). The widget still uses the main thread when it loads and when someone chats, which can affect INP. Do not copy a lab score from another domain.

Does the widget survive client-side navigations?

Yes. The script runs once (give it a stable id). It appends a node to document.body, outside the Next.js layout that swaps on navigation. Heeya does not expose a destroy method. Navigating away from a page that mounted the script does not remove the bubble until a full reload of a document that never loaded it.

Can I show it on only some routes?

Put Script in that segment's layout or page, not in the root layout. Client-side navigation from a page that already loaded the widget will leave the bubble in place. A full load of a route without the script will not show it.

What is the difference between App Router and Pages Router here?

Same script, same strategy. App Router: root or nested layout, and a Client Component if you need onLoad. Pages Router: pages/_app for every route, or one page file. Do not put this widget in pages/_document with beforeInteractive.

Create the agent, then paste the Next.js script

Register, add the documentation the widget should retrieve, copy the agent id from the connect screen, and use the lazyOnload snippet above.

Create the agent used in this tutorial