Third-party scripts with next/script
Next.js next/script, the primitive that schedules every third-party vendor tag so it stops competing with your own code.
Three teams want three scripts on your SaaS this week. Marketing wants a product-analytics snippet so they can see which features get used. Checkout needs Stripe.js to mount the card field. Support wants a chat widget in the corner so users can reach a human. Each one is a <script> tag a vendor handed you, and the path of least resistance is to paste all three into your root layout and move on.
If you do that, you’ve made the same decision three times without noticing it. All three scripts now download and run on every route, the marketing page included. All three compete with your own React code for the browser’s single main thread , so the page takes longer to become interactive and your LCP slips. (LCP is Largest Contentful Paint, the moment the biggest thing on screen finishes painting, which you met back in the next/image lesson.) You’ve also given the urgent script the same priority as the one nobody would miss for five seconds: Stripe matters because the user is about to pay, while the chat widget can wait. The three scripts have three genuinely different urgencies, and the plain <script> tag has no way to express that.
This is the same story you saw with next/image and next/font. A plain HTML element silently regresses a Core Web Vital, and Next ships a component whose defaults encode the careful choice for you. Every third-party script flows through next/script, whose job is to let you say when each script should load so it stops competing with your own code. Once you can answer one question, “when does this need to run, and what breaks if it runs late?”, the rest of this lesson is choosing a value from a list of four.
What next/script actually does
Section titled “What next/script actually does”Start with the thing it replaces. A raw <script src="..."> in your JSX is render-blocking by default: the browser stops parsing, fetches the script, runs it, and only then continues. It has no concept of “after the page is interactive,” no idea that your React app should boot first, and if you drop the same tag in two places it loads twice. It does exactly what <script> has always done, and that is the problem, because that behavior predates the entire idea of a hydrating React app.
next/script is a thin scheduler wrapped around that tag. It doesn’t make your vendor’s code smaller or faster, since the bytes are the bytes. What it does is decide the moment the script gets inserted into the page, and it makes a few other things free along the way: it loads the script once even as the user navigates around within a layout, it hands you load and error callbacks, and it does not block your render unless you explicitly tell it to. The verb to hold onto is schedules: you hand it a script and a sense of urgency, and it picks the right moment to run it.
The minimal call is two lines, so the happy path is also the shortest thing you can write.
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <script src="https://cdn.example.com/analytics.js"></script> {children} </body> </html> );}The wrong default in a React app. The browser stops parsing to fetch and run this tag, it knows nothing about hydration, and it loads again if this layout re-renders elsewhere in your tree.
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Script src="https://cdn.example.com/analytics.js" /> {children} </body> </html> );}The same line of intent, now scheduled. No directive is needed, since this is still a server file. Next loads the script after the page hydrates instead of blocking, and dedupes it for you. With no strategy prop set it takes the default, which is the right choice for the common case. That is why the shortest call is also usually the correct one.
That default has a name worth knowing, because it’s the one you’ll reach for without thinking. By default <Script> loads with the afterInteractive strategy, right after hydration begins. Every strategy you’re about to learn is measured relative to that moment. A <Script> can live in any page or any layout, with one exception we’ll get to. The real skill here isn’t the syntax, since you’ve already seen all of it. It’s knowing which of four moments to load at.
The four loading strategies
Section titled “The four loading strategies”The strategy prop takes one of four values, and choosing between them is the actual lesson. Rather than learning them in alphabetical order, learn them in the order an experienced engineer reaches for them. There is a default you’ll use almost always, two deviations you earn by meeting a specific condition, and one you can’t use here at all.
afterInteractive, the default you rarely override. It loads right after hydration begins, so your app’s own JavaScript gets the main thread first and the third-party script slots in behind it. This is home for analytics snippets, tag managers, and error-monitoring loaders: anything that should run as soon as reasonably possible but never ahead of the code that makes your app work. If you don’t have a specific reason to pick something else, this is the answer, and most scripts never need anything more deliberate.
lazyOnload, which you’ll reach for more often than you think. It loads during browser idle time, after everything else on the page has finished. Chat widgets, social embeds, and retargeting pixels, anything the user doesn’t need for their first interaction, belong here. The question to ask is whether anything breaks if this script loads five seconds late, or never loads at all. If the honest answer is “nothing the user would notice,” it’s lazyOnload. Beginners tend to leave non-critical scripts at the afterInteractive default because it’s good enough. The discipline is to actively push them down to lazyOnload so they stop competing for the main thread during the moments that matter most.
beforeInteractive, narrow, expensive, and root-layout-only. This one loads before any Next.js code runs, and Next always injects it into the document <head> no matter where you put the <Script> in your tree. Because it has to be in the head before anything else, it must live in your root app/layout.tsx, and Next enforces this. It is the most expensive strategy you have: it competes directly with your first-party data fetching, and because it’s in the root layout it runs site-wide on every single route. Reserve it for scripts that genuinely must run before the page paints or hydrates: bot and fraud detectors that need to fingerprint the request before anything else happens, and cookie-consent managers that must decide what’s allowed to load before other scripts get the chance. The trap is that “before interactive” sounds like “safest, earliest, best,” so beginners reach for it by reflex, and that reflex is what slows down LCP across the whole site. Apply a hard rule: if you can’t say out loud the specific reason this script must beat hydration, it is not beforeInteractive.
worker, worth knowing by name even though you can’t use it here. This one offloads the script to a Web Worker so heavy third-party code runs entirely off the main thread, using a library called Partytown under the hood. It’s the most interesting idea of the four and the one you’ll reach for least, because it’s still experimental, requires a config flag, and does not work with the App Router: it’s pages-directory only today. This course is App-Router-only, so for now you should know the word, know it’s the direction heavy third-party JS is heading, and know you can’t reach for it yet. We won’t set it up.
That leaves three strategies you’ll actually use, and the question that picks between them is short enough to ask on sight. Walk it in order.
Loads before any Next.js code and is always injected into the document <head>, so it must live in your root app/layout.tsx, which Next enforces. It’s the most expensive choice: it competes with your own data fetching and runs on every route. Reserve it for cookie-consent managers and bot/fraud detection. If you can’t say out loud why this script must beat hydration, it isn’t beforeInteractive.
The default, and the one you rarely override. Loads right after hydration begins, so your own JavaScript gets the main thread first and the script slots in behind it. Home for analytics snippets, tag managers, and error-monitoring loaders.
Loads during browser idle time, after everything else has finished. Chat widgets, social embeds, and retargeting pixels: anything that breaks nothing if it loads late or not at all. Demote here aggressively. (worker would sit even further out, running heavy DOM-free code entirely off the main thread, but it’s unavailable in the App Router today.)
The walk is quick because the questions are ranked: you ask the expensive, narrow question first, and only fall through to the common cases when the answer is no. To make the choice automatic, here are six scripts a real SaaS accumulates. Sort each one into the strategy you’d reach for.
Sort each vendor script into the loading strategy you'd reach for. Drag each item into the bucket it belongs to, then press Check.
Notice the shape of the answer key. The two beforeInteractive scripts are the ones that decide what else is allowed to run (consent) or whether to trust the request at all (bot detection). Everything that merely reports on the user, analytics and error monitoring, is afterInteractive, and everything the user could live without for a few seconds, chat and embeds, is lazyOnload. That’s the whole judgment, and you just made it six times.
Callbacks and the ‘use client’ cliff
Section titled “Callbacks and the ‘use client’ cliff”Sometimes loading the script isn’t the end of the job. You need to run a line of your code the instant the vendor’s global becomes available, or react when the load fails. <Script> gives you three callbacks for exactly this:
onLoadfires once, when the script has finished loading. This is where initialization that depends on the global the script defines goes, such as callingwindow.Intercom('boot', ...)after Intercom’s loader has landed.onErrorfires if the script fails to load, whether from a network blip, an ad blocker, or a CDN outage. It’s your hook for graceful degradation.onReadyfires on first load and again every time the component re-mounts. The extra firings are the point: they let you re-run vendor initialization after the user navigates to a new route within your app.
There’s one constraint here that catches people often. All three callbacks only work inside a Client Component. A bare <Script src="..."> with no callbacks runs fine in a Server Component, which is every example you’ve seen so far. But the moment you add any of these callbacks, the file needs 'use client' at the top, because callbacks are event handlers, and event handlers run in the browser, not on the server. This follows the same boundary rule you learned earlier in the course: interactivity lives on the client. The reason it catches people is that the failure is indirect: you add an onLoad, you get an error that doesn’t mention the word “onLoad,” and you don’t connect the two. So commit the rule to memory in its clean form: callbacks mean 'use client', and no callbacks means you can leave it on the server.
Here is one concrete client component that boots a chat widget once its script lands.
'use client';
import Script from 'next/script';
export const IntercomWidget = ({ appId }: { appId: string }) => { return ( <Script id="intercom-widget" src="https://widget.intercom.io/widget/loader.js" strategy="lazyOnload" onLoad={() => { window.Intercom('boot', { app_id: appId }); }} /> );};The 'use client'; directive is here entirely because of the callback further down. This file attaches a <Script> callback, and callbacks force the file onto the client. Strip the callback and this directive wouldn’t be needed.
'use client';
import Script from 'next/script';
export const IntercomWidget = ({ appId }: { appId: string }) => { return ( <Script id="intercom-widget" src="https://widget.intercom.io/widget/loader.js" strategy="lazyOnload" onLoad={() => { window.Intercom('boot', { app_id: appId }); }} /> );};The <Script> with its src and strategy="lazyOnload". A chat widget has zero first-interaction value, so it loads at idle. The id is optional here, since this is an external script and we’ll see next that those dedupe by src automatically, but naming a script you attach behavior to is a tidy habit.
'use client';
import Script from 'next/script';
export const IntercomWidget = ({ appId }: { appId: string }) => { return ( <Script id="intercom-widget" src="https://widget.intercom.io/widget/loader.js" strategy="lazyOnload" onLoad={() => { window.Intercom('boot', { app_id: appId }); }} /> );};The onLoad callback fires once, after the loader script lands, and only then is window.Intercom defined and safe to call. This callback is the reason step 1 exists.
'use client';
import Script from 'next/script';
export const IntercomWidget = ({ appId }: { appId: string }) => { return ( <Script id="intercom-widget" src="https://widget.intercom.io/widget/loader.js" strategy="lazyOnload" onLoad={() => { window.Intercom('boot', { app_id: appId }); }} /> );};Contrast onReady: if this widget needed re-initializing on every client navigation, you’d swap onLoad for onReady here, since it fires on every re-mount, not just the first load.
Two constraints are worth a sentence each. onLoad and onError can’t be paired with beforeInteractive, because that script runs before React is even on the page, so there’s no component lifecycle to fire them; use onReady there if you need a hook. The classic use of onReady is real: re-running a vendor’s setup after a client-side navigation, the way you’d re-embed a Google Map when the user lands on a new page. In 2026, though, you reach for it less than you’d expect. For something like sending an analytics pageview on every route change, the better answer is a purpose-built package, rather than hand-rolling usePathname plus an onReady call yourself. The raw pattern exists, you just rarely write it, which leads into a point the section after next will make.
Placement, dedup, and inline scripts
Section titled “Placement, dedup, and inline scripts”You’ve decided when a script loads. The other half of the decision is where you put the <Script>, and that choice is about scope, not aesthetics. A <Script> in a layout loads for that layout’s entire subtree and persists as the user navigates within it, while a <Script> in the root layout loads on every route in the app. So placement is a scoping decision with real consequences: marketing pixels belong in your (marketing) layout, your app’s product analytics in the app layout, and Stripe.js in the checkout route or layout only, since it’s dead weight on a pricing page. The rule mirrors the one you learned for fonts, where loading scope should match render scope: put the script in the narrowest layout that covers every route that needs it, and no wider. Blanketing the root layout makes every other route pay for a script it doesn’t use.
Now the dedup behavior, because there’s an asymmetry that catches people. Next loads any given script once, even as the user navigates within a layout, and won’t re-inject it on every render. For external scripts (anything with a src), this happens automatically and you don’t have to do anything. For inline scripts, where you write the JavaScript directly as the script’s content rather than pointing at a URL, Next can’t track it for you unless you give it an id. Leave the id off an inline script and it silently fails to optimize: there’s no error, it just quietly loads more than it should. That asymmetry is why inline scripts always carry an id in well-written code. It’s not a style preference, it’s the prop the optimization depends on.
<Script src="https://cdn.example.com/analytics.js" strategy="afterInteractive" />Deduped automatically. An external script is identified by its src, so Next loads it once, lets you navigate freely within the layout, and won’t reload it. No id required.
<Script id="ga-init" strategy="afterInteractive"> {`window.dataLayer = window.dataLayer || [];function gtag(){ dataLayer.push(arguments); }gtag('js', new Date());`}</Script>The id is what the optimization depends on. An inline script has no src for Next to key on, so the id is how Next tracks and optimizes it. Omit it and dedup silently breaks. Inline scripts always carry both an id and a strategy. When a vendor offers a hosted snippet or an SDK, prefer that over pasting raw inline JS: there’s less to maintain, and you get their updates for free.
One more thing belongs in the placement decision, and it has legal weight, not just performance weight. Under EU privacy law, chiefly the GDPR and the ePrivacy rules, many analytics and marketing scripts are only allowed to load after the user has consented to being tracked. That turns “which layout does this pixel go in” into a compliance question: drop a tracking script in a layout where it fires before your consent banner has been answered, and you’ve broken the law, not just the page. The safe habit is to never let non-essential trackers load unconditionally. Gate them behind your consent state, and lean on lazyOnload so they’re deferred and conditional rather than eager. Building the full consent flow is its own topic for later. What you need to carry out of this lesson is the threshold itself: a tracking script’s placement can be a legal decision, so treat it like one.
When not to reach for Script at all
Section titled “When not to reach for Script at all”The most useful thing to know about <Script> is when to skip it: most of the time, you shouldn’t reach for it at all. <Script> is the floor, the primitive everything else is built on, and the first question an experienced engineer asks about any vendor is not “which strategy?” but “do they ship something better than a snippet?”
Increasingly, they do. Modern vendors such as PostHog, Sentry, LaunchDarkly, and most of the category ship a typed npm SDK. You import it like any other dependency instead of pasting a tag: it’s tree-shakable so you ship only what you use, it’s typed so your editor catches mistakes, and it integrates with React through hooks and providers instead of dumping a global on window. When a vendor offers an SDK, that’s the answer. <Script> is the fallback for the vendor that only gives you a raw snippet and nothing better.
Google is the case worth knowing by name. Google Analytics and Google Tag Manager don’t ship a conventional SDK, they ship a snippet, but you still shouldn’t hand-roll them with <Script>. The official @next/third-parties/google package wraps them for you with GoogleAnalytics and GoogleTagManager components, plus helpers like sendGAEvent and sendGTMEvent and embed components for Google Maps and YouTube. All of them load the underlying scripts after hydration by default, with the careful choice baked in. It’s the purpose-built tool for that vendor family, so you’d reach for it over a raw GA snippet every time. One honest caveat: the package is still officially marked experimental and under active development, so install it deliberately and don’t treat its API as frozen. Even so, it’s already the right reach for GA/GTM over a hand-injected tag.
Here is that order at a glance.
A typed SDK
if the vendor ships one — import it, don’t inject it
import posthog from 'posthog-js' e.g. PostHog · Sentry · LaunchDarkly
The Google package
for GA4 / GTM, which ship a snippet, not an SDK
@next/third-parties/google gives you GoogleAnalytics · GoogleTagManager
A raw <Script>
only when the vendor offers nothing better
<Script src="…" strategy="…" /> the floor every option above is built on
Underneath both decisions, the strategy and the SDK, is one cost you’re managing. Every third-party script you add costs bytes to download, time to parse, and main-thread time to execute, and none of that is free for your user. So the question that drives this whole lesson is the same one asked two ways: what breaks if this script disappeared tomorrow? Ask it about urgency and it pushes you toward lazyOnload, or toward deleting the script entirely. Ask it about integration and it pushes you toward a typed SDK you can configure and tree-shake, rather than a tag you can’t touch. If you can’t justify a script, don’t ship it.
Worked example: three scripts, three decisions
Section titled “Worked example: three scripts, three decisions”Back to the three scripts the three teams wanted. The right answer is different for each one, and the reasoning behind that is what you’re learning. Here’s how an experienced engineer would place all three.
<Script src="https://js.stripe.com/v3" strategy="afterInteractive" />Needed for the interaction, not before it, and scoped to where the interaction happens. This lives in the checkout route or layout only. It’s useless on marketing or pricing pages, so scoping it tightly keeps every other route from paying for it. The strategy is afterInteractive because the card field is part of the user’s interaction once they’re on the page, but nothing has to run before hydration.
import posthog from 'posthog-js';
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: 'https://us.i.posthog.com',});// init inside a client provider — full setup comes laterThe right move is the SDK, not <Script>. PostHog ships a typed npm SDK, so you don’t reach for the component at all. You import it and initialize it inside a client provider (gestured at here; the full wiring is a later chapter), and you get types, tree-shaking, and React hooks instead of a global dumped on window.
<Script id="intercom" src="https://widget.intercom.io/widget/loader.js" strategy="lazyOnload"/>Zero first-interaction value, so it waits. A chat widget loads at idle with lazyOnload, behind everything that actually matters on screen. It goes in whichever layout offers support (the app layout, say), not the root, and if you wired it up inline it would carry an id so Next can dedupe it.
Three scripts, three different correct answers, and not one of them came from memorizing an API. They came from asking when the script needs to run and whether the vendor ships something better than a tag. That is the skill this lesson is teaching.
One quick check before you move on.
Marketing pastes a retargeting pixel into the root app/layout.tsx with strategy="beforeInteractive". The build succeeds and the pixel fires. What’s the actual problem with this choice?
id, Next can’t dedupe the pixel, so it reloads on every navigation within the layout.beforeInteractive is only legal in the root layout, so placing it there is the one mistake that won’t compile — it needs to move into a route group.next/script; this one only works because it silently fell back to a plain <script> tag.lazyOnload, consent-gated, narrowly-scoped script — beforeInteractive in the root layout is the exact opposite on every axis: earliest instead of idle, every route instead of one, eager instead of consent-gated. The id decoy is about inline scripts; this pixel has a src, so Next dedupes it automatically. And the root layout is precisely the one place beforeInteractive is allowed, so nothing here is a compile error — it’s a judgment error.next/script needs zero configuration to do all of this, with no next.config.ts entry, unlike the image pipeline you set up earlier. The work is never in the setup. It’s in the decision you now know how to make: pick the latest loading moment that still works, scope the script to the narrowest layout that needs it, and prefer a real SDK to a snippet whenever the vendor ships one.