
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
scripttags inserted during client-side rendering, so code that "works on refresh" silently fails after aLinknavigation.
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:
| Strategy | When it loads | Where it can go | Good for |
|---|---|---|---|
beforeInteractive | In the initial HTML, before any Next.js code | Root layout only | Cookie consent managers, bot detection |
afterInteractive | After some hydration (the default) | Any layout or page | Tag managers, analytics |
lazyOnload | During browser idle time, after all resources load | Any layout or page | Chat widgets, social embeds, feedback tools |
worker | In a web worker via Partytown (experimental) | Pages Router only | Heavy 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:
onLoadruns once, after the script finishes loading.onReadyruns after the script loads and again every time the component mounts, for example after navigating back to the page.onErrorruns 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:
- 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.
- Performance panel. Record a page load and look for long tasks attributed to third-party scripts during hydration.
- Network panel. Filter by "3rd-party requests" to confirm
lazyOnloadscripts load after theloadevent and facade scripts do not load at all until clicked. - 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, oronErrorin a Server Component. Move theScriptinto a file marked"use client". - The script runs twice. Two
Scriptcomponents load the samesrcwith different query strings, or an inline script is missing anid. Give every inline script a stableidand load each script in one place. - The widget disappears after navigating back. You initialized it in
onLoad, which only fires on first load. UseonReady. - beforeInteractive script is ignored. It is not in the root layout. In the App Router,
beforeInteractivescripts must be inapp/layout.tsxor another root layout. - worker strategy does nothing. It only works in the Pages Router with
experimental.nextScriptWorkersenabled. UselazyOnloadin the App Router. - Analytics counts only the first page view. A hand-written snippet does not track client-side navigations. Use
GoogleAnalyticsfrom@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 thenonceprop 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:
- Next.js Docs: Script Component API reference — every prop and strategy with examples.
- Next.js Docs: How to load and optimize scripts — layout scripts, inline scripts, and event handlers.
- Next.js Docs: Third Party Libraries — Google Analytics, Tag Manager, Maps, and YouTube components.
- Next.js Docs: Content Security Policy — generating and applying nonces in Proxy.
- web.dev: Efficiently load third-party JavaScript — the performance principles behind loading strategies and facades.


