A raw <script src> tag dropped into a Next.js layout is a blunt instrument: it either blocks parsing, races hydration, or re-executes on every soft navigation. The next/script component (<Script>) exists to make third-party loading a scheduling decision rather than a placement accident. It wraps the browser’s own loading primitives with a strategy prop that pins each script to a precise point in the React hydration lifecycle, deduplicates by src across client-side route changes, and surfaces onLoad/onReady/onError callbacks so you can initialize a vendor SDK deterministically instead of guessing when its global is ready.
This guide covers the four load strategies and exactly where each one runs in the timeline, the placement rules that differ between the App Router and the Pages Router, and the dedup semantics that make <Script> safe to render inside a component that mounts on every navigation. Two companion guides go deeper on the hardest cases: offloading Next.js scripts to a worker with Partytown when afterInteractive still costs too much main-thread time, and gating the Next.js Script component behind consent when a script must not fire until a lawful basis exists.
Prerequisites / When to Apply
Reach for <Script> — not a hand-written tag — whenever you load any third-party JavaScript in a Next.js app: analytics, tag managers, chat widgets, consent platforms, A/B frameworks, payment SDKs. The decision the component forces you to make is which strategy, and that maps directly to the script’s role:
- Needed before the page is interactive (polyfills, a consent stub that must set a denied default, framework bootstrappers) →
beforeInteractive. - Needed soon but not blocking first paint (analytics, tag managers, most SDKs) →
afterInteractive, the default. - Non-essential, deferrable to idle (chat widgets, support beacons, social embeds) →
lazyOnload. - Pure background work with no synchronous DOM dependency (analytics that only fires events) →
worker, which relocates execution off the main thread via Partytown.
If a script needs a nonce for a strict CSP, or must be gated on user consent, <Script> is still the right wrapper — you compose those constraints on top of the strategy rather than dropping back to a raw tag. The priority-hints workflow governs the network-fetch side of the same script; strategy governs execution timing.
Concept: The strategy Prop and the Hydration Timeline
strategy is the entire point of next/script. Each value binds the script’s execution to a named moment relative to React hydration, and getting it wrong is the difference between a fast page and a blocked main thread.
beforeInteractive
The script is hoisted into the initial HTML and executes before any Next.js hydration, ahead of the framework’s own bootstrap code. Use it only for things that must run first: a Consent Mode v2 default stub, a bot-detection challenge, a critical polyfill. In the App Router, beforeInteractive is only honored when placed in the root app/layout.tsx; putting it in a page does nothing useful because the page renders after the shell. It is the most expensive strategy — it delays interactivity — so treat it as a last resort.
afterInteractive (default)
If you omit strategy, you get afterInteractive. The script injects and runs immediately after the page hydrates, which is early enough for analytics and tag managers to capture the initial pageview but late enough not to block first paint. This is the correct default for Google Tag Manager, GA4, and most SDKs. It is also where the main-thread cost hides: afterInteractive still executes on the main thread, so a heavy vendor bundle here degrades INP — the exact problem the worker-offload companion guide solves.
lazyOnload
Execution is deferred until the browser is idle after the load event, scheduled cooperatively so it does not compete with hydration or user input. Use it for anything the user does not need immediately: live-chat widgets, feedback beacons, social share buttons. A chat widget on lazyOnload can save 100–300 ms of main-thread time during the critical early window.
worker
strategy="worker" moves the script’s execution off the main thread entirely using Partytown, which runs the third-party code inside a web worker and proxies its document/window calls back to the main thread synchronously via a service worker. This is experimental and gated behind experimental.nextScriptWorkers in next.config.js. It only suits scripts that do not need direct, latency-sensitive DOM access — analytics is the canonical fit. See offloading Next.js scripts to a worker with Partytown for the full setup and caveats.
Implementation
The steps below cover the common integrations end to end.
1. Analytics with the default strategy (App Router). Render <Script> from a Client or Server Component; for afterInteractive and lazyOnload it may live in any layout or page.
// app/layout.tsx (App Router)
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{/* afterInteractive is the default; shown explicitly for clarity. */}
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
strategy="afterInteractive"
/>
</body>
</html>
);
}
2. A pre-hydration stub with beforeInteractive. Inline scripts use the id-plus-children form. In the App Router this must be in the root layout.
// app/layout.tsx — Consent Mode v2 denied default, before anything else runs.
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script id="consent-default" strategy="beforeInteractive">
{`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
gtag('consent','default',{ad_storage:'denied',analytics_storage:'denied',
ad_user_data:'denied',ad_personalization:'denied',wait_for_update:500});`}
</Script>
</head>
<body>{children}</body>
</html>
);
}
3. Initialize an SDK deterministically with onLoad. Never assume a vendor global exists on the next line — wait for the callback. onLoad and onReady require the component to be a Client Component ('use client'), because they are event handlers.
'use client';
import Script from 'next/script';
export function IntercomLoader() {
return (
<Script
src="https://widget.intercom.io/widget/APP_ID" // replace APP_ID
strategy="lazyOnload"
onLoad={() => {
// The global is guaranteed to exist here, not before.
window.Intercom('boot', { app_id: 'APP_ID' });
}}
onError={(e) => {
console.error('Intercom failed to load', e);
}}
/>
);
}
4. Pages Router placement. In the Pages Router, beforeInteractive scripts belong in pages/_document.tsx; afterInteractive/lazyOnload scripts can go in pages/_app.tsx or any individual page. Rendering a beforeInteractive script outside _document logs a warning and is downgraded.
// pages/_document.tsx (Pages Router) — beforeInteractive must live here.
import { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';
export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
<Script src="https://example.com/polyfill.js" strategy="beforeInteractive" />
</body>
</Html>
);
}
The load callbacks: onLoad, onReady, onError
onLoadfires once, immediately after the script finishes loading and executing. Use it for one-time initialization.onReadyfires afteronLoadon first mount, and then again on every subsequent client-side navigation where the component re-mounts. Use it to re-invoke SDK APIs that must run per route (e.g. re-rendering a maps embed) even though the script itself is not re-downloaded.onErrorfires if the script fails to load — the hook you need for a graceful fallback chain rather than a silent gap in analytics.
Dedup across client navigations
Next.js keys each script by its src (or, for inline scripts, its id). On a soft client-side navigation, if a <Script> with the same src is rendered again, the framework does not re-inject or re-execute it — it reuses the already-loaded instance and fires onReady again. This is why you can safely render an analytics <Script> inside a component that mounts on many routes without loading GTM four times. Inline scripts must carry a stable id for this dedup to work; without one, Next.js cannot recognize the duplicate and will warn.
Verification Checklist
Interaction with Worker Offloading, Consent Gating, and Web Workers
<Script> is the wrapper; the two companion guides are what you compose on top of it. When afterInteractive analytics still shows up as main-thread cost in a trace, offloading Next.js scripts to a worker with Partytown switches the same component to strategy="worker" and relocates execution. When a script represents a tracking vendor that must not run pre-consent, gating the Next.js Script component behind consent conditionally renders the component off a useConsent() hook.
The worker strategy is Next.js’s built-in path to the broader technique of offloading heavy scripts to web workers; if you need finer control than the nextScriptWorkers flag exposes, that reference covers the general Partytown and worker mechanics framework-agnostically.
Troubleshooting
onLoad/onReady throws “Event handlers cannot be passed to Client Component props”
Symptom: A build or runtime error naming the onLoad prop, in the App Router.
Cause: You rendered <Script> with a callback inside a Server Component. Callbacks are event handlers and require a client boundary.
Fix: Add 'use client' to the top of the module that renders the callback-bearing <Script>, or lift that <Script> into a small Client Component.
beforeInteractive script “has no effect” / logs a downgrade warning
Symptom: Console warning that beforeInteractive outside the root document is ignored; the script runs late.
Cause: In the App Router, beforeInteractive is only honored in app/layout.tsx; in the Pages Router only in pages/_document.tsx.
Fix: Move the <Script> to the correct root location, or — if it does not truly need to run before hydration — switch it to afterInteractive.
The vendor global is undefined right after <Script>
Symptom: TypeError: window.SomeSDK is undefined when you call the SDK in component code.
Cause: You referenced the global synchronously; the script loads asynchronously and has not executed yet.
Fix: Move all SDK calls into the onLoad/onReady callback, which fires only after execution completes.
Analytics loads twice on navigation
Symptom: Duplicate pageviews or two network requests for the same vendor.
Cause: An inline <Script> without a stable id, or two components rendering the same src with different query strings, defeating dedup.
Fix: Give inline scripts a unique id; keep the src byte-identical across renders so the dedup key matches.
Frequently Asked Questions
What is the default strategy if I do not set the prop?
afterInteractive. The script injects and runs just after the page hydrates — early enough for analytics to capture the first pageview, but not blocking first paint. Set an explicit strategy anyway; it documents intent and makes review easier.
Can I use next/script in a Server Component?
Yes for the src/strategy-only form (no callbacks). The moment you add onLoad, onReady, or onError, the component must be a Client Component ('use client'), because those are event handlers that React cannot serialize across the server boundary.
Does next/script deduplicate scripts across page navigations?
Yes. Next.js keys each script by src (or id for inline scripts) and will not re-inject or re-execute a script already loaded during the session. It re-fires onReady on re-mount so you can re-run per-route SDK calls without re-downloading the bundle. Inline scripts need a stable id for this to work.
When should I use strategy="worker" instead of afterInteractive?
When the script does meaningful main-thread work but has no latency-sensitive DOM dependency — analytics is the canonical case. worker runs it off the main thread via Partytown, protecting INP. It is experimental (experimental.nextScriptWorkers) and unsuitable for scripts that read layout or mutate the DOM synchronously. See offloading Next.js scripts to a worker with Partytown.