
Next.js Loading and optimizing scripts
A plain <script src="..."> tag is one of the easiest ways to quietly wreck a page's performance, because the browser's default behavior for it is almost never what you actually want: block HTML parsing, fetch the script, execute it, then continue rendering the rest of the page — for every third-party analytics snippet, chat widget, and ad-tech tag someone's ever pasted into a <head> tag without thinking twice about it. next/script exists specifically to take that default away from you and replace it with something you actually control.
This article covers where to place a script for the scope you need, the loading strategies that determine exactly when it executes relative to your page, and the less-obvious features — inline scripts, event handlers, offloading to a worker — that round out the component.
Choosing scope: layout vs. root layout
The first decision with any third-party script isn't how to load it — it's where it needs to be available. Next.js maps this directly onto your existing route structure.
A layout script loads for that layout's route and everything nested beneath it:
// app/dashboard/layout.tsx
import Script from "next/script";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<section>{children}</section>
<Script src="https://example.com/script.js" />
</>
);
}
This fetches the moment a user visits dashboard/page.js or any route nested under it, like dashboard/settings/page.js — and critically, Next.js deduplicates it: navigating between multiple routes that all share this layout does not re-fetch or re-execute the script on every navigation. It loads once, for the shared layout, regardless of how many times you move between its child routes.
An application-wide script goes in the root layout instead, and loads for literally every route in the app:
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
<Script src="https://example.com/script.js" />
</html>
);
}
The same single-load guarantee applies here too — it loads once regardless of how many pages a user visits in one session, not once per page. That said, the docs are direct about a real trade-off worth taking seriously: scoping a script to only the specific pages or layouts that actually need it is the recommended default, precisely because loading something for every single route — including the many routes that will never actually use it — is unnecessary performance cost paid on pages that get nothing out of it. Reach for the root layout only when a script genuinely is needed everywhere (a site-wide analytics tag is the classic legitimate case), not as a default convenience.
Strategy: controlling exactly when a script executes
This is the actual heart of what makes next/script valuable over a plain <script> tag — four loading strategies, each mapping to a genuinely different point in the page lifecycle, chosen via the strategy prop:
beforeInteractive— loads before any of your own Next.js code runs, and before hydration begins at all. Reserved for scripts your page genuinely can't function correctly without having in place from the very first moment — bot detection that needs to run before any user interaction is possible, a polyfill required for your own code to even parse correctly.afterInteractive(the default) — loads early, but after some hydration has already happened. This is the right home for the overwhelming majority of third-party scripts: analytics, tag managers, most everyday embeds. It's genuinely fine that this happens slightly after the page becomes interactive — the user's actual experience of the page isn't blocked waiting on it.lazyOnload— deferred all the way until the browser has idle time. The correct choice for anything genuinely non-critical to the immediate experience: a chat widget, a lower-priority embed, anything a user could plausibly never interact with in a given session.worker(experimental) — offloads execution entirely off the main thread, into a web worker, via Partytown.
Picking correctly here is directly, measurably tied to Core Web Vitals — a heavy analytics script naively loaded beforeInteractive can genuinely delay the point at which your page becomes usable, for a script whose entire job is passively observing usage, not gating it.
The worker strategy: real but not yet fully baked
Offloading a script's execution to a web worker via Partytown is a genuinely appealing idea — the main thread stays free for your actual application code, while third-party scripts run in an isolated worker context instead. But it comes with an explicit warning worth taking at face value: this strategy is not yet stable, and does not yet work with the App Router. If you're building on the App Router — which this entire series assumes — this option isn't currently usable for you regardless of how appealing it sounds, and it's worth not spending time trying to force it into an App Router project until that changes.
For a Pages Router project where it is usable, it requires explicit opt-in via nextScriptWorkers in next.config.js, after which running the dev server walks you through installing the required Partytown package. Before reaching for it even where it's supported, read Partytown's own documented trade-offs directly — running arbitrary third-party scripts inside a worker isn't free of downsides, since scripts that assume synchronous, unrestricted DOM access (a lot of third-party scripts do exactly this) may not behave correctly once isolated into a worker context.
Inline scripts still get the same optimization
Not every script you need is loaded from an external URL — sometimes it's a small, local snippet, and the Script component still supports that, in two equivalent ways:
<Script id="show-banner">
{`document.getElementById('banner').classList.remove('hidden')`}
</Script>
<Script
id="show-banner"
dangerouslySetInnerHTML={{
__html: `document.getElementById('banner').classList.remove('hidden')`,
}}
/>
The one non-negotiable requirement here: an id is mandatory for inline scripts. Without it, Next.js has no way to track and correctly deduplicate/optimize the script, and — worth internalizing as a specific, easy-to-hit mistake — omitting the id is a common cause of an inline script silently not behaving the way you'd expect, with no obvious error pointing you at the actual cause.
Reacting to a script's own lifecycle
Three event handlers let your own code respond to what's happening with a loaded script, rather than just firing it and hoping:
onLoad— fires once, after the script finishes loading for the first time.onReady— fires after loading completes, and again every single time the component using it re-mounts — genuinely different fromonLoad, and the one to reach for if you need setup logic to re-run whenever the component comes back into the tree, not just on the very first load.onError— fires if the script fails to load at all, which is the one hook worth not skipping in anything resembling production code — a third-party script genuinely can fail to load (CDN outage, ad blocker, network failure), and silently proceeding as though it succeeded is often worse than degrading visibly.
These handlers come with one hard requirement worth flagging clearly, since it's an easy trap: they only work inside a Client Component — the file needs 'use client' as its literal first line.
"use client";
import Script from "next/script";
export default function Page() {
return (
<Script
src="https://example.com/script.js"
onLoad={() => {
console.log("Script has loaded");
}}
/>
);
}
A Script with an onLoad sitting inside a plain Server Component simply won't work as expected — this is a real, common mistake, and one worth checking for specifically if an event handler you attached appears to just never fire.
Passing through additional DOM attributes
Plenty of legitimate <script> attributes aren't things the Script component's own prop API explicitly models — a Content Security Policy nonce, arbitrary data-* attributes a third-party vendor's script might require for configuration. The component handles this by forwarding anything it doesn't recognize straight through to the final rendered <script> element, with no special syntax needed on your end:
import Script from "next/script";
export default function Page() {
return (
<Script
src="https://example.com/script.js"
id="example-script"
nonce="XUENAJFW"
data-test="script"
/>
);
}
This is worth knowing specifically if your app runs a strict Content Security Policy requiring a nonce on every script tag (covered in more depth in the dedicated CSP article in this series) — you don't need a special CSP-aware variant of Script; the ordinary nonce prop just passes through untouched to the actual rendered element.
Key Takeaways
| Decision | What to do |
|---|---|
| Script needed on one section of the app | Add to that section's layout.tsx, not the root |
| Script needed everywhere | Root layout.tsx — but only if genuinely justified |
| Script your page can't function without at all | strategy="beforeInteractive" |
| Most third-party scripts (analytics, tag managers) | strategy="afterInteractive" (the default) |
| Non-critical, can wait for idle time | strategy="lazyOnload" |
| Offload off the main thread | strategy="worker" — Pages Router only, still experimental |
| Inline script | <Script id="...">...</Script> — the id is mandatory |
| React to load success/failure | onLoad / onReady / onError, Client Component only |
| CSP nonce or custom data attributes | Pass as ordinary props — they forward through automatically |
The single habit worth building from this article: default to afterInteractive unless you have a specific, articulable reason to reach for one of the other three strategies, and default to scoping every script to the narrowest layout that actually needs it rather than the root. Both defaults exist precisely because the naive alternative — a plain <script> tag with no strategy at all, dropped into the root layout because it was the easiest place to paste it — is exactly the pattern that quietly costs a page its Core Web Vitals score without anyone noticing until well after the fact.


