Type something to search...
How to Load Third-Party Scripts Efficiently with next/script?

How to Load Third-Party Scripts Efficiently with next/script?

You can spend weeks shaving kilobytes off your own JavaScript bundle and lose all of it to one chat widget. Third-party scripts such as analytics, tag managers, ad networks, customer support chat, A/B testing tools, and social embeds are often the heaviest code on a page. They compete with your app for the main thread during hydration, delay interactivity, and drag down Core Web Vitals like Interaction to Next Paint (INP). Worse, you do not control their code, so you cannot optimize it. You can only control when and how it loads.

That is the job of next/script. The Script component lets you decide, script by script, whether something loads before hydration, right after it, or when the browser is idle, and it guarantees each script loads only once even as users navigate between pages. Combined with the @next/third-parties package, it turns third-party loading from an afterthought into a deliberate performance decision.

This article covers next/script in the App Router from start to finish: why a plain script tag is the wrong tool, the four loading strategies and when to use each, placement in layouts and pages, inline scripts, onLoad, onReady, and onError, loading scripts only after cookie consent, deferring heavy widgets until user interaction, Content Security Policy nonces, @next/third-parties for Google services, and how to measure the result.

Why Not Just Use a script Tag?

You can put a raw script element in a React component, but in a Next.js app it causes real problems:

  • No ordering control. The script is fetched and executed wherever the browser encounters it, often competing with your framework code during hydration.
  • Duplicate loading. A script inside a page component can be injected again on client-side navigations, double-counting analytics events or re-initializing widgets.
  • Hydration mismatches. Scripts that mutate the DOM before React hydrates can cause hydration errors.
  • Inline scripts do not run on client navigation. React does not execute script tags inserted during client-side rendering, so code that "works on refresh" silently fails after a Link navigation.

next/script solves all four. It deduplicates scripts by src or id, injects them at the right moment based on a strategy, and runs inline code reliably.

The Four Loading Strategies

The strategy prop controls when a script loads:

StrategyWhen it loadsWhere it can goGood for
beforeInteractiveIn the initial HTML, before any Next.js codeRoot layout onlyCookie consent managers, bot detection
afterInteractiveAfter some hydration (the default)Any layout or pageTag managers, analytics
lazyOnloadDuring browser idle time, after all resources loadAny layout or pageChat widgets, social embeds, feedback tools
workerIn a web worker via Partytown (experimental)Pages Router onlyHeavy scripts that do not need the DOM

The default is afterInteractive, which is reasonable but not always right. The single most effective change in most projects is moving non-essential scripts to lazyOnload.

beforeInteractive

beforeInteractive scripts are injected into the server-rendered HTML and always placed in the document head, no matter where you put the component. They are fetched before your first-party JavaScript, though their execution does not block hydration. In the App Router they must live in the root layout:

// app/layout.tsx
import Script from "next/script";
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.example-consent.com/consent.js"
          strategy="beforeInteractive"
        />
      </body>
    </html>
  );
}

Use it sparingly. It runs once per full document load and does not re-run on client-side navigations, and anything that loads this early competes with your most critical resources.

afterInteractive

The default strategy. The script is injected client-side and loads once the page has started hydrating. This fits scripts that should start early but are not needed to render the page, such as a tag manager:

// app/layout.tsx
import Script from "next/script";

const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        {GTM_ID && (
          <Script id="gtm" strategy="afterInteractive">
            {`
              (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
              new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
              j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
              'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
              })(window,document,'script','dataLayer','${GTM_ID}');
            `}
          </Script>
        )}
      </body>
    </html>
  );
}

There is a cleaner way to load Google Tag Manager, covered in the @next/third-parties section below.

lazyOnload

lazyOnload waits until the browser is idle and all other resources have loaded. Visitors never notice a support chat bubble appearing a second later, but they do notice a page that takes a second longer to respond:

// app/(marketing)/layout.tsx
import Script from "next/script";

export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <Script src="https://widget.example-chat.com/loader.js" strategy="lazyOnload" />
    </>
  );
}

worker (Experimental)

The worker strategy offloads a script to a web worker using Partytown, freeing the main thread entirely. It requires the nextScriptWorkers flag:

// next.config.js
module.exports = {
  experimental: {
    nextScriptWorkers: true,
  },
};

Two important limits: the worker strategy is experimental, and it does not work with the App Router yet. It can only be used in the pages directory. Many scripts that rely heavily on synchronous DOM access also break inside a worker. For App Router projects, use lazyOnload or the interaction-based loading pattern shown later.

Where to Place Scripts

Where you render the Script component decides which routes load it:

  • Root layout (app/layout.tsx). Loads on every route. Reserve this for scripts every page truly needs, like analytics.
  • Nested layout (app/dashboard/layout.tsx). Loads only for that section. Next.js loads it once even as users move between pages in the section.
  • Page (app/pricing/page.tsx). Loads only on that page.

The Next.js team recommends scoping scripts as narrowly as possible. A Stripe.js script needed only at checkout should live in the checkout layout, not the root layout. A route group is a convenient way to apply a script to a set of pages without changing URLs:

app/
  layout.tsx            -> analytics for everyone
  (marketing)/
    layout.tsx          -> chat widget for marketing pages only
    page.tsx
    pricing/page.tsx
  (app)/
    layout.tsx          -> no chat widget inside the product
    dashboard/page.tsx

Inline Scripts

The Script component can also run inline JavaScript. Write the code as a template string child, or use dangerouslySetInnerHTML:

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script id="set-build-info" strategy="afterInteractive">
          {`window.__BUILD__ = { version: "1.4.2", env: "production" };`}
        </Script>
      </body>
    </html>
  );
}

An id is required for inline scripts. Next.js uses it to track the script and make sure it executes only once. Without it, you get a warning and unreliable behavior across navigations.

Keep secrets out of inline scripts. Anything you write there is visible in the page source, so only interpolate values that are safe to expose, such as NEXT_PUBLIC_ environment variables.

Running Code After a Script Loads

Many libraries need initialization after they load: a map needs to be created, a widget needs a configuration call. next/script provides three event handlers:

  • onLoad runs once, after the script finishes loading.
  • onReady runs after the script loads and again every time the component mounts, for example after navigating back to the page.
  • onError runs if the script fails to load.

Event handlers are functions, and functions cannot be passed from Server Components, so these props only work inside a Client Component. Put the script in a small component marked with "use client":

// components/MapEmbed.tsx
"use client";

import Script from "next/script";
import { useRef, useState } from "react";

declare global {
  interface Window {
    L?: {
      map: (el: HTMLElement) => {
        setView: (coords: [number, number], zoom: number) => unknown;
      };
      tileLayer: (url: string, options?: Record<string, unknown>) => {
        addTo: (map: unknown) => void;
      };
    };
  }
}

export default function MapEmbed({ lat, lng }: { lat: number; lng: number }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [failed, setFailed] = useState(false);

  function initMap() {
    const L = window.L;
    const el = containerRef.current;
    if (!L || !el || el.dataset.ready) return;

    const map = L.map(el);
    map.setView([lat, lng], 13);
    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution: "OpenStreetMap contributors",
    }).addTo(map);
    el.dataset.ready = "true";
  }

  return (
    <>
      <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
      <div ref={containerRef} className="h-80 w-full rounded" />
      {failed && <p className="mt-2 text-sm">The map could not be loaded.</p>}
      <Script
        src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
        strategy="lazyOnload"
        onReady={initMap}
        onError={() => setFailed(true)}
      />
    </>
  );
}

Why onReady and not onLoad? The script only loads once per session, so onLoad fires only the first time. If the user navigates away and back, the component remounts with a fresh empty container, and only onReady will run again to rebuild the map.

Two rules to remember: onLoad cannot be used with beforeInteractive (use onReady instead), and none of these handlers work in Server Components.

The Client Component can then be used from any Server Component page:

// app/contact/page.tsx
import MapEmbed from "@/components/MapEmbed";

export default function ContactPage() {
  return (
    <section className="container py-16">
      <h1 className="h2 mb-6">Visit Us</h1>
      <MapEmbed lat={23.8103} lng={90.4125} />
    </section>
  );
}

For more on where the client boundary should sit, see when to use the "use client" directive in Next.js.

Using @next/third-parties for Google Services

For the most common Google integrations, the @next/third-parties package wraps next/script with sensible defaults. Install it alongside Next.js:

npm install @next/third-parties@latest

The package is still marked experimental, so pin the version in production and test upgrades.

Google Analytics

// app/layout.tsx
import { GoogleAnalytics } from "@next/third-parties/google";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
      <GoogleAnalytics gaId="G-XXXXXXXXXX" />
    </html>
  );
}

Send custom events from Client Components with sendGAEvent:

// components/DownloadButton.tsx
"use client";

import { sendGAEvent } from "@next/third-parties/google";

export default function DownloadButton() {
  return (
    <button
      className="btn btn-primary"
      onClick={() => sendGAEvent("event", "file_download", { file_name: "pricing.pdf" })}
    >
      Download pricing
    </button>
  );
}

The component also tracks page views on client-side navigations through the browser history, which a hand-written gtag snippet often misses. For a complete walkthrough, see how to add Google Analytics to your Next.js website.

Google Tag Manager

// app/layout.tsx
import { GoogleTagManager } from "@next/third-parties/google";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <GoogleTagManager gtmId="GTM-XXXXXXX" />
      <body>{children}</body>
    </html>
  );
}

Push custom events with sendGTMEvent({ event: "signup_completed", plan: "pro" }) from a Client Component.

YouTube and Google Maps Embeds

YouTubeEmbed uses lite-youtube-embed under the hood, rendering a lightweight placeholder and loading the real player only when the visitor clicks play. That avoids the several hundred kilobytes a standard iframe embed loads upfront:

// app/tutorials/page.tsx
import { YouTubeEmbed } from "@next/third-parties/google";

export default function TutorialsPage() {
  return <YouTubeEmbed videoid="ogfYd705cRs" height={400} params="controls=1" />;
}

GoogleMapsEmbed similarly lazy-loads a Maps embed by default.

Loading Scripts Only After Cookie Consent

In many regions you must not load analytics or advertising scripts until the visitor consents. Conditional rendering makes this straightforward, because next/script only injects a script once the component is rendered:

// components/ConsentScripts.tsx
"use client";

import Script from "next/script";
import { useEffect, useState } from "react";

const CONSENT_KEY = "cookie-consent";

export default function ConsentScripts() {
  const [consent, setConsent] = useState<"granted" | "denied" | null>(null);

  useEffect(() => {
    setConsent(localStorage.getItem(CONSENT_KEY) as "granted" | "denied" | null);
  }, []);

  function choose(value: "granted" | "denied") {
    localStorage.setItem(CONSENT_KEY, value);
    setConsent(value);
  }

  if (consent === "granted") {
    return (
      <Script
        src="https://analytics.example.com/tracker.js"
        strategy="afterInteractive"
        data-site-id="wsm"
      />
    );
  }

  if (consent === "denied") return null;

  return (
    <div role="dialog" aria-label="Cookie consent" className="fixed inset-x-0 bottom-0 bg-white p-4 shadow-lg">
      <p className="mb-3">We use analytics cookies to understand how the site is used.</p>
      <div className="flex gap-3">
        <button className="btn btn-primary" onClick={() => choose("granted")}>
          Accept
        </button>
        <button className="btn btn-outline-primary" onClick={() => choose("denied")}>
          Decline
        </button>
      </div>
    </div>
  );
}

Reading localStorage inside useEffect keeps the server and client render identical, which avoids hydration mismatches. Extra attributes such as data-site-id are forwarded to the final script element. Render ConsentScripts once in the root layout.

Deferring Heavy Widgets Until Interaction

Even lazyOnload still downloads the script on every visit. For widgets most visitors never use, such as live chat, a better approach is a facade: show a lightweight button and load the real script only when someone clicks it.

// components/ChatLauncher.tsx
"use client";

import Script from "next/script";
import { useState } from "react";

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

export default function ChatLauncher() {
  const [requested, setRequested] = useState(false);
  const [loading, setLoading] = useState(false);

  function handleClick() {
    if (window.ExampleChat) {
      window.ExampleChat.open();
      return;
    }
    setRequested(true);
    setLoading(true);
  }

  return (
    <>
      <button
        type="button"
        onClick={handleClick}
        className="fixed bottom-6 right-6 rounded-full bg-primary px-5 py-3 text-white shadow-lg"
        aria-label="Open chat"
      >
        {loading ? "Loading..." : "Chat with us"}
      </button>
      {requested && (
        <Script
          src="https://widget.example-chat.com/loader.js"
          strategy="afterInteractive"
          onLoad={() => {
            setLoading(false);
            window.ExampleChat?.open();
          }}
          onError={() => setLoading(false)}
        />
      )}
    </>
  );
}

Visitors who never open the chat never download it. The few who do wait about a second, once.

Scripts and Content Security Policy

If your site sends a strict Content Security Policy with nonces, every script needs the request's nonce. In the App Router, generate the nonce in proxy.ts (or middleware.ts before Next.js 16), put it in the Content-Security-Policy header, and Next.js automatically applies it to its own framework scripts. For your own Script components, read the nonce from the request headers and pass it through:

// app/layout.tsx
import Script from "next/script";
import { headers } from "next/headers";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
          strategy="afterInteractive"
          nonce={nonce}
        />
      </body>
    </html>
  );
}

Reading headers() makes the layout dynamic, which is unavoidable with nonces because each request needs a unique value. For the full header setup, see recommended security practices for Next.js apps.

Measuring the Impact

Do not guess; measure before and after each change:

  1. Lighthouse. Run it in Chrome DevTools and open the "Reduce the impact of third-party code" audit. It lists each third-party origin with its transfer size and main-thread blocking time.
  2. Performance panel. Record a page load and look for long tasks attributed to third-party scripts during hydration.
  3. Network panel. Filter by "3rd-party requests" to confirm lazyOnload scripts load after the load event and facade scripts do not load at all until clicked.
  4. Field data. Watch INP and Total Blocking Time in your real-user monitoring after deploying. Lab tests do not capture every device.

If third-party code is only part of your problem, how to optimize performance in a Next.js app covers the rest of the checklist.

Common Problems and Fixes

  • "Event handlers cannot be passed to Client Component props." You used onLoad, onReady, or onError in a Server Component. Move the Script into a file marked "use client".
  • The script runs twice. Two Script components load the same src with different query strings, or an inline script is missing an id. Give every inline script a stable id and load each script in one place.
  • The widget disappears after navigating back. You initialized it in onLoad, which only fires on first load. Use onReady.
  • beforeInteractive script is ignored. It is not in the root layout. In the App Router, beforeInteractive scripts must be in app/layout.tsx or another root layout.
  • worker strategy does nothing. It only works in the Pages Router with experimental.nextScriptWorkers enabled. Use lazyOnload in the App Router.
  • Analytics counts only the first page view. A hand-written snippet does not track client-side navigations. Use GoogleAnalytics from @next/third-parties, or send page views on route change.
  • Script blocked by the browser console with a CSP error. The script is missing the nonce, or its origin is missing from script-src. Pass the nonce prop and add the domain to your policy.

next/script FAQ

The default is afterInteractive. The script is injected on the client and loads once the page has started hydrating, which suits analytics and tag managers. Use lazyOnload for scripts that can wait until the browser is idle.

Yes. The Script component can be rendered from Server Components, including layouts and pages. Only the onLoad, onReady, and onError props require a Client Component, because event handler functions cannot be passed from the server.

No. Next.js tracks scripts by src or id and loads each one only once, even if the user navigates between several pages that render it. Use onReady if you need to run setup code each time a component mounts.

Not yet. The worker strategy is experimental, requires the nextScriptWorkers flag, and currently only works in the pages directory. In the App Router, use lazyOnload or load the script on user interaction.

Use @next/third-parties for Google Analytics and Google Tag Manager. It is built on next/script, loads the tags after hydration, tracks client-side page views, and provides helpers for sending events. Use next/script directly for services the package does not cover.

Next.js uses the id to track and deduplicate inline scripts, since they have no src to identify them. Without an id, Next.js cannot guarantee the code runs exactly once across navigations.

Conclusion

Third-party scripts are often the biggest performance cost on a Next.js page, and next/script is how you take control of them. Choose the strategy deliberately: beforeInteractive only for the rare script that must run before anything else, afterInteractive for analytics and tag managers, and lazyOnload for everything that can wait. Scope each script to the layout or page that needs it, give inline scripts an id, put event handlers in Client Components, and use onReady when a widget must survive navigation.

Then go further where it matters: use @next/third-parties for Google services, load tracking only after consent, hide heavy widgets behind a facade, and pass nonces when you run a strict CSP. Measure each change in Lighthouse and in real-user data, and your app code will finally get the main thread it deserves.

Here are some useful references for going deeper on next/script:

  1. Next.js Docs: Script Component API reference — every prop and strategy with examples.
  2. Next.js Docs: How to load and optimize scripts — layout scripts, inline scripts, and event handlers.
  3. Next.js Docs: Third Party Libraries — Google Analytics, Tag Manager, Maps, and YouTube components.
  4. Next.js Docs: Content Security Policy — generating and applying nonces in Proxy.
  5. web.dev: Efficiently load third-party JavaScript — the performance principles behind loading strategies and facades.
Tags :
Share :

Related Posts

Building Powerful Desktop Applications with Next.js

Building Powerful Desktop Applications with Next.js

Next.js is a popular React framework known for its capabilities in building server-side rendered (S

Continue Reading
Can use custom server logic with Next.js?

Can use custom server logic with Next.js?

Next.js, a popular React framework for building web applications, has gained widespread adoption for its simplicity, performance, and developer-frien

Continue Reading
TypeScript with Next.js?

TypeScript with Next.js?

Next.js has emerged as a popular React framework for building robust web applications, offering developers a powerful set of features to enhance thei

Continue Reading