The LayBuild widget is a plain script tag that injects a launcher button and an iframe. There is no React component to install, so in a Next.js App Router app the right move is to render it once from a layout with next/script and strategy="lazyOnload", and, if your users are signed in, to compute the identity hash in a Server Component so the widget secret never reaches the browser. The rest of this post explains what the script does, so you can reason about performance, CSP and routing instead of copying a snippet blind.

What widget.js actually does

When widget.js runs, it reads its configuration from its own data-* attributes (or from a window.AIChatConfig object if you prefer), then does four things:

  • Adds a <style> element for the launcher and panel.
  • Creates an <iframe> pointing at /embed on the LayBuild origin, passing your widget key, agent, colour, title, greeting and the current page URL and title as query parameters.
  • Adds a launcher button (and an optional "Chat with AI" pill) fixed to the bottom corner.
  • Exposes a small API on window.LayBuild (also window.AIChat): open(), close(), toggle(), isOpen() and send(text).

The chat itself runs inside the iframe, on LayBuild's origin. It talks to the LayBuild API over HTTPS and Socket.io, and it talks to your page only through postMessage: the loader tells it when the panel opens or closes, and the iframe only accepts messages from the parent window at the origin the loader reported. Your page's JavaScript cannot read the conversation, and the conversation's scripts cannot touch your DOM.

text
 Your page                                   LayBuild
 +------------------------------+           +-----------------------------------+
 | widget.js (loader)           |  iframe   | /embed (chat UI)                  |
 |  - launcher button, styles   | --------> |  - POST /api/widget/session       |
 |  - window.LayBuild API       | <-------> |  - Socket.io messages             |
 |                              | postMessage|                                  |
 +------------------------------+           +-----------------------------------+
                                                     |
                                                     v
                                  Widget guard: subscription active, widget enabled,
                                  Origin/Referer against Allowed Domains, rate limit

Two details matter for performance. First, the iframe is created with its src set as soon as the loader runs, not when the visitor opens the panel, so the embed page loads in the background on every page view that runs the script. Second, the loader guards against running twice with a global flag, so a second copy of the script on the same page does nothing. embed.js is a tiny alternative loader that copies its own data-* attributes onto a new widget.js script tag; loading widget.js directly saves that extra request.

What the server checks before a chat starts

Both the widget config request and the session request go through the same guard on the API. It resolves your workspace from the widget key (keys start with wkey_), refuses with a 402 if the workspace subscription is not active, refuses with a 403 if an admin has disabled the widget, and then compares the request's Origin or Referer header with the Allowed Domains list in your widget settings. The default list is *, which allows any site. Rules can be exact hostnames or wildcards like *.example.com, and requests from localhost, 127.0.0.1 and private 10.x and 192.168.x addresses always pass, so local development works without changes. Session creation is also rate limited per IP, 60 requests a minute by default.

Treat the domain list as a guard against casual reuse of your snippet, not as authentication. A header check stops a browser on another site from opening a chat under your key; it does not stop someone who scripts requests from a server and sets whatever headers they like. If you need to know who a visitor is, use identity verification, below.

Adding it to an App Router layout

Put the script in the layout that covers every page where the widget should appear. next/script works in Server Components as long as you do not pass onLoad, onReady or onError, which need a Client Component. It forwards data-* props to the script element, and a script rendered from a layout loads once and survives client-side navigation between pages under that layout.

tsx
// app/layout.tsx
import Script from 'next/script';
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* lazyOnload: a support launcher is not worth competing with your own first paint */}
        <Script
          id="laybuild-widget"
          src="https://laybuild.com/widget.js"
          strategy="lazyOnload"
          data-widget-key="wkey_your_widget_key"
          data-position="bottom-right"
          data-color="#1f6feb"
          data-title="Acme Support"
          data-greeting="Hi. Ask about orders, billing or setup."
        />
      </body>
    </html>
  );
}

Which strategy to use:

  • lazyOnload loads during browser idle time, after the page's resources. For a chat launcher this is the right default; the Next.js docs list chat plugins as the example use.
  • afterInteractive (the default) loads earlier, after some hydration. Use it if the widget should appear as soon as possible, for example on a help centre page.
  • beforeInteractive is for scripts the page cannot work without, such as consent managers, and must live in the root layout. A chat widget is not that.
  • worker does not work with the App Router, and the loader needs the real DOM anyway.

The attributes the loader reads are data-widget-key, data-agent-id, data-color, data-position (bottom-right or bottom-left), data-title, data-greeting, data-badge-text, data-auto-open="true" (opens the panel one second after load), data-z-index, data-user-email, data-user-hash, and data-origin for self-hosted installs where the script and the app are served from different places. Your dashboard's widget page generates this snippet with your key filled in.

Signed-in users: compute the hash in a Server Component

By default the widget asks a visitor for their name and email before the first message. If the visitor is already signed in to your app, you can pass their email plus a hash that proves your server vouched for it. LayBuild computes HMAC-SHA256 of the lowercased, trimmed email using your widget secret and compares it with the hash you sent. If it matches, the conversation is marked as verified and no form is shown. You can also require verification, in which case sessions without a valid hash are refused with a 401.

The secret must stay on your server, which is exactly what a Server Component is for:

tsx
// app/(app)/SupportWidget.tsx
import 'server-only';
import { createHmac } from 'node:crypto';
import Script from 'next/script';
import { getSignedInEmail } from '@/lib/session'; // your own auth helper

function widgetHash(email: string): string {
  const secret = process.env.LAYBUILD_WIDGET_SECRET;
  if (!secret) throw new Error('LAYBUILD_WIDGET_SECRET is not set');
  // LayBuild lowercases and trims before hashing, so do the same or the hashes will not match
  return createHmac('sha256', secret).update(email.trim().toLowerCase()).digest('hex');
}

export async function SupportWidget() {
  const email = await getSignedInEmail();
  return (
    <Script
      id="laybuild-widget"
      src="https://laybuild.com/widget.js"
      strategy="lazyOnload"
      data-widget-key="wkey_your_widget_key"
      data-title="Acme Support"
      {...(email
        ? { 'data-user-email': email.trim().toLowerCase(), 'data-user-hash': widgetHash(email) }
        : {})}
    />
  );
}

Render <SupportWidget /> from the layout of your signed-in area (for example app/(app)/layout.tsx), not the root layout. Reading the session makes that layout dynamic, and you do not want every marketing page to become per-request just to render a chat launcher.

Two traps. Because the loader initializes only once per page load, a user who signs in through a client-side transition keeps the anonymous widget until the next full page load; if your sign-in flow ends in a redirect, that happens naturally. And the loader has an identify() method, but the embedded chat does not act on it today, so pass identity through the data attributes as shown.

Opening the panel from your own UI

Anything that calls window.LayBuild has to run in the browser, so it goes in a Client Component. The widget may not have loaded yet when the button renders, so check before calling.

tsx
'use client';

type LayBuildWidget = { open: () => void; send: (text: string) => void };

export function AskSupportButton({ question }: { question?: string }) {
  function handleClick() {
    const widget = (window as Window & { LayBuild?: LayBuildWidget }).LayBuild;
    if (!widget) return; // lazyOnload has not finished yet; the launcher is not there either
    if (question) widget.send(question);
    else widget.open();
  }
  return (
    <button type="button" onClick={handleClick}>
      Ask support
    </button>
  );
}

send() opens the panel and posts the text as the customer's message, which is handy for "Ask about this error" links next to error states.

Content Security Policy

If your site sends a CSP header, the widget needs three allowances: the LayBuild origin in script-src for the loader, the same origin in frame-src for the iframe, and 'unsafe-inline' (or a policy that otherwise allows it) in style-src, because the loader inserts a <style> element for the launcher. Without the last one the button still loads but renders unstyled. Nothing inside the iframe is governed by your CSP; it runs under LayBuild's.

On narrow screens (480 px wide or less) the panel opens full screen and hides the launcher while open, so you do not need your own mobile handling.

Next steps

Generate your snippet from the widget page in your dashboard, or read the developer docs. For how widget conversations relate to WhatsApp and the REST API, see one conversation model for web chat, WhatsApp and the API, and for what the agent will and will not answer once the widget is live, preventing hallucinations in customer support.