Tutorial12 min readby Abd Shanti

How To Add A Chat Widget To Next.js Without Wrecking Your Lighthouse Score

How To Add A Chat Widget To Next.js Without Wrecking Your Lighthouse Score
On this page

What Almost Everyone Does First

You get the embed snippet from your chat vendor. It looks like this and it could not be simpler.

<script src="https://cdn.example.com/w.js" data-id="abc123" async></script>

The instructions say put it before the closing body tag. So you open your Next.js project, look for a body tag, find it in your root layout, and paste it in. Job done, you think.

Then you run Lighthouse and your performance score has dropped 20 points and you have no idea why.

Here is what happened. In the App Router, a raw script tag in your layout is not the same thing as a script tag at the bottom of an HTML file. React will hoist it, execute it during hydration, and generally do things you did not plan for. Meanwhile the browser is trying to make your page interactive and now has to parse a chat widget instead.

The Version That Works

Next has a component built specifically for this and it takes about thirty seconds to switch to.

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.tglivechat.com/w.js"
          data-id="YOUR_WIDGET_ID"
          strategy="lazyOnload"
        />
      </body>
    </html>
  )
}

That is genuinely it. The important part is that one word, lazyOnload.

What The Strategies Actually Mean

Next gives you four loading strategies and picking the wrong one is where most of the damage happens. Here is the plain English version.

StrategyWhen it loadsUse it for
beforeInteractiveBefore any Next code runsBot detection, consent managers, polyfills
afterInteractiveRight after hydrationAnalytics, tag managers
lazyOnloadWhen the browser goes idleChat widgets, social embeds
workerIn a web worker, experimentalThings you feel adventurous about

Chat belongs in lazyOnload and I will defend that strongly. Think about the actual human behaviour. Somebody lands on your page. They read the headline. They scroll. Maybe they read a bit more. Somewhere between five seconds and never, they might click the chat bubble.

There is no scenario where a visitor needs the chat widget in the first second. So there is no reason for it to compete with your hero image, your fonts, or your JavaScript for main thread time during the exact window when the page is trying to become usable.

A quick warning on beforeInteractive. Do not use it for chat. I have seen people reach for it thinking earlier is better. It is the most expensive strategy available and it blocks the page. Using it for a chat widget is like using an ambulance to deliver a pizza.

If You Are Still On The Pages Router

Plenty of production apps are and that is fine. Same component, different file.

// pages/_app.tsx
import Script from 'next/script'

export default function App({ Component, pageProps }) {
  return (
    <>
      <Component {...pageProps} />
      <Script
        src="https://cdn.tglivechat.com/w.js"
        data-id="YOUR_WIDGET_ID"
        strategy="lazyOnload"
      />
    </>
  )
}

Putting it in the custom App file rather than a page means it survives client side navigation without reloading. Which brings us to the mistake that costs people the most debugging time.

The Bug That Eats An Afternoon

Here is a failure I have watched happen more than once, and it is a properly annoying one to diagnose.

Someone puts the chat widget in a page component instead of the layout. It works perfectly on first load. Then they navigate to another page and back, and now there are two chat bubbles. Or the widget vanishes. Or the websocket connection drops and messages stop arriving.

The reason is that page components unmount and remount during client side navigation. Layouts do not. So a widget mounted in a page gets torn down and rebuilt every time somebody clicks a link, which most third party widgets handle badly because they were written assuming a full page load.

Rule of thumb. Third party scripts go in the layout, or the custom App file. Never in a page.

Skipping It On Certain Routes

You often do not want chat everywhere. Checkout pages, logged in dashboards, admin screens.

Make a tiny client component that checks where you are.

// components/ChatWidget.tsx
'use client'

import Script from 'next/script'
import { usePathname } from 'next/navigation'

const HIDDEN_ON = ['/checkout', '/dashboard', '/admin']

export default function ChatWidget() {
  const pathname = usePathname()
  const hidden = HIDDEN_ON.some((p) => pathname.startsWith(p))
  if (hidden) return null

  return (
    <Script
      src="https://cdn.tglivechat.com/w.js"
      data-id="YOUR_WIDGET_ID"
      strategy="lazyOnload"
    />
  )
}

Then drop that component into your layout instead of the raw Script. Your layout stays a server component, only the small widget wrapper is a client component, and you have not dragged the whole tree into client land just to read a pathname.

The Trick For People Who Really Care

If you are chasing a perfect score, there is a technique called a facade and it is genuinely clever.

Instead of loading the chat widget at all, you render your own fake bubble. It is a button. It costs you maybe two kilobytes because you wrote it yourself. When somebody clicks it, only then do you load the real widget.

The results are significant. There is a well circulated example where facading Intercom improved a Lighthouse score by around 15 points. The team at Calibre reported a 30 percent improvement in time to interactive using a library built exactly for this, called react-live-chat-loader.

The fact that a whole library ecosystem exists purely to delay loading chat widgets tells you everything about how heavy those widgets have become.

The tradeoff is a short pause after the first click while the real thing downloads. Most people never notice because they are still deciding what to type. And you have removed the entire cost for the ninety odd percent of visitors who were never going to open it.

Worth saying, if your widget is genuinely small, a facade is over engineering. Ours is around 15 kilobytes gzipped for the core with themes loading separately, and at that size lazyOnload is plenty. The facade pattern exists because some widgets ship 500 to 750 kilobytes, and at that weight you need the trick.

Plain React, No Next

Vite, Create React App, whatever you have. The idea is the same, you just do the deferring by hand.

import { useEffect } from 'react'

export function useChatWidget(widgetId: string) {
  useEffect(() => {
    if (document.getElementById('tglc-script')) return

    const load = () => {
      const s = document.createElement('script')
      s.id = 'tglc-script'
      s.src = 'https://cdn.tglivechat.com/w.js'
      s.async = true
      s.dataset.id = widgetId
      document.body.appendChild(s)
    }

    if ('requestIdleCallback' in window) {
      const id = window.requestIdleCallback(load, { timeout: 5000 })
      return () => window.cancelIdleCallback(id)
    }
    const t = setTimeout(load, 2500)
    return () => clearTimeout(t)
  }, [widgetId])
}

The guard on the element id is the important line. In React 18 development mode, effects run twice on purpose, which will absolutely give you two chat widgets and a confusing half hour if you skip it.

requestIdleCallback with a timeout is doing roughly what lazyOnload does under the hood. The setTimeout is the fallback for Safari, which took its time supporting idle callbacks.

If you would rather not write this yourself, there is a published package for our widget on npm that wraps it in a component.

Check That It Actually Worked

Do not trust me, or the vendor, or your own intuition. Measure it.

Build for production first, because dev mode numbers are meaningless. Then run Lighthouse on mobile with throttling on, twice. Once with the widget, once with it commented out. Compare Total Blocking Time and Largest Contentful Paint.

If the gap is under about 50 milliseconds, you are done, go and do something more useful. If it is over 200 milliseconds, either your loading strategy is wrong or the widget is too heavy to fix with loading tricks, and the honest answer is a lighter widget.

Also open the Network tab and check when the widget request actually fires. It should be well after your content has painted. If it is going out in the first wave alongside your app bundle, your strategy is not being applied and something else is loading it.

Opening And Closing It From Your Own Code

A thing people want almost immediately after installing. You have a "Talk to us" button somewhere in your page, and you want it to open the chat rather than making the user hunt for the bubble.

Most widgets expose a global object for this. The pattern looks like this, and the important part is the guard, because the widget loads lazily and might not exist yet when somebody clicks.

'use client'

declare global {
  interface Window {
    tglc?: { open: () => void; close: () => void }
  }
}

export function ChatButton() {
  const openChat = () => {
    if (window.tglc) {
      window.tglc.open()
    } else {
      // Widget has not finished loading yet. Fall back to something
      // useful rather than doing nothing and looking broken.
      window.location.href = 'mailto:[email protected]'
    }
  }

  return <button onClick={openChat}>Talk to us</button>
}

That fallback branch matters more than it looks. With lazyOnload there is a real window, usually a second or two on a slow connection, where your button exists and the widget does not. Without the fallback, an impatient visitor clicks your lovely call to action and absolutely nothing happens, which is a worse experience than not having the button.

If You Have A Content Security Policy

Worth a heads up because this catches people in production rather than in development, which is the worst time to find out.

If your Next app sets a Content Security Policy, and it should, a third party widget will be blocked unless you allow it explicitly. You typically need the vendor domain in script-src for the code itself, connect-src for the websocket or API calls it makes, and img-src if it loads avatars or logos.

script-src 'self' https://cdn.tglivechat.com;
connect-src 'self' https://api.tglivechat.com wss://api.tglivechat.com;
img-src 'self' data: https://cdn.tglivechat.com;

The one that trips everybody is connect-src with the websocket scheme. People add the https origin, see the script load fine, and then wonder why messages never send. Chat widgets keep a live connection open, so wss needs to be allowed separately from https.

Check the browser console on your deployed site rather than locally, since CSP headers are often only applied in production.

Testing It Without Annoying Yourself

Two practical annoyances come up while developing with a chat widget installed, and both have easy fixes.

The first is that the widget loads on localhost and you end up sending test messages to your real support inbox. Colleagues get notifications about "asdfgh" at eleven at night. Gate it on the environment.

{process.env.NODE_ENV === 'production' && <ChatWidget />}

The second is that with React strict mode on in development, effects run twice deliberately, which can produce two widgets or two websocket connections. If you are using the Script component this is handled for you, because Next deduplicates by source URL. If you hand rolled the loader, that element id guard from earlier is what saves you.

One more thing worth knowing. If your widget has a domain restriction setting turned on, localhost will be blocked, which looks exactly like the widget being broken. Either add localhost to the allowed list while developing or accept that you will only see it working on a deployed preview. I would suggest the preview, since that is closer to reality anyway.

The Short Checklist

  • Use the Script component, not a raw script tag.
  • Use lazyOnload. Not beforeInteractive, ever.
  • Put it in the layout or the custom App file, never in a page.
  • Wrap it in a small client component if you need to skip certain routes.
  • Guard against double mounting if you are hand rolling it in React.
  • Measure in a production build, on mobile throttling, with and without.

Six lines of config, basically. It is one of the rare performance jobs where the fix is genuinely quick and the improvement shows up on the very next Lighthouse run.

And if you take one thing from this, make it the strategy prop. Half the chat widget performance complaints I see are not caused by heavy widgets at all. They are caused by a perfectly reasonable widget being loaded at the worst possible moment, by someone who pasted a script tag where the vendor documentation told them to and had no reason to think twice about it.

Questions people actually ask

Where should I put a chat widget script in Next.js App Router?

In your root layout, using the Script component from next/script with strategy set to lazyOnload. That defers loading until the browser is idle, so the widget stops competing with your actual page content for the main thread.

Why not just use a plain script tag?

A plain script tag in the App Router can be moved or executed at a time you did not intend, and you lose the loading strategies Next gives you. The Script component handles deduplication across navigations, which matters in a single page app where your layout does not remount.

Should I use afterInteractive or lazyOnload for chat?

lazyOnload for chat, almost always. afterInteractive is for things you need working immediately, like analytics that must capture a bounce. Nobody opens a chat widget in the first second of a page load, so there is no reason to compete with page rendering.

How do I stop the widget loading on certain pages?

Put the Script in a small client component that reads the current pathname and returns null on routes where you do not want it. Checkout flows and dashboards are common places to skip it.

More articles