Next.js Interview Prep
Performance and Optimization

Script Optimization in Next.js -- next/script

Script Optimization in Next.js -- next/script

LinkedIn Hook

"Your Lighthouse score just dropped 30 points. The culprit? A single line of Google Analytics."

Here's the dirty secret of modern web performance: your React code is probably fast. Your images are probably optimized. Your fonts load without layout shift. And yet your site still feels sluggish -- because a dozen third-party scripts (analytics, chat widgets, tag managers, ad pixels) are hijacking the main thread before your app even mounts.

The fix isn't removing them. Marketing needs their tools. The fix is loading them correctly.

Next.js ships next/script -- a component that gives you surgical control over when and how every script loads. beforeInteractive for critical polyfills. afterInteractive for analytics. lazyOnload for chat widgets nobody clicks for 10 seconds. worker to push scripts entirely off the main thread using Partytown.

One strategy flag can turn a blocking 300ms script into a zero-cost background task. Most devs never touch it.

In Lesson 7.3, I break down every loading strategy, when to use each, and the real-world patterns interviewers love to probe.

Read the full lesson -> [link]

#NextJS #WebPerformance #CoreWebVitals #ThirdPartyScripts #FrontendDevelopment #InterviewPrep


Script Optimization in Next.js -- next/script thumbnail


What You'll Learn

  • How next/script controls when and how third-party scripts load
  • The four loading strategies: beforeInteractive, afterInteractive, lazyOnload, worker
  • When to use each strategy for analytics, chat widgets, tag managers, and polyfills
  • How Partytown offloads scripts to a web worker, freeing the main thread
  • Inline script patterns with dangerouslySetInnerHTML and the id requirement
  • Lifecycle callbacks: onLoad, onReady, onError
  • Real-world examples: Google Analytics, GTM, Intercom, Facebook Pixel
  • Common mistakes that silently destroy Core Web Vitals

The Traffic Controller Analogy -- Why Script Loading Matters

Imagine a busy airport with one runway. Every plane (script) wants to land immediately. If the tower lets them all land whenever they feel like it, planes pile up, flights delay, and the whole airport grinds to a halt. That's what happens when you drop twenty <script> tags into your HTML. The browser's main thread becomes the runway, and every script fights for it.

A good air traffic controller orders arrivals. Critical flights land first (emergency medical, polyfills). Regular flights land after the runway is safe (analytics, A/B testing). Non-urgent cargo waits until the sky is clear (chat widgets, social embeds). And some cargo doesn't need the main runway at all -- it can use the auxiliary runway (web worker).

next/script is that controller. By passing a strategy prop, you tell Next.js exactly which runway a script should use and when. The browser stays responsive, your Largest Contentful Paint stays low, and your Interaction to Next Paint doesn't suffer when a tag manager bloats to 200 KB.

+---------------------------------------------------------------+
|           UNCONTROLLED SCRIPT LOADING (The Problem)           |
+---------------------------------------------------------------+
|                                                                |
|  <head>                                                        |
|    <script src="analytics.js"></script>        <-- blocks      |
|    <script src="gtm.js"></script>              <-- blocks      |
|    <script src="intercom.js"></script>         <-- blocks      |
|    <script src="pixel.js"></script>            <-- blocks      |
|  </head>                                                       |
|                                                                |
|  Main thread timeline:                                         |
|  [parse HTML][analytics][gtm][intercom][pixel][render app]     |
|              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                  |
|              User sees blank screen for 2+ seconds             |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           NEXT/SCRIPT STRATEGIC LOADING (The Solution)         |
+---------------------------------------------------------------+
|                                                                |
|  <Script src="polyfill.js" strategy="beforeInteractive" />     |
|  <Script src="analytics.js" strategy="afterInteractive" />     |
|  <Script src="intercom.js" strategy="lazyOnload" />            |
|  <Script src="gtm.js" strategy="worker" />                     |
|                                                                |
|  Main thread timeline:                                         |
|  [polyfill][parse HTML][render app][analytics][...later...]    |
|                        ^^^^^^^^^^^^                            |
|                        User sees app immediately               |
|                        (intercom loads on idle)                |
|                        (gtm runs in web worker)                |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). A horizontal timeline split into 4 lanes: 'beforeInteractive' (red #ef4444, urgent), 'afterInteractive' (emerald #10b981, default), 'lazyOnload' (purple #8b5cf6, relaxed), 'worker' (cyan #06b6d4, offloaded). Each lane shows a small script icon entering at a different moment relative to a vertical 'page interactive' marker. Above the timeline, a browser window with a green checkmark and '0ms main thread blocking' label. White monospace labels throughout."


The Four Loading Strategies

next/script exposes four strategies via the strategy prop. Each one tells Next.js exactly when to insert the script tag into the document and how to schedule its execution.

Strategy 1: beforeInteractive

The script loads before any Next.js hydration code runs. The HTML is parsed, then this script executes, then React hydrates. Use this only for scripts the rest of your app depends on -- polyfills, feature detection, bot detection, consent managers that must block everything else.

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {/* Polyfill must run before any app code */}
        <Script
          src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"
          strategy="beforeInteractive"
        />
        {children}
      </body>
    </html>
  );
}

Important: beforeInteractive only works in the root layout in the App Router. If you put it in a regular page or component, Next.js ignores the strategy and falls back to afterInteractive. This is because the script must be injected before hydration begins -- something only the root layout can guarantee.

Strategy 2: afterInteractive (Default)

The script loads after the page becomes interactive -- meaning after hydration completes. This is the default strategy and the right choice for the vast majority of third-party scripts, especially analytics and tag managers that need to track user sessions from the start.

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Google Analytics -- loads after the app is interactive */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
          strategy="afterInteractive"
        />
        <Script id="google-analytics" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'G-XXXXXXXXXX');
          `}
        </Script>
      </body>
    </html>
  );
}

Why default: the user's app is already visible and clickable. The script arrives in the background without blocking first paint, yet it's early enough to catch the full session duration for analytics purposes.

Strategy 3: lazyOnload

The script loads during browser idle time, after all other resources have been fetched. Use this for anything the user won't interact with in the first few seconds -- chat widgets, social media embeds, customer support popups, feedback tools.

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Intercom chat widget -- no rush, load when idle */}
        <Script
          src="https://widget.intercom.io/widget/APP_ID"
          strategy="lazyOnload"
        />
      </body>
    </html>
  );
}

Real impact: Intercom's widget weighs ~250 KB. Loading it afterInteractive delays your Time to Interactive. Loading it lazyOnload means it arrives silently in the background only after everything else is done. The user who never opens the chat pays zero cost.

Strategy 4: worker (Experimental -- Partytown)

The script runs in a web worker, entirely off the main thread. This is powered by Partytown, a library that intercepts DOM access from a worker and proxies it back to the main thread asynchronously.

// next.config.js
// Opt in to the experimental worker strategy
module.exports = {
  experimental: {
    nextScriptWorkers: true,
  },
};
// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Google Tag Manager runs in a web worker, not main thread */}
        <Script
          src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
          strategy="worker"
        />
      </body>
    </html>
  );
}

The trade-off: DOM access from a worker is slower than direct access (because every call is proxied), but for scripts that mostly ping servers and read cookies -- analytics, pixels, tag managers -- the main-thread savings are enormous. Partytown can cut a GTM-induced 400ms blocking task down to near-zero main-thread cost.

+---------------------------------------------------------------+
|           STRATEGY DECISION FLOW                              |
+---------------------------------------------------------------+
|                                                                |
|  Does the app break without this script?                       |
|    YES -> beforeInteractive (polyfills, consent)               |
|    NO  -> continue                                             |
|                                                                |
|  Does it need to track from the very first moment?             |
|    YES -> afterInteractive (GA, Segment, Mixpanel)             |
|    NO  -> continue                                             |
|                                                                |
|  Does the user interact with it within 5 seconds?              |
|    NO  -> lazyOnload (chat, social embeds, feedback)           |
|    YES -> afterInteractive                                     |
|                                                                |
|  Is the script heavy and mostly talks to servers?              |
|    YES -> worker (GTM, Facebook Pixel, ads)                    |
|                                                                |
+---------------------------------------------------------------+

Inline Scripts -- Configuration and Initialization

Not every script is a remote URL. Many third-party tools require an inline snippet to configure them (setting a tracking ID, defining a data layer, calling an init function). next/script supports inline scripts in two ways: children or dangerouslySetInnerHTML.

Inline with Children

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Inline script MUST have a unique id */}
        <Script id="hotjar-init" strategy="afterInteractive">
          {`
            (function(h,o,t,j,a,r){
              h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
              h._hjSettings={hjid:1234567,hjsv:6};
              a=o.getElementsByTagName('head')[0];
              r=o.createElement('script');r.async=1;
              r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
              a.appendChild(r);
            })(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
          `}
        </Script>
      </body>
    </html>
  );
}

The id requirement: Every inline <Script> must have a unique id prop. Next.js uses this ID to deduplicate scripts across navigations and to track which inline scripts have already executed. Without an id, Next.js throws an error in development and may re-execute the script on every route change in production.

Inline with dangerouslySetInnerHTML

// Alternative syntax -- useful when the script content comes from a variable
<Script
  id="config-script"
  strategy="afterInteractive"
  dangerouslySetInnerHTML={{
    __html: `window.APP_CONFIG = ${JSON.stringify(appConfig)};`,
  }}
/>

This form is helpful when you need to serialize server-side data into a global variable before other scripts run.


Lifecycle Callbacks -- onLoad, onReady, onError

next/script fires three callbacks you can hook into. These are invaluable when your app code needs to wait for a third-party library to finish loading before calling its API.

// components/MapLoader.tsx
'use client';

import Script from 'next/script';
import { useState } from 'react';

export default function MapLoader() {
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');

  return (
    <>
      <Script
        src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY"
        strategy="afterInteractive"
        // Fires once, the first time the script finishes loading
        onLoad={() => {
          console.log('Google Maps script loaded');
          // Safe to call window.google.maps here
        }}
        // Fires every time the component mounts, even if already loaded
        onReady={() => {
          console.log('Google Maps ready to use');
          setStatus('ready');
          // Initialize the map here -- this is the recommended callback
          // for using a library, because it also handles re-mounts
        }}
        // Fires if the script fails to load (network error, 404, etc.)
        onError={(e) => {
          console.error('Failed to load Google Maps:', e);
          setStatus('error');
        }}
      />
      {status === 'ready' && <div id="map" style={{ height: 400 }} />}
      {status === 'error' && <p>Map failed to load. Please refresh.</p>}
    </>
  );
}

Understanding the difference between onLoad and onReady:

  • onLoad fires once, the first time the script is executed in the page lifecycle. If you navigate away and back, the script is already loaded, so onLoad does not fire again.
  • onReady fires on the initial load and on every subsequent mount of the component. This is the safer callback for initializing SDK state (drawing a map, opening a widget) because it handles client-side navigation correctly.
  • onError fires when the script tag's error event fires -- typically network failures, blocked scripts (adblockers), or bad URLs. Always handle this for critical scripts to avoid silent failures.

Real-World Example -- Google Tag Manager

GTM is the most commonly loaded third-party script on the web. Here's the recommended pattern for loading it correctly in a Next.js App Router project, using the worker strategy for maximum performance.

// app/layout.tsx
import Script from 'next/script';

const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        {/* GTM inline bootstrap -- pushes initial events to the dataLayer */}
        <Script
          id="gtm-init"
          strategy="afterInteractive"
          dangerouslySetInnerHTML={{
            __html: `
              (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push(
                {'gtm.start': new Date().getTime(), event:'gtm.js'}
              );var f=d.getElementsByTagName(s)[0],
              j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
              j.async=true;j.src=
              'https://www.googletagmanager.com/gtm.js?id='+i+dl;
              f.parentNode.insertBefore(j,f);
              })(window,document,'script','dataLayer','${GTM_ID}');
            `,
          }}
        />
      </head>
      <body>
        {/* Fallback noscript iframe for users without JavaScript */}
        <noscript>
          <iframe
            src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
            height="0"
            width="0"
            style={{ display: 'none', visibility: 'hidden' }}
          />
        </noscript>
        {children}
      </body>
    </html>
  );
}

Real-World Example -- Facebook Pixel

Facebook Pixel is a classic case where lazyOnload or worker wins. It fires a handful of network pings -- it does not render DOM, so offloading it costs nothing.

// components/FacebookPixel.tsx
'use client';

import Script from 'next/script';

const PIXEL_ID = process.env.NEXT_PUBLIC_FB_PIXEL_ID;

export default function FacebookPixel() {
  return (
    <Script
      id="fb-pixel"
      strategy="lazyOnload"
      dangerouslySetInnerHTML={{
        __html: `
          !function(f,b,e,v,n,t,s){
            if(f.fbq)return;n=f.fbq=function(){
              n.callMethod? n.callMethod.apply(n,arguments):n.queue.push(arguments)
            };
            if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
            n.queue=[];t=b.createElement(e);t.async=!0;
            t.src=v;s=b.getElementsByTagName(e)[0];
            s.parentNode.insertBefore(t,s)
          }(window, document,'script',
          'https://connect.facebook.net/en_US/fbevents.js');
          fbq('init', '${PIXEL_ID}');
          fbq('track', 'PageView');
        `,
      }}
    />
  );
}

Common Mistakes

1. Using beforeInteractive for analytics scripts. Analytics does not need to block hydration. Putting Google Analytics or Segment on beforeInteractive forces the browser to download, parse, and execute a multi-hundred-kilobyte script before the page becomes interactive. This tanks Largest Contentful Paint and Time to Interactive. Analytics belongs on afterInteractive (default) -- it still catches the full session duration without delaying first paint.

2. Forgetting the id prop on inline scripts. Every inline <Script> (one that uses children or dangerouslySetInnerHTML) must have a unique id. Without it, Next.js cannot deduplicate the script across client-side navigations, and you may see the script re-executing on every route change -- leading to duplicate analytics events, doubled tracking pixels, and strange SDK behavior.

3. Placing beforeInteractive scripts outside the root layout. In the App Router, beforeInteractive only works when the <Script> is placed in app/layout.tsx. If you put it in a nested layout or a page, Next.js silently downgrades it to afterInteractive. The reason is architectural: scripts must be injected before hydration begins, and only the root layout renders before hydration.

4. Calling the third-party API before it loads. A common bug: putting window.fbq('track', ...) in a useEffect that runs before the Facebook Pixel script finishes loading. Always gate third-party API calls on the onReady callback, or check for the global's existence before calling it: if (typeof window.fbq === 'function') { ... }.

5. Ignoring onError for critical scripts. Adblockers routinely block analytics and pixel scripts. Corporate firewalls block others. If your app silently assumes a script loaded successfully, you'll see mysterious runtime errors when the global is undefined. Always attach onError to critical scripts and handle the failure gracefully -- log it, show a fallback, or disable features that depend on the script.


Interview Questions

1. "What are the four loading strategies in next/script and when would you use each?"

beforeInteractive loads the script before any Next.js hydration runs -- use it only for polyfills, consent managers, or bot detection that the rest of the app depends on. In the App Router it only works in the root layout. afterInteractive (the default) loads the script after hydration completes -- this is correct for analytics and tag managers that need to track full sessions without blocking first paint. lazyOnload loads the script during browser idle time after everything else is done -- ideal for chat widgets, social embeds, and feedback tools the user won't touch for several seconds. worker runs the script in a web worker via Partytown, keeping the main thread completely free -- perfect for heavy scripts like Google Tag Manager that mostly ping servers rather than touching the DOM.

2. "Why does Next.js restrict beforeInteractive to the root layout in the App Router?"

beforeInteractive guarantees that the script runs before React hydration, which means it must be injected into the initial HTML stream. Only the root layout participates in that initial stream -- nested layouts and pages are rendered and hydrated progressively, so they have no way to insert a script before the hydration boundary that has already passed. If you place a beforeInteractive script in a nested layout or page, Next.js silently downgrades it to afterInteractive because the original guarantee cannot be honored at that point in the render tree.

3. "What is Partytown and how does the worker strategy improve performance?"

Partytown is a library that runs third-party scripts inside a web worker instead of on the main thread. Because web workers have no direct DOM access, Partytown intercepts every DOM, cookie, and storage call via a synchronous-looking proxy and forwards it to the main thread via postMessage. For scripts that are mostly network pings and cookie reads -- analytics, tag managers, ad pixels -- this means near-zero main-thread cost even for multi-hundred-kilobyte scripts. The trade-off is that DOM operations from the worker are slower due to proxy overhead, so Partytown is not suitable for scripts that heavily manipulate the DOM, like rich text editors or visualization libraries. In Next.js, you opt in by setting experimental.nextScriptWorkers: true and then passing strategy="worker" to any <Script>.

4. "What is the difference between onLoad and onReady, and which should you use to initialize a third-party SDK?"

onLoad fires exactly once -- the first time the script's load event fires in the page lifecycle. onReady fires both on the initial load and again on every subsequent mount of the component that renders the <Script>. In a single-page app with client-side navigation, a user can leave a page and come back without the script reloading. onLoad would not fire the second time, but onReady would -- making it the correct place to re-initialize a map, reopen a widget, or re-register an SDK callback. For one-time bootstrap logic (like declaring a dataLayer), onLoad is fine. For anything that needs to happen every time the hosting component mounts, use onReady.

5. "You're loading Google Tag Manager and it's adding 400ms of main-thread blocking time. How would you fix it in Next.js?"

First, I'd confirm the blocking time comes from GTM itself rather than the tags it loads, using the Performance panel's long task view. If GTM's own bootstrap is the issue, I'd switch its <Script> strategy to worker, enabling experimental.nextScriptWorkers in next.config.js so Partytown can run GTM in a web worker. That moves the download, parse, and execution off the main thread entirely -- Partytown proxies any DOM access GTM needs. If the downstream tags GTM fires are the real culprits, I'd audit the GTM workspace to move non-critical tags (ad pixels, heatmaps) behind triggers that only fire after user interaction, and consider loading the GTM script itself with lazyOnload if no first-paint events need to be captured. As a final fallback, I'd ensure the inline GTM bootstrap snippet uses strategy="afterInteractive" at minimum so it never runs before hydration.


Quick Reference -- Script Optimization Cheat Sheet

+---------------------------------------------------------------+
|           NEXT/SCRIPT CHEAT SHEET                             |
+---------------------------------------------------------------+
|                                                                |
|  IMPORT:                                                       |
|  import Script from 'next/script'                              |
|                                                                |
|  REMOTE SCRIPT:                                                |
|  <Script src="..." strategy="afterInteractive" />              |
|                                                                |
|  INLINE SCRIPT (id is REQUIRED):                               |
|  <Script id="init" strategy="afterInteractive">                |
|    {`window.foo = 'bar';`}                                     |
|  </Script>                                                     |
|                                                                |
|  WITH CALLBACKS:                                               |
|  <Script                                                       |
|    src="..."                                                   |
|    onLoad={() => {}}                                           |
|    onReady={() => {}}                                          |
|    onError={(e) => {}}                                         |
|  />                                                            |
|                                                                |
|  WORKER (enable in next.config.js first):                      |
|  experimental: { nextScriptWorkers: true }                     |
|  <Script src="..." strategy="worker" />                        |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           KEY RULES                                            |
+---------------------------------------------------------------+
|                                                                |
|  1. Default to afterInteractive for analytics                  |
|  2. Use lazyOnload for chat widgets and social embeds          |
|  3. beforeInteractive ONLY in root layout, ONLY for polyfills  |
|  4. Always give inline scripts a unique id                     |
|  5. Use onReady, not onLoad, for SDK initialization            |
|  6. Always handle onError for adblocker/network failures       |
|  7. Heavy + mostly-network scripts -> worker via Partytown     |
|                                                                |
+---------------------------------------------------------------+
StrategyWhen It LoadsBlocks Hydration?Best For
beforeInteractiveBefore hydrationYesPolyfills, consent managers, bot detection
afterInteractiveAfter hydration (default)NoAnalytics, tag managers, A/B testing
lazyOnloadBrowser idle timeNoChat widgets, social embeds, feedback tools
workerIn a web workerNoGTM, Facebook Pixel, heavy analytics
CallbackFires WhenUse Case
onLoadOnce, on first loadOne-time bootstrap logic
onReadyOn load and every mountSDK initialization, re-entry safe setup
onErrorOn load failureAdblocker/network error handling

Prev: Lesson 7.2 -- Font Optimization in Next.js Next: Lesson 7.4 -- Metadata and SEO in Next.js


This is Lesson 7.3 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.

On this page