Scripts and third-party code
Load analytics, widgets, maps and videos in Next.js with next/script strategies and @next/third-parties, and see when each script downloads and runs.
- 01Choose a strategyLoad a script with beforeInteractive, afterInteractive or lazyOnload, and know why worker isn't one to use in the App Router yet.
- 02Run your own code with a scriptGive inline scripts an id, and use onLoad, onReady and onError from a Client Component.
- 03Add Google's tools, and check their costUse @next/third-parties for Google Analytics, Tag Manager, YouTube and Google Maps, and read which requests each one makes.
The tea shop's analytics script
Third-party code is code another company writes that your site loads: analytics that count visitors, a chat widget, a map, a video player. It runs in the visitor's browser, next to your own code, and each piece has to be downloaded and run. Next.js loads these scripts with <Script> from next/script: you say which script, and when it should load, and Next.js takes care of the rest.
Here the tea shop loads an analytics script on every page. The script, public/analytics.js, is a stand-in: three lines that only write down that they ran. A real one would come from an analytics company; a stand-in keeps the recording from depending on another company's servers. The project was built with real Next.js 16.4.3 and run with next start, then real Chrome took three steps: open /, click Teas, and click Show what ran.
app/layout.tsximport Link from "next/link";import Script from "next/script";import RunLog from "./run-log"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body> <nav> <Link href="/">Home</Link> <Link href="/teas">Teas</Link> </nav> {children} <RunLog /> <Script src="/analytics.js" /> </body> </html> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh tea, sent to your door.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (11), files and other sites included
GET/200a whole pageGET/_next/static/chunks/1kexj4s7oxhs2.js200application/javascriptGET/_next/static/chunks/06bs-nf19b2js.js200application/javascriptGET/analytics.js200application/javascriptGET/_next/static/chunks/3gjfgdn2oppok.js200application/javascriptGET/_next/static/chunks/3l_qmp-61z4yc.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/45834dh3i6j9o.js200application/javascriptGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /
GET/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/__PAGE__
- next-url
- /
Line 2 of app/layout.tsx imports Script. Line 16 adds <Script src="/analytics.js" /> to the root layout, so every page has it. src is the script's address; files in public are served from the site's root, so /analytics.js is public/analytics.js. app/run-log.tsx is a Client Component that writes React hydrated the page in an Effect on lines 14 to 17, and shows everything written down when its button is clicked. Hydration is when React takes over the HTML already on screen: it matches its Client Components to that HTML and attaches their event handlers, so buttons start to work (Server rendering and hydration goes further).
Now the Steps tab, one step at a time:
- Open the shop. The page asks for
/analytics.jsamong its first files. The Network tab shows why: the HTML has<link rel="preload" href="/analytics.js" as="script">, which tells the browser to start downloading it at once. - Click Teas. No request at all. The teas page was prefetched when its link appeared, and the analytics script isn't downloaded again: Next.js loads a script once, even when the visitor moves between pages.
- Click Show what ran. The log says
React hydrated the page, thenanalytics.js ran: in this run, once, and after the log's Effect.
Before the doors open, the lock has to work. As the doors open, the till goes on. The radio can wait until the morning rush is over. Some boxes arrive at dawn, but stay closed until there's time. Each job has its moment, and the shop runs best when nothing is done earlier than it has to be.
- In real life: The lock that must be on before the doors open
- In Next.js:
beforeInteractive: runs before the page works - In real life: The till, switched on as the doors open
- In Next.js:
afterInteractive: runs once the page has begun to hydrate - In real life: The radio, turned on once the morning rush is over
- In Next.js:
lazyOnload: loads when the browser is idle - In real life: Things delivered early, but unpacked later
- In Next.js: A preloaded script, run later
next/script loads a script once, at the moment its strategy says. beforeInteractive is fetched before any of the page's own code, afterInteractive (the default) loads after some (or all) of the page has hydrated, and lazyOnload once the page has loaded and the browser is idle. Handlers like onLoad need a Client Component, inline scripts need an id, and @next/third-parties wraps Google's tools in ready-made components.
Four strategies: when a script loads and runs
The strategy prop decides when a script loads. The shop gets a chat widget, public/chat.js, another stand-in, which also writes down how far the page had loaded when it ran (document.readyState: loading, interactive once the HTML is read, or complete once the page and its files have loaded). Each button is one strategy. Before you click, guess: in which version does the chat script write its line before React hydrated the page, and which version's is the last file Chrome asks for?
app/layout.tsximport Script from "next/script";import RunLog from "./run-log"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body> {children} <RunLog /> <Script src="/chat.js" strategy="beforeInteractive" /> </body> </html> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Questions? Our chat is open from 9 to 5.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (7), files and other sites included
GET/200a whole pageGET/_next/static/chunks/2n2x6ko-morcw.js200application/javascriptGET/_next/static/chunks/1hv73wm-mg223.js200application/javascriptGET/chat.js200application/javascriptGET/_next/static/chunks/3gjfgdn2oppok.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/3-d-p3p3456r4.js200application/javascript
Line 12 of the root layout. The HTML preloads /chat.js, so Chrome asks for it among the first files. In this recording, step 2's log says chat.js ran (page: interactive) before React hydrated the page. (The docs say its execution doesn't block hydration.)
Step 1 shows when Chrome downloaded /chat.js, and step 2's log shows the order things ran in, in this recording. Next to each, what Next.js's docs promise:
beforeInteractiveis for scripts the whole site needs before it works, like a cookie consent manager or a bot detector. It must be in the root layout. The docs say these scripts are preloaded and fetched before any first-party code, "but their execution does not block page hydration from occurring." In this recording, the log sayschat.js ran (page: interactive), thenReact hydrated the page: in Next.js 16.4.3, the App Router's startup code runs these scripts one after another and then starts hydrating (app-bootstrap.ts, lines 58 to 80). It runs once per full page load; a click to another page doesn't run it again.afterInteractive, the default, can go in any page or layout. The docs say it loads "after some (or all) hydration occurs on the page". Its file is preloaded as early, but Next.js adds the script from an Effect in<Script>; in this recording,React hydrated the pagecame first. Next.js's docs name tag managers and analytics for it.lazyOnloadisn't preloaded. Next.js waits for the page'sloadevent, when every file the page needs has arrived, then for a moment when the browser is idle, and only then adds the script. In step 1's list,/chat.jsis the last file. It suits chat widgets and social media buttons.workerwould run the script in a web worker, a background thread, with a library called Partytown. It's experimental, needsnextScriptWorkersinnext.config.ts, and the docs say it's not yet stable and doesn't work in the App Router yet. In this recording, the page never asked for/chat.js, and the log has no line from it.
Each row is one strategy: where it can go, and what it did in these recordings.
| Strategy | Where it can go | When Chrome downloaded it | When it ran |
|---|---|---|---|
beforeInteractive | Only in the root layout. | Preloaded by the HTML, among the first files. | Before the log's hydration line, in this 16.4.3 recording. The docs say it doesn't block hydration. |
afterInteractive (the default) | Any page or layout. | Preloaded by the HTML, among the first files. | After the log's hydration line. The docs say: after some (or all) hydration. |
lazyOnload | Any page or layout. | Last: after the page has loaded, when the browser is idle. | After the page had loaded. |
worker | Experimental; the docs say it doesn't yet work with the App Router. | Not at all, in this App Router recording. | Not at all, in this App Router recording. |
chat.js ran in these 16.4.3 recordings. Both beforeInteractive and afterInteractive scripts were preloaded by the HTML; what differed is when they ran. lazyOnload waited for the page to load and the browser to be idle. worker didn't load.A plain <script> tag has its own ways to wait, async and defer (From HTML to running script explains them). <Script> adds what a React app needs on top: it ties the moment to the page's startup and to its load, and loads each script once across pages.
<Script> is a Client Component in packages/next/src/client/script.tsx. For afterInteractive, it asks React to preload the file during rendering, at lines 361 to 376, and adds the script in an Effect, at lines 259 to 271. lazyOnload waits for the load event and an idle moment, at lines 169 to 177. For beforeInteractive, the App Router's startup code in 16.4.3 loads the scripts one after another, then calls hydrate, which is why the recorded log has the chat script first: app-bootstrap.ts, lines 58 to 80.
Inline scripts need an id
An inline script has its code between the tags instead of in a file, like a few lines that set up an analytics tool. Write the code as a string inside <Script>, or pass it with dangerouslySetInnerHTML. Next.js's docs say an inline script must have an id, "in order for Next.js to track and optimize the script". Here a small component with an inline script is on two pages, / and /teas:
app/visit-note.tsximport Script from "next/script"; export default function VisitNote() { return ( <Script id="visit-note"> {`window.shopLog = window.shopLog || [];window.shopLog.push("the inline script ran");`} </Script> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Green tea, mint tea and oolong.
- React hydrated the page
- the inline script ran
- Focus
- the button “Show what ran”
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Line 5 gives the inline script the id the docs require. Both pages render it, and in this recording, step 3's log has the inline script ran once.
Line 5 of app/visit-note.tsx opens the <Script>, and lines 6 and 7 are its code. Real Chrome opened /, clicked Teas, and showed the log. In this recording, with id="visit-note", the inline script ran once; without an id, it ran twice, once for each page. That's how 16.4.3's <Script> works inside: it keeps track of the scripts it has run by their id, or by their src for a file (script.tsx, lines 75 to 80), and an inline script has no src. The guarantee to rely on is the docs' rule: give every inline script an id.
onLoad, onReady and onError
Some scripts give you something to use once they've loaded, like a map library that draws a map into an element. <Script> takes three handlers for that. They're functions, and a Server Component can't send a function to the browser, so they only work in a Client Component. What the docs promise for each:
onLoadruns code after the script has finished loading. It can't be used withbeforeInteractive.onReadyruns after the script has finished loading, and then every time the component is mounted again once the script is loaded, after a navigation for example. It's the place to set up a widget the script provides.onErrorruns if the script fails to load. It can't be used withbeforeInteractiveeither.
The shop's map, on its visit page and its contact page:
app/visit/shop-map.tsx"use client"; import Script from "next/script";import { useRef, useState } from "react"; declare global { interface Window { TeaMap?: { draw: (element: HTMLElement) => void }; }} export default function ShopMap() { const mapRef = useRef<HTMLDivElement>(null); const [status, setStatus] = useState("Waiting for the map script"); return ( <section> <div ref={mapRef} /> <p>{status}</p> <Script src="/tea-map.js" onLoad={() => setStatus("onLoad ran: the map script has loaded")} onReady={() => window.TeaMap?.draw(mapRef.current!)} onError={() => setStatus("onError ran: the map couldn't load")} /> </section> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Write to hello@teashop.example, or come by.
Waiting for the map script
- Focus
- the link “Contact”
- Scrolled
- 0 px down
Requests the page made during this step (2), files and other sites included
GET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /__PAGE__
- next-url
- /visit
GET/contact200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /contact/__PAGE__
- next-url
- /visit
Line 1 of app/visit/shop-map.tsx makes it a Client Component. Lines 20 to 25 load /tea-map.js, a stand-in map library whose TeaMap.draw writes a map's address into an element, and counts how many times it has drawn. Line 22, onLoad, changes the text under the map. Line 23, onReady, draws the map into the <div> from line 18, through a ref. Line 24, onError, says the map couldn't load. Both pages render ShopMap. Click through what happened in this recording:
- Open Visit us. Chrome asks for
/tea-map.js. Both handlers run:Times drawn: 1, andonLoad ran: the map script has loaded. - Click Contact. A new
ShopMapmounts. The only requests are the router's fetches for the pages' RSC payloads (the data that describes a page);/tea-map.jsisn't asked for again. The map saysTimes drawn: 2:onReadyran for the new component, since the script was loaded. The text saysWaiting for the map script: the new component'sonLoaddidn't run, because the script had finished loading before it mounted. - Click Visit us. The page is back exactly as it was,
Times drawn: 1. Nothing mounted: with Cache Components on, Next.js keeps a page you just left, hidden, and shows it again on the way back.
Each row is one handler: what the docs say, and what it did in this experiment.
| Handler | When it runs | Good to know |
|---|---|---|
onLoad | After the script has finished loading. In the recording, the contact page's ShopMap, mounted after that, didn't get it. | Not for beforeInteractive. |
onReady | After the script has finished loading, and every time the component mounts again once it's loaded. In the recording, it ran on both pages. | Set up a widget the script provides. |
onError | When the script fails to load. In the recording, after a 404. | Not for beforeInteractive. |
/visit, tea-map.js loaded, and onLoad and onReady both ran. On /contact, a new ShopMap mounted after the script had loaded: only its onReady ran, and nothing was downloaded again.Now break it, two ways:
app/visit/shop-map.tsx"use client"; import Script from "next/script";import { useRef, useState } from "react"; declare global { interface Window { TeaMap?: { draw: (element: HTMLElement) => void }; }} export default function ShopMap() { const mapRef = useRef<HTMLDivElement>(null); const [status, setStatus] = useState("Waiting for the map script"); return ( <section> <div ref={mapRef} /> <p>{status}</p> <Script src="/maps/tea-map.js" onLoad={() => setStatus("onLoad ran: the map script has loaded")} onReady={() => window.TeaMap?.draw(mapRef.current!)} onError={() => setStatus("onError ran: the map couldn't load")} /> </section> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
onError ran: the map couldn't load
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Errors in Chrome's console during this step (1)
Failed to load resource: the server responded with a status of 404 (Not Found)
Requests the page made during this step (12), files and other sites included
GET/visit200a whole pageGET/_next/static/chunks/1kexj4s7oxhs2.js200application/javascriptGET/_next/static/chunks/2qh-avimhn1oz.js200application/javascriptGET/maps/tea-map.js404text/plainGET/_next/static/chunks/3gjfgdn2oppok.js200application/javascriptGET/_next/static/chunks/29k0lwsa-cdae.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/3xfzf07sl2z2b.js200application/javascriptGET/_next/static/chunks/0k9wmronebsvk.js200application/javascriptGET/no answer yetfetch- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /visit
GET/visitno answer yetfetch- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /visit
GET/contactno answer yetfetch- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /visit
Line 21 points at a file that doesn't exist. The server answers 404, Chrome logs the failed load, and onError on line 24 shows onError ran: the map couldn't load.
A script at a wrong address answers 404, and onError runs. Chrome also logs the failed load in its console, which the Steps tab lists. And handlers in a Server Component don't reach the browser at all: the build stops while it prerenders /visit. A <Script> without handlers is fine in a Server Component, like the analytics script in the shop's root layout.
Google Analytics, Tag Manager, maps and videos
@next/third-parties is a package from the Next.js team with ready-made components for popular third-party tools. GoogleAnalytics, GoogleTagManager and YouTubeEmbed load their scripts with <Script>; GoogleMapsEmbed loads no script, only a frame. The docs call the package experimental. Install it with npm install @next/third-parties@latest; the latest on October 30, 2026 is 16.4.3, released with Next.js. Google's tools are in @next/third-parties/google. Here are Google Analytics and Google Tag Manager, with made-up IDs:
app/layout.tsximport { GoogleAnalytics } from "@next/third-parties/google"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body>{children}</body> <GoogleAnalytics gaId="G-TEASHOP1" /> </html> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Fresh tea, sent to your door.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (7), files and other sites included
GET/200a whole pageGET/_next/static/chunks/2n2x6ko-morcw.js200application/javascriptGET/_next/static/chunks/1hv73wm-mg223.js200application/javascriptGEThttps://www.googletagmanager.com/gtag/js?id=G-TEASHOP1not sent: another sitescriptGET/_next/static/chunks/3gjfgdn2oppok.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/07w89l33g7bi7.js200application/javascript
Line 9 adds Google Analytics. The HTML preloads gtag/js from www.googletagmanager.com, and the page asks for it: the one request to another site, which the recorder stopped.
GoogleAnalytics takes your measurement ID as gaId, and GoogleTagManager your container ID as gtmId. In the root layout, they load on every page. Each one writes a small inline script that sets up Google's dataLayer, and loads Google's script with <Script>'s default strategy, afterInteractive: the Network tab shows the HTML preloading it from www.googletagmanager.com, and step 1 lists that request, not sent, since the recorder stops every request to another site. To record an event, like an Add to cart click, the package has sendGAEvent and sendGTMEvent. The docs add that Google Analytics counts a page view each time the URL changes, so client-side navigations are counted too. If the site already has Tag Manager, the docs suggest setting up Analytics through it instead of adding both.
The visit page gets a video and a map. The map is far down the page, below 3000 px of text:
app/visit/page.tsximport { GoogleMapsEmbed, YouTubeEmbed } from "@next/third-parties/google"; export default function VisitPage() { return ( <main> <h1>Visit us</h1> <YouTubeEmbed videoid="ogfYd705cRs" height={400} playlabel="Play: how we pack our tea" /> <div style={{ height: 3000 }}>Opening hours, prices and the story of the shop.</div> <GoogleMapsEmbed apiKey="your-maps-api-key" mode="place" q="Tea shop, 12 Leaf Street" height={300} width="100%" /> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (9), files and other sites included
GET/visit200a whole pageGEThttps://cdn.jsdelivr.net/gh/paulirish/lite-youtube-embed@master/src/lite-yt-embed.cssnot sent: another sitestylesheetGET/_next/static/chunks/2n2x6ko-morcw.js200application/javascriptGET/_next/static/chunks/1hv73wm-mg223.js200application/javascriptGET/_next/static/chunks/3gjfgdn2oppok.js200application/javascriptGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js200application/javascriptGET/_next/static/chunks/1tq6ppe__m8m-.js200application/javascriptGET/_next/static/chunks/3xoej_kx-l3ao.js200application/javascriptGEThttps://cdn.jsdelivr.net/gh/paulirish/lite-youtube-embed@master/src/lite-yt-embed.jsnot sent: another sitescript
Line 7 adds the video with YouTubeEmbed, by its videoid; it loads lite-youtube-embed's script with <Script> and lazyOnload. Line 9 adds the map with GoogleMapsEmbed: a Maps Embed API key from Google, here a placeholder, the mode, and q, what to show. It renders a plain <iframe>, which the browser loads, not a script. Open the Network tab: the video is a <lite-youtube> element, and the map an <iframe loading="lazy"> whose address holds the key, for anyone to read. Then the Steps tab:
- Open Visit us. The page asks
cdn.jsdelivr.netfor lite-youtube-embed's stylesheet and script, a small library that draws a thumbnail and a play button, and loads YouTube's own player only when the visitor clicks. Nothing from youtube.com, and no map. - Scroll down to the map. Now the page asks
www.google.comfor the map's frame.loading="lazy"made the browser wait until the map was near the screen.
cdn.jsdelivr.net for lite-youtube-embed's script and stylesheet, and nothing from youtube.com. The map's frame was asked for only after the scroll.Measuring what a script costs
Every script costs the visitor something: the bytes to download, often a connection to another site, and the time to run it in the browser, on the same thread that responds to clicks. The recorded request lists show the first part: which files each version asked for, from which site, and in which step. That's the question to ask of every third-party script: what does it make the page download, and when?
To measure the rest in your own app, use real Chrome on the real site:
- The Network panel in Chrome's DevTools lists every request with its size and its site. Reload the page and filter by a third party's domain to see everything it brings.
- The Performance panel records a page load and shows which scripts kept the browser busy, and for how long.
- Lighthouse, also in DevTools, has an audit called "Reduce the impact of third-party code", which lists each third party's size and main-thread time.
- Build it twice, with the script and without, or with two strategies, and compare, the way each playground's versions were built.
A script you don't load costs nothing. Put a script in the page or layout that needs it, not the root layout, as the docs recommend; pick the latest strategy that still works; and prefer components like YouTubeEmbed that wait for the visitor.
Which strategy fits?
Sort these scripts by the strategy that suits each one.
- A cookie consent manager that must decide before anything else runs
- A bot detector that checks every visitor
- Google Analytics
- A tag manager
- A chat widget in the corner
- Social media share buttons at the bottom of a post
Sort each script: does it need beforeInteractive, afterInteractive, or can it wait for lazyOnload?
Common mix-ups
- "
afterInteractivemeans the file downloads after hydration." No. The HTML preloads it, so it downloads early. It runs later, after some (or all) of the page has hydrated. - "
beforeInteractiveworks in any page." It belongs in the root layout, for scripts the whole site needs first. - "
workeris a fourth option for the App Router." No. The docs say it's not yet stable and doesn't yet work with the App Router; in the recorded App Router page, the script never loaded. - "A script in the root layout downloads again on every page." No. Next.js loads it once; a click to another page didn't ask for it again.
- "An inline script doesn't need an id." The docs require one, so Next.js can track the script. In the recording, the one without an id ran twice.
- "
onLoadandonReadyare the same." No.onLoadruns when the script finishes loading.onReadyruns then too, and again whenever the component mounts once the script is loaded. - "A YouTube embed loads YouTube's player with the page." Not
YouTubeEmbed: the recorded page asked youtube.com for nothing.
Practice exercises
A Next.js project can't run inside this page, so answer first, then use the hints.
The chat widget isn't needed until a visitor has a question. Which strategy goes in place of the question mark?
import Script from "next/script";
export default function HomePage() {
return (
<main>
<h1>Tea shop</h1>
<p>Questions? Our chat is open from 9 to 5.</p>
<Script src="/chat.js" strategy="?" />
</main>
);
}
import Script from "next/script";
export default function HomePage() {
return (
<main>
<h1>Tea shop</h1>
<p>Questions? Our chat is open from 9 to 5.</p>
<Script src="/chat.js" strategy="lazyOnload" />
</main>
);
}
lazyOnload. A chat widget can wait: Next.js adds it after the page's load event, when the browser is idle. In the recording, it was the last file Chrome asked for.
This component is on two pages, and its inline script runs on both. What's missing?
import Script from "next/script";
export default function VisitNote() {
return (
<Script>
{`window.shopLog = window.shopLog || [];
window.shopLog.push("the inline script ran");`}
</Script>
);
}
import Script from "next/script";
export default function VisitNote() {
return (
<Script id="visit-note">
{`window.shopLog = window.shopLog || [];
window.shopLog.push("the inline script ran");`}
</Script>
);
}
An id. Next.js remembers which scripts it has run by id or src, and an inline script has no src. With id="visit-note", the recorded log has the line once instead of twice.
chat.js writes chat.js ran into the log, and RunLog writes React hydrated the page from its Effect. In the recording of this layout, which line came first?
import Script from "next/script";
import RunLog from "./run-log";
export const metadata = { title: "Tea shop" };
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
{children}
<RunLog />
<Script src="/chat.js" strategy="beforeInteractive" />
</body>
</html>
);
}
chat.js ran (page: interactive). The docs promise that beforeInteractive scripts are fetched before any first-party code, and say their execution doesn't block hydration. In this recording, the App Router's startup code (app-bootstrap.ts) ran the chat script and then started hydrating, so its line came before RunLog's React hydrated the page.
The build stops while it prerenders this page. What does the file need, so that onLoad can work?
import Script from "next/script";
export default function VisitPage() {
return (
<main>
<h1>Visit us</h1>
<Script src="/tea-map.js" onLoad={() => console.log("The map script has loaded")} />
</main>
);
}
"use client";
import Script from "next/script";
import { useRef, useState } from "react";
declare global {
interface Window {
TeaMap?: { draw: (element: HTMLElement) => void };
}
}
export default function ShopMap() {
const mapRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState("Waiting for the map script");
return (
<section>
<div ref={mapRef} />
<p>{status}</p>
<Script
src="/tea-map.js"
onLoad={() => setStatus("onLoad ran: the map script has loaded")}
onReady={() => window.TeaMap?.draw(mapRef.current!)}
onError={() => setStatus("onError ran: the map couldn't load")}
/>
</section>
);
}
Move the <Script> with its handler into a Client Component: a file that starts with "use client", like app/visit/shop-map.tsx. A Server Component can't send a function to the browser. The recorded build of the shop with ShopMap passes.
A visitor opens /visit, then clicks Contact, which also renders ShopMap. In the recording, which of the contact page's ShopMap handlers ran?
"use client";
import Script from "next/script";
import { useRef, useState } from "react";
declare global {
interface Window {
TeaMap?: { draw: (element: HTMLElement) => void };
}
}
export default function ShopMap() {
const mapRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState("Waiting for the map script");
return (
<section>
<div ref={mapRef} />
<p>{status}</p>
<Script
src="/tea-map.js"
onLoad={() => setStatus("onLoad ran: the map script has loaded")}
onReady={() => window.TeaMap?.draw(mapRef.current!)}
onError={() => setStatus("onError ran: the map couldn't load")}
/>
</section>
);
}
Only onReady. The docs say onReady runs after the script loads and every time the component mounts once it's loaded, and onLoad runs when the script finishes loading. The contact page's ShopMap mounted after that, so in the recording it shows Times drawn: 2 and Waiting for the map script.
The map is below 3000 px of text. When does the visitor's browser ask Google for it?
import { GoogleMapsEmbed, YouTubeEmbed } from "@next/third-parties/google";
export default function VisitPage() {
return (
<main>
<h1>Visit us</h1>
<YouTubeEmbed videoid="ogfYd705cRs" height={400} playlabel="Play: how we pack our tea" />
<div style={{ height: 3000 }}>Opening hours, prices and the story of the shop.</div>
<GoogleMapsEmbed apiKey="your-maps-api-key" mode="place" q="Tea shop, 12 Leaf Street" height={300} width="100%" />
</main>
);
}
When the visitor scrolls near it. GoogleMapsEmbed renders an <iframe loading="lazy">, so the browser asks www.google.com for the map only when it's close to the screen. In the recording, the request came in the scroll step.
Check your understanding
For each question, ask when the script is downloaded, when it runs, and which component is allowed to hold its handlers.
Question 1 of 7Which strategy does
<Script src="/analytics.js" />use?Choose an answer to see the explanation.
Question 2 of 7Where does a
beforeInteractivescript like this one belong?Read the code, then predictTSXimport Script from "next/script"; import RunLog from "./run-log"; export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body> {children} <RunLog /> <Script src="/chat.js" strategy="beforeInteractive" /> </body> </html> ); }Choose an answer to see the explanation.
Question 3 of 7In the recording, which
/chat.jswas the last file Chrome asked for?Choose an answer to see the explanation.
Question 4 of 7What do Next.js's docs say about
strategy="worker"in the App Router?Choose an answer to see the explanation.
Question 5 of 7What does this inline script need, according to Next.js's docs?
Read the code, then predictTSXimport Script from "next/script"; export default function VisitNote() { return ( <Script> {`window.shopLog = window.shopLog || []; window.shopLog.push("the inline script ran");`} </Script> ); }Choose an answer to see the explanation.
Question 6 of 7Why does this page stop the build?
Read the code, then predictTSXimport Script from "next/script"; export default function VisitPage() { return ( <main> <h1>Visit us</h1> <Script src="/tea-map.js" onLoad={() => console.log("The map script has loaded")} /> </main> ); }Choose an answer to see the explanation.
Question 7 of 7A page has
<YouTubeEmbed>from @next/third-parties. Before anyone clicks play, what does the page ask for?Choose an answer to see the explanation.
Key takeaways
<Script>from next/script loads a script once, even across pages, at the moment itsstrategysays.beforeInteractive, in the root layout, is fetched before the page's own code.afterInteractive, the default, is preloaded but runs after some (or all) hydration.lazyOnloadloads after the page, when the browser is idle.workeris experimental and doesn't yet work with the App Router.- An inline script needs an
id, so Next.js can track it; the recorded one without an id ran twice. onLoad,onReadyandonErrorare functions, so they need a Client Component.onLoadruns when the script finishes loading;onReadyruns then, and on every later mount once it's loaded.@next/third-parties/googlehasGoogleAnalytics,GoogleTagManager,YouTubeEmbed, which load their scripts with<Script>, andGoogleMapsEmbed, a lazy frame.- A script's cost is what it makes the page download and run: list its requests, then measure in DevTools.
Remember the one-liner.
Load third-party code with next/script, as late as it can be, once, and from the component that needs it.
Coming next: Static files and the public folder. It shows what goes in public, like the stand-in scripts here, how it's served and cached, and when to import a file instead.