Skip to content
Chapter 34Lesson 5

Third-party scripts with next/script

Next.js next/script, the primitive that schedules when each third-party vendor tag loads.

Three teams want three scripts on your app this week. Marketing wants a product-analytics snippet, checkout needs Stripe.js to mount the card field, and support wants a chat widget. Each is a <script> tag a vendor handed you, and the easy move is to paste all three into your root layout.

That repeats one mistake three times. Now every script downloads and runs on every route, and all three compete with your React code for the browser’s single main thread , so the page takes longer to become interactive and your LCP slips (Largest Contentful Paint, from the next/image lesson). They also share one priority, though their urgencies differ: Stripe matters because the user is about to pay, while the chat widget can wait. A plain <script> tag can’t express any of that.

As with next/image and next/font, a plain HTML element quietly regresses a Core Web Vital, and Next ships a component with better defaults. Every third-party script flows through next/script, which lets you say when each one loads so it stops competing with your own code. The choice comes down to one question: when does this need to run, and what breaks if it runs late?

A raw <script src="..."> in your JSX is render-blocking: the browser stops parsing, fetches the script, runs it, then continues. It has no notion of “after the page is interactive,” and it loads twice if the same tag renders in two places.

next/script is a thin scheduler around that tag. It doesn’t change your vendor’s code; it decides the moment the script is inserted into the page. It loads the script once as the user navigates within a layout, hands you load and error callbacks, and does not block your render unless you ask it to.

app/layout.tsx
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, knows nothing about hydration, and loads it again if this layout re-renders elsewhere.

That default is afterInteractive: the script loads 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 layout, with one exception we’ll get to.

The strategy prop takes one of four values: a default you’ll use almost always, two you reach for under a specific condition, and one you can’t use here at all.

afterInteractive, the default. 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. Home for analytics snippets, tag managers, and error-monitoring loaders: anything that should run soon but never ahead of the code that makes your app work.

lazyOnload, for anything non-critical. Loads during browser idle time, after everything else on the page has finished. Chat widgets, social embeds, and retargeting pixels belong here. If nothing the user would notice breaks when the script loads five seconds late or never, it’s lazyOnload. Demote non-critical scripts down here so they stop competing for the main thread when it matters.

beforeInteractive, narrow and expensive. Loads before any Next.js code, and Next always injects it into the document <head>, so it must live in your root app/layout.tsx. It’s your most expensive strategy: it competes with first-party data fetching and, from the root layout, runs on every route. Reserve it for scripts that must run before the page paints: bot and fraud detectors that fingerprint the request, and cookie-consent managers that decide what else may load. “Before interactive” sounds like “earliest and safest,” so beginners reach for it by reflex and slow down LCP site-wide. If you can’t say out loud why this script must beat hydration, it isn’t beforeInteractive.

worker, which you can’t use here. It offloads the script to a Web Worker via Partytown , but it’s experimental and does not work with the App Router. This course is App-Router-only, so just know the word for now.

That leaves three strategies, and one short question picks between them.

Which loading strategy?

The walk is quick because the questions are ranked: ask the expensive, narrow question first, and fall through when the answer is no. Now sort six scripts a real web app accumulates 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.

afterInteractive Runs ASAP, behind your own JS
lazyOnload Idle-time, after everything else
beforeInteractive Before hydration, root layout only
Product-analytics loader (PostHog snippet)
Error-monitoring loader
Cookie-consent manager
Bot / fraud detector
Intercom chat widget
Twitter / X embed

The beforeInteractive pair decides what else may run (consent) or whether to trust the request (bot detection). Anything that merely reports on the user, analytics and error monitoring, is afterInteractive; anything the user could live without for a few seconds, chat and embeds, is lazyOnload.

Often you need to run your own code the instant the vendor’s global appears, or react when the load fails. <Script> gives you three callbacks for this:

  • onLoad fires once the script finishes loading. Put initialization that depends on the vendor’s global here, like calling window.Intercom('boot', ...) once Intercom’s loader lands.
  • onError fires when the script fails to load, from a network blip, an ad blocker, or a CDN outage. It’s your hook for graceful degradation.
  • onReady fires on first load and again on every re-mount, so you can re-run vendor setup after the user navigates to a new route.

One constraint trips people up: all three callbacks only work in a Client Component. A bare <Script src="..."> with no callbacks runs fine in a Server Component, as in every example so far. Add any callback and the file needs 'use client', because callbacks are event handlers and event handlers run in the browser. The failure is indirect, the error never mentions onLoad, so memorize the rule: callbacks mean 'use client', no callbacks means leave it on the server.

Here is a 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 });
}}
/>
);
};

'use client'; is here only because of the callback below. Strip the callback and this directive goes too.

'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 for an external script, since 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 });
}}
/>
);
};

onLoad fires once the loader 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 });
}}
/>
);
};

To re-initialize on every client navigation, swap onLoad for onReady, which fires on every re-mount rather than just the first load.

1 / 1

onLoad and onError can’t pair with beforeInteractive: that script runs before React is on the page, so there’s no component lifecycle to fire them. Use onReady instead.

Where you put a <Script> is a scoping decision. A script in a layout loads for that layout’s entire subtree and persists as the user navigates within it; a script in the root layout loads on every route in the app. So marketing pixels belong in your (marketing) layout, product analytics in the app layout, and Stripe.js in the checkout route or layout, where it isn’t dead weight on a pricing page. The rule mirrors the one for fonts: put the script in the narrowest layout that covers every route that needs it, and no wider.

Dedup has an asymmetry worth knowing. Next loads a given script once and won’t re-inject it as the user navigates within a layout. For external scripts (anything with a src) this is automatic. For inline scripts, where you write the JavaScript as the script’s content instead of pointing at a URL, Next can’t track it without an id. Omit the id and dedup silently breaks: no error, it just loads more than once.

<Script src="https://cdn.example.com/analytics.js" strategy="afterInteractive" />

Deduped automatically. Next keys an external script on its src, loads it once, and won’t reload it as you navigate within the layout. No id required.

Placement can also be a legal decision. Under EU privacy law, chiefly the GDPR and the ePrivacy rules, many analytics and marketing scripts may load only after the user consents to being tracked. Drop such a script where it fires before your consent banner is answered, and you’ve broken the law, not just the page. So gate non-essential trackers behind your consent state, and use lazyOnload so they stay deferred and conditional.

<Script> is the floor, the primitive everything else builds on, so the first question about any vendor isn’t “which strategy?” but “do they ship something better than a snippet?”

Increasingly they do. Vendors like PostHog, Sentry, and LaunchDarkly ship a typed npm SDK you import like any other dependency: tree-shakable, typed, and wired into React through hooks and providers instead of a global on window. When a vendor offers an SDK, that’s the answer. <Script> is the fallback for the vendor that gives you only a raw snippet.

Google is the case to know by name. Google Analytics and Google Tag Manager ship a snippet, not an SDK, but you still shouldn’t hand-roll them. The official @next/third-parties/google package wraps them in GoogleAnalytics and GoogleTagManager components that load after hydration by default, so reach for it over a raw GA tag every time. The package is still officially experimental, so don’t treat its API as frozen.

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

The vendor-integration order, read left to right: reach for a typed SDK first, then Google's dedicated package, and drop to a raw <Script> only when the vendor ships nothing better. You only fall to the next rung when the one before it doesn't apply.

Every third-party script costs bytes to download, time to parse, and main-thread time to run, so one question drives the whole lesson: what breaks if this script disappeared tomorrow? 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. Each gets a different answer.

app/(checkout)/layout.tsx
<Script src="https://js.stripe.com/v3" strategy="afterInteractive" />

Needed for the interaction, not before it, and scoped to where it happens. This lives in the checkout route or layout only; it’s useless on marketing or pricing pages, so tight scoping keeps every other route from paying for it. The strategy is afterInteractive because the card field is part of the interaction once the page loads, but nothing has to run before hydration.

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?

The pixel now runs ahead of hydration on every single route, stealing the main thread from your first-party code and dragging down LCP site-wide — and it can fire before the user has answered the consent banner.
Without an 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.
Retargeting pixels aren’t supported by next/script; this one only works because it silently fell back to a plain <script> tag.

next/script needs zero configuration for all of this, no next.config.ts entry, unlike the image pipeline you set up earlier. The decisions are the work: 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.