Third-party scripts with next/script
I've seen production sites crawl to a halt because Google Analytics, Intercom, Mixpanel, and five ad networks all loaded before the first paint. Your React component tree renders fast, but then 2MB of tracking code blocks the main thread. The Next.js next/script component solves this by letting you load third-party JavaScript with intention—specifying when it executes relative to your page lifecycle.
Analytics should not slow your site. In this post, I'll walk you through the three loading strategies, show you when to use each one, and share the quarterly audit habit that keeps script bloat from creeping back in.
Understanding the next/script strategies
Before diving into code, let's clarify what next/script actually does. It's a Next.js wrapper around the HTML <script> tag that gives you fine-grained control over execution order and timing. Unlike dropping a raw <script> in your _document.tsx or sprinkling them in component files, next/script ensures consistent behavior across builds and lets Next.js optimize when those scripts run.
The component accepts a strategy prop that changes everything about how and when the script loads and executes. Get this wrong, and you're back to slow sites. Get it right, and your analytics fire without stealing CPU cycles from React.
strategy='afterInteractive' for most tags
This is your default hammer. Use afterInteractive for Google Analytics, Segment, Amplitude, Mixpanel, Intercom, and any vendor that doesn't need to run before the page is interactive. The browser loads the script after Next.js hydration completes, which means your React app is already painted and responding to clicks.
In a recent e-commerce rebuild, we moved our analytics from beforeInteractive to afterInteractive and cut first contentful paint by 340ms. The trade-off: events from the first few hundred milliseconds might not fire. In practice, almost nobody cares—you'll see 99% of user sessions either way.
import Script from 'next/script';
export default function Layout({ children }) {
return (
<>
{children}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>
<Script
id="ga-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'GA_ID');
`,
}}
/>
</>
);
}Notice the id prop on the second script. Next.js uses it to deduplicate—if the same id appears twice, the script runs only once. This is critical when you have a shared layout and multiple page components trying to initialize the same tracker.
For Segment, the pattern is nearly identical. Load the CDN script with afterInteractive, then initialize it in a separate <Script> block with your write key:
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<>
{children}
<Script
src="https://cdn.segment.com/analytics.js/v1/YOUR_WRITE_KEY/analytics.min.js"
strategy="afterInteractive"
/>
<Script
id="segment-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
analytics.ready(() => {
analytics.identify('user-id', { email: 'user@example.com' });
});
`,
}}
/>
</>
);
}The beauty here is that afterInteractive runs early enough to capture page view events without blocking initial render. By the time the user is clicking around, your analytics are live.
lazyOnload for chat widgets
Chat widgets, heatmap tools (Hotjar, Clarity), and polite ads should use lazyOnload. These scripts are nice-to-have, not need-to-have. They fire in a background task after the browser is idle, typically several seconds after load.
On a ride-hailing platform I worked on, we had Intercom chat loading on every page. Users complained that the widget took time to appear, and it also delayed other interactions. We switched to lazyOnload, and the widget now loads silently in the background. By the time a user notices it's there (usually after 30 seconds), it's been running for 20. Performance metrics improved, user complaints dropped.
import Script from 'next/script';
export default function ChatWidget() {
return (
<Script
src="https://widget.intercom.io/widget/YOUR_APP_ID"
strategy="lazyOnload"
onLoad={() => {
if (window.Intercom) {
window.Intercom('boot', {
app_id: 'YOUR_APP_ID',
user_id: getCurrentUserId(),
});
}
}}
/>
);
}The onLoad callback fires once the script downloads and executes. This is your hook to initialize the widget with user data or custom configuration. Avoid doing heavy lifting in onLoad—keep it synchronous and fast.
For Hotjar, the pattern is the same:
import Script from 'next/script';
export default function Analytics() {
return (
<Script
src="https://static.hotjar.com/c/hotjar-YOUR_SITE_ID.js?sv=6"
strategy="lazyOnload"
/>
);
}Because heatmaps consume CPU and bandwidth to record sessions, lazy loading them is both a performance win and a user experience win. Users on slow connections won't suffer the penalty if they bounce fast.
Avoid beforeInteractive unless essential
This strategy forces the browser to fetch and execute the script before React hydrates. Use it only for:
- Flash-of-unstyled-content (FOUC) prevention—e.g., a theme picker that must run before paint
- Critical feature detection—e.g., checking if the user's browser supports a required API
- Auth initialization—e.g., loading a session token from a third-party service
I've seen teams use beforeInteractive for Google Analytics "just to be safe." It's rarely necessary and always pays a performance price. Unless you have a documented business requirement (like legal compliance that demands analytics fire before user interaction), stick with afterInteractive.
Here's a legitimate use case: a dark mode preference stored in localStorage. If you don't load and apply the theme before paint, the user sees a white flash on a dark desktop:
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<>
<Script
id="theme-init"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.dataset.theme = theme;
`,
}}
/>
{children}
</>
);
}This runs before React renders, so the DOM gets the correct data-theme attribute from the start. Without beforeInteractive, you'd see a flicker. But this is the exception, not the rule.
Common mistakes
Over 5+ years, I've watched teams shoot themselves in the foot with next/script. Here are the patterns to avoid:
Mistake 1: Multiple initialization scripts without deduplication
A shared layout initializes Segment, then a page component initializes it again. The script runs twice, causing duplicate events and wasted bandwidth. Always use stable id props:
// ✗ BAD: No ID, could load multiple times
<Script src="https://cdn.segment.com/..." strategy="afterInteractive" />
// ✓ GOOD: ID prevents duplicates
<Script
id="segment-analytics"
src="https://cdn.segment.com/..."
strategy="afterInteractive"
/>Mistake 2: Forgetting the onError handler
CDNs go down. Your script URL might be wrong. If you don't handle errors, your page breaks silently. Always wrap third-party scripts with error handling:
import Script from 'next/script';
export default function Analytics() {
return (
<Script
src="https://external-analytics.com/tracker.js"
strategy="afterInteractive"
onError={() => {
console.error('Analytics failed to load');
// Optionally send an alert to your error tracking service
}}
/>
);
}Mistake 3: Ignoring the async/defer distinction
Don't use async or defer` attributes directly. Next.js handles this for you based on the strategy. If you pass both a strategy and these attributes, you're fighting the framework.
Mistake 4: Loading scripts at the component level when they should be global
If a script runs on every page (analytics, session tracking), load it in your root layout or _app.tsx, not in every component. Duplicating the script load across multiple files wastes network requests and makes audits harder.
Audit tags quarterly — they pile up
This is the habit that separates maintained codebases from script graveyards. Every quarter, I search my codebase for all <Script> components and ask three questions:
- Is this script still active in our analytics vendor's dashboard?
- Are we actually using the data it collects?
- If we deleted it, would anything break?
Invariably, we find scripts left over from experiments (A/B tests that ended six months ago), abandoned tools (Mixpanel replaced by Segment), or copy-pasted code (the same tracking tag in three different layouts).
Here's a simple audit checklist I use:
- Run a regex search for all
<Script>imports and usage - Cross-check each against your active vendor list (check billing dashboards)
- Search your codebase for references to each script's global variable (e.g.,
window.gtag,window.Intercom). If it's never used, it's dead weight. - Delete what's unused. Redeploy. Monitor error logs for 24 hours.
- Set a calendar reminder for 90 days from now
In a SaaS product I maintained, we found six analytics scripts loaded on every page. Three were redundant. By deleting them, we reduced third-party blocking time by 1.2 seconds. That single audit paid for itself in better Core Web Vitals and faster load times.
Create a spreadsheet and own it. Document which scripts are in which layouts, their purpose, and the date you last verified they're active. This becomes invaluable when new team members ask "why is this here?" and makes quarterly cleanups fast.
Key takeaways
- Use
strategy="afterInteractive"for analytics, tracking, and data collection. It fires after hydration and won't block initial render. - Use
strategy="lazyOnload"for chat widgets, heatmaps, and polite ads that don't need to run immediately. - Avoid
beforeInteractiveunless you have a specific, documented reason (FOUC prevention, critical auth). It always slows your page. - Always use stable
idprops to prevent script duplication across layouts and pages. - Add
onErrorhandlers to catch CDN failures gracefully. - Audit your scripts quarterly. Unused tags pile up fast and silently degrade performance.
- Prioritize being thoughtful about every script. One extra tracker might not hurt, but ten will.






Comments
Loading comments…