Choosing a Loading Strategy: `beforeInteractive`, `afterInteractive`, `lazyOnload`
A Google Analytics snippet pasted into the root layout as a raw <script src> makes first paint wait on a host you don't control. next/script exists to make the timing of third-party loads explicit:
import Script from 'next/script'
// afterInteractive (default) — analytics and tag managers
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XYZ" strategy="afterInteractive" />
// lazyOnload — background, low priority: chat widget, social embeds
<Script src="https://cdn.chatvendor.com/widget.js" strategy="lazyOnload" />
// beforeInteractive — reserved lane: consent gate, bot detection
<Script src="https://cdn.consentvendor.com/consent.js" strategy="beforeInteractive" />
The four strategies and what each one literally means:
| Strategy | When it loads | Placement | Use for |
|---|---|---|---|
beforeInteractive |
injected into the initial HTML from the server, preloaded and fetched before any first-party module; executes in placement order; does not block hydration | root layout only | cookie consent managers, bot detectors |
afterInteractive (default) |
injected client-side, loads after some or all hydration | any page or layout; runs only where rendered | tag managers, analytics |
lazyOnload |
injected during browser idle time, after all page resources are fetched | any page or layout | chat support, social widgets |
worker |
off the main thread in a web worker — experimental, nextScriptWorkers flag, pages/ only |
— | advanced third-party-heavy pages |
The default answers most questions: analytics goes afterInteractive. The docs describe it as the strategy for anything that needs to load ASAP but not ahead of your own code — hydration wins the race, the measurement pixel arrives second, and neither blocks the other.
beforeInteractive is the narrow lane, and it is the one interviewers probe. Four rules define it: it must live in the root layout, it loads on every page, it runs once per document load (client-side navigations do not re-run it — not even a root param change like /en → /fi, because the root layout never re-executes), and the tag is injected into <head> regardless of where you place the component. Only scripts whose absence breaks the page qualify: a consent manager that has to gate every other vendor, a bot detector that has to observe first paint.
Callback props come with their own restrictions:
'use client'
<Script
id="vendor-lib"
src="https://cdn.example.com/lib.js"
onLoad={() => console.log('the file arrived, once')}
onReady={() => initWidgets()} // fires again after each remount
onError={(e) => report(e)}
/>
onLoad, onReady, and onError all require a Client Component — a function prop cannot cross the server boundary. onLoad cannot be combined with beforeInteractive (use onReady there), and onError cannot either. The distinction to remember is that onLoad fires when the file loads, while onReady fires on the component's terms: once on first load and again every time the component remounts after a route navigation.
One more thing has changed little since raw HTML: a plain <script src> written into a Server Component's JSX lands in the served document and blocks the parser where it sits. next/script with afterInteractive or lazyOnload moves the fetch out of HTML parsing entirely — the tag is injected on the client, so the first paint never waits on it. Which leaves the real cost of these scripts: the main thread time they take after they load.
A practical classifier for any snippet: ask what breaks if it arrives five seconds late. Nothing visible → lazyOnload. Metrics or assignment slightly late, page fully functional → afterInteractive. The page misbehaves without it — consent flash before the banner, bot scoring before paint → beforeInteractive. Note what "after some or all hydration" implies: these scripts run once your React tree is interactive, so they compete with early interactions rather than with first paint — which is why demoting a non-essential vendor to lazyOnload tends to help loading and responsiveness at once. The fonts from Self-Hosted Fonts with next/font already cost your origin nothing; scripts are the byte budget you never fully own.
Placement and Lifecycle: What Happens Across Client-Side Navigations
Placement answers a different question than strategy. Strategy decides when a script loads; placement decides where in the app it exists at all.
// app/layout.tsx — site-wide measurement, every document load
<Script id="ga" src="https://www.googletagmanager.com/gtag/js?id=G-XYZ" strategy="afterInteractive" />
// app/pricing/page.tsx — only loads when /pricing opens in the browser
<Script id="pricing-chat" src="https://cdn.chatvendor.com/widget.js" strategy="lazyOnload" />
Two properties fall out of the component tree. A script declared in the root layout runs for every page — that is where analytics belongs. A script declared in one page's tree loads only when that page or group of pages is opened, so a feature-specific integration never taxes the routes that don't use it. Give every script an id; it is how Next identifies the instance across renders and remounts.
beforeInteractive placement has its own rule, because it looks like it should matter: the component may appear anywhere inside the root layout, and the script still lands in <head>. What placement does control is execution order — multiple beforeInteractive scripts execute in the order they are declared, which matters when one vendor must run before another.
Then the lifecycle, which is what actually trips people up in review:
document load → beforeInteractive scripts fetch and run (once per document)
/pricing opens → its Script mounts; lazyOnload injects after window load
navigate to /about → the Script component unmounts; the work already done stays
back to /pricing → onReady fires again; the file is NOT downloaded twice
That asymmetry is the reason onReady exists next to onLoad. onLoad means "the file arrived", which happens once per injected tag. onReady means "this component wants the library initialized now", which happens on every mount — the docs' canonical example is a Google Maps embed that must build a fresh map inside a new div after each navigation:
'use client'
import { useRef } from 'react'
import Script from 'next/script'
export function Map({ center }: { center: [number, number] }) {
const mapRef = useRef<HTMLDivElement>(null)
return (
<>
<div ref={mapRef} className="h-96 w-full" />
<Script
id="google-maps"
src="https://maps.googleapis.com/maps/api/js"
onReady={() => {
new google.maps.Map(mapRef.current!, { center, zoom: 8 })
}}
/>
</>
)
}
Note the failure mode this prevents: doing the same init in onLoad gives you a map on first visit and an empty box after every client-side navigation, because the load event never refires. Doing it in useEffect without an id gives you duplicate injection when two components request the same library.
Compare all of this with dropping a raw <script> tag into the body and the trade is clear. You lose every strategy, every callback, and the framework's dedupe bookkeeping; you keep parser-blocking behavior in the initial HTML and you own the "load it only once, but init it every mount" problem yourself. There is one case where a hand-appended script is genuinely right — a vendor that must not load until consent is granted — and that pattern appends at runtime from the accept handler rather than declaring anything in the tree.
Two finishing details. Inline snippets — a config object, a consent string — take children instead of src and follow the same strategy rules, saving a round trip for a few hundred bytes. And id is quiet bookkeeping rather than decoration: Next uses it to recognize an already-injected tag across renders and remounts, so a component that unmounts and returns does not append a second copy of the same library. Ids must therefore be unique per script — two components wanting one shared vendor should agree on a single id instead of inventing two.
Strategies and placement decide when bytes arrive. The next chapter is about what those bytes cost once they are running on a thread your users need.
The Main-Thread Bill: Third-Party Scripts, Long Tasks, and Next Paint
Every strategy is a bet about the main thread. Interactivity is budgeted in long tasks — blocks of JavaScript over 50 ms — and third-party scripts are the most common way teams spend the budget before their own code runs.
Take a 300 KB chat widget shipped afterInteractive on the homepage: hydration completes, then the widget's parse and initialization run as a burst of long tasks precisely when the user starts tapping. The symptom is not a slow load — it is a menu that takes two frames to open, which is exactly what Interaction to Next Paint grades (good ≤ 200 ms, poor > 500 ms; the full numbers return in Auditing Core Web Vitals). A Lighthouse score can stay respectable while INP sits in the poor range, because the score is dominated by load-time events and INP watches what happens after load.
The levers, in the order you should reach for them:
- Push it later.
lazyOnloadwaits until the browser is idle and all page resources have loaded, so the widget initializes after the page is painted and interactive. For anything non-essential this is the one-line fix with the largest win. - Push it earlier only when required.
beforeInteractivecosts every document load: it fetches before your own modules, so a misassigned analytics snippet makes all first-party work queue behind a third party. Reserve it for consent and bot detection. - Push it off the thread. The experimental
workerstrategy (nextScriptWorkers: true, Pages Router only) runs the script in a web worker — but not every vendor tolerates life withoutwindow, so treat it as an advanced option, not a default. - Gate it. The consent flow is structural: consent manager
beforeInteractive, every other vendor injected only after consent is granted — because a<Script>you declare will load whether consent exists or not.
Consent-gated loading, concretely:
'use client'
function loadVendor(src: string) {
const s = document.createElement('script')
s.src = src
s.async = true
document.body.appendChild(s)
}
// called from the consent banner's accept handler, nowhere else:
loadVendor('https://cdn.chatvendor.com/widget.js')
Budget discipline is arithmetic, not vibes. List every script on the critical page, name its owner and purpose, then ask of each one: what visibly breaks if this loads five seconds later? An honest "nothing" moves it to lazyOnload. An honest "the page must not run without it" earns beforeInteractive. Everything else stays at the default, and anything without an owner gets deleted — an abandoned A/B tag runs on every visit forever.
Verification is per script, not per page. In DevTools → Network, filter by third-party domains and sort by transfer size; in the Performance panel, record an idle page load and look for long-task bars after hydration. Attribute each bar to a vendor by URL, then move that vendor down the ladder. Repeat until the trace after hydration is quiet, because script cost shows up as INP, not as a waterfall entry: a 200 ms main-thread block delays every interaction landing inside it, including ones from your own bundle if they queue behind it.
Numbers to keep in the conversation: a long task is anything over 50 ms; Total Blocking Time is the lab proxy for work piled up between FCP and interactivity; INP itself is good at ≤ 200 ms and poor above 500 ms at p75. The audit takes twenty minutes: record a load, wait out the post-hydration burst, then record an interaction with each control on the page. Every long-task bar after that point names its script by URL — annotate the bars, take the list to the owning teams, and re-record after each move. A vendor that insists it must run early is a requirement to challenge, not to accommodate silently.
That closes the third-party problem. One class of load is still blind: navigation, where Next.js prefetches work you never asked for — the behavior we turn to next in Router Prefetching Behavior.
Quiz
1. A cookie-consent manager must run before every other vendor script on a fresh load. Where does it go?
2. Google Analytics is loaded with a raw <script> in the root layout and is blocking first paint. What is the correct change?
3. A Google Maps embed initialized in onLoad works on first visit but renders an empty box after client-side navigations. Why, and what is the fix?
4. A 300 KB chat widget loaded with the default strategy produces long tasks right after hydration. What is the minimal correct change?
Flashcards
Question
The four next/script strategies — when does each load and where can it live?
Click to reveal answer
Answer
beforeInteractive: injected into the initial HTML from the server, preloaded and fetched before any first-party module, executed in placement order, root layout only, once per document load, does not block hydration (consent managers, bot detectors). afterInteractive (default): client-injected after some/all hydration, any page or layout (analytics, tag managers). lazyOnload: idle time after all resources (chat, social widgets). worker: experimental, nextScriptWorkers flag, pages/ only.
Question
Which next/script callbacks require a Client Component, and what are the beforeInteractive restrictions?
Click to reveal answer
Answer
onLoad, onReady, and onError all take functions, so they require a Client Component — a function cannot cross the server boundary. With beforeInteractive you cannot use onLoad or onError at all; onReady is the supported hook there. Semantics: onLoad = the file arrived (once per injected tag); onReady = initialize now (fires on first load and every remount after navigation); onError = the fetch failed.
Question
What does placement of a Script decide, including beforeInteractive?
Click to reveal answer
Answer
Placement scopes existence: a script in the root layout runs for every page, a script in a page's tree loads only when that page (or group of pages) opens in the browser. Give each an id so Next can identify the instance across renders. beforeInteractive ignores component position for injection — it always lands in <head> — but multiple beforeInteractive scripts execute in the order they are declared.
Question
What is wrong with a plain <script src> written in JSX, and what does next/script change?
Click to reveal answer
Answer
In a Server Component it lands in the served HTML and blocks the parser exactly where it sits, with no strategies, no callbacks, and no dedupe bookkeeping — you own the 'load once, init every mount' problem yourself. next/script with afterInteractive or lazyOnload moves the fetch out of HTML parsing to client injection, so first paint never waits on the third party.
Question
How do you load a vendor only after the user consents?
Click to reveal answer
Answer
Declare nothing in the component tree — a declared Script loads whether consent exists or not. Run the consent manager itself with beforeInteractive, then append the vendor at runtime from the accept handler (document.createElement('script') + appendChild, async). That is the only pattern where hand-appending a script beats next/script.
Revision Notes
Key Takeaways
- 1. afterInteractive is the default and the right home for analytics/tag managers; lazyOnload is the one-line fix for non-essential vendors; beforeInteractive is a reserved lane for consent and bot detection
- 2. beforeInteractive means: initial HTML, head injection, fetched before first-party modules, executes in placement order, does not block hydration, runs once per document load, root layout only
- 3. Callbacks require Client Components — onLoad is the file arriving (once), onReady is 'initialize now' (every mount), and neither onLoad nor onError may be paired with beforeInteractive
- 4. Placement scopes the script: root layout runs site-wide, feature pages run only where rendered; ids identify instances, and beforeInteractive ordering follows declaration order
- 5. Third-party cost shows up as INP, not the network waterfall — audit long tasks after hydration, move scripts down the ladder, and gate consent-gated vendors by appending at runtime
Interview Tips
- • "Where does Google Analytics go?" — afterInteractive (the default) in the root layout; explain what you rejected: beforeInteractive would queue first-party code behind a third party, lazyOnload delays hits unnecessarily, raw <script> blocks parse
- • "onLoad or onReady?" — onLoad fires once when the file arrives; onReady fires after load and again on every remount, which is why the Maps embed pattern uses it; note beforeInteractive supports neither onLoad nor onError
- • "How do you keep third-party scripts from hurting responsiveness?" — strategies push work to idle, long tasks are the >50 ms bar in the Performance panel, INP thresholds are 200/500 ms, and consent-gated vendors are appended at runtime rather than declared
- • "Describe beforeInteractive in one breath." — in the root layout, injected into <head>, fetched before first-party modules, executed in placement order, does not block hydration, runs once per document; reserved for consent and bot detection
Cheat Sheet
Loading Scripts with next/script
Strategy matrix:
| Strategy | Loads | Placement | Use for |
|---|---|---|---|
beforeInteractive |
in initial HTML, before first-party modules; once per document; does NOT block hydration; always in <head> |
root layout only | consent managers, bot detectors |
afterInteractive (default) |
client-injected after hydration | any page/layout (scoped to where rendered) | analytics, tag managers |
lazyOnload |
idle time after all resources | any page/layout | chat, social widgets |
worker |
web worker (experimental) | pages/ + nextScriptWorkers: true |
advanced |
Callbacks (all Client Components):
'use client'
<Script id="vendor" src="https://cdn.example.com/lib.js"
onLoad={() => {}} // file arrived — once; NOT allowed with beforeInteractive
onReady={() => {}} // initialize now — every mount (Maps embed pattern)
onError={(e) => {}} // fetch failed — NOT allowed with beforeInteractive />
Consent-gated loading: consent manager = beforeInteractive; vendors appended at runtime from the accept handler — never declared in the tree.
function loadVendor(src: string) {
const s = document.createElement('script'); s.src = src; s.async = true
document.body.appendChild(s)
}
Audit loop: Network (3rd-party transfer size) → Performance (long-task bars > 50 ms after hydration) → attribute by URL → move the vendor down the ladder: default → lazyOnload → runtime-gated → beforeInteractive only if the page breaks without it. Cost surfaces as INP (good ≤ 200 ms, poor > 500 ms).