Optimización de Scripts

Scripts de terceros (Google Analytics, Facebook Pixel, chat widgets) pueden hacer tu sitio lento si no los cargas correctamente. NextJS tiene el componente Script para optimizarlos.

El problema con <script>

<!-- ❌ Tag HTML tradicional -->
<script src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'GA_ID');
</script>

Problemas:

  • Bloquea el renderizado (página lenta)
  • No hay control de cuándo se carga
  • Puede duplicarse en navegación cliente
  • Dificulta medir el impacto en performance

La solución: <Script>

import Script from 'next/script'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        
        <Script 
          src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
          strategy="afterInteractive"
        />
        <Script id="google-analytics" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_ID');
          `}
        </Script>
      </body>
    </html>
  )
}

NextJS controla cuándo y cómo se cargan los scripts para no afectar la performance.

Estrategias de carga

La prop strategy controla cuándo se carga el script:

beforeInteractive

Carga antes de que la página sea interactiva. Para scripts críticos.

<Script 
  src="/polyfills.js"
  strategy="beforeInteractive"
/>

Cuándo usar:

  • Polyfills críticos
  • Scripts que deben ejecutarse antes que todo
  • Bot detection
  • A/B testing que afecta el primer render

Importante: Solo funciona en app/layout.tsx, no en páginas individuales.

afterInteractive (default)

Carga después de que la página sea interactiva. Para la mayoría de scripts.

<Script 
  src="https://www.googletagmanager.com/gtag/js"
  strategy="afterInteractive"
/>

Cuándo usar:

  • Analytics (Google Analytics, Mixpanel)
  • Ads
  • Social widgets
  • Chat widgets
  • La mayoría de scripts de terceros

Por qué es mejor: El usuario puede interactuar con tu página mientras los scripts cargan en background.

lazyOnload

Carga cuando el navegador está idle (no tiene nada más que hacer).

<Script 
  src="https://connect.facebook.net/en_US/sdk.js"
  strategy="lazyOnload"
/>

Cuándo usar:

  • Scripts no críticos
  • Widgets de redes sociales
  • Comentarios (Disqus)
  • Customer support chats
  • Scripts que no afectan la funcionalidad principal

Por qué es mejor: Estos scripts no compiten por recursos con el contenido principal.

worker (experimental)

Carga el script en un Web Worker (no bloquea el thread principal).

<Script 
  src="/heavy-calculation.js"
  strategy="worker"
/>

Experimental - No recomendado para producción aún.

Ejemplos prácticos

Google Analytics

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

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        
        <Script
          src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`}
          strategy="afterInteractive"
        />
        <Script id="google-analytics" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', '${process.env.NEXT_PUBLIC_GA_ID}');
          `}
        </Script>
      </body>
    </html>
  )
}

Google Tag Manager

<Script
  id="gtm"
  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','${process.env.NEXT_PUBLIC_GTM_ID}');
    `
  }}
/>

Facebook Pixel

<Script
  id="facebook-pixel"
  strategy="afterInteractive"
>
  {`
    !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', '${process.env.NEXT_PUBLIC_FB_PIXEL_ID}');
    fbq('track', 'PageView');
  `}
</Script>

Chat widget (Intercom)

<Script
  id="intercom"
  strategy="lazyOnload"
>
  {`
    window.intercomSettings = {
      app_id: "${process.env.NEXT_PUBLIC_INTERCOM_ID}"
    };
    (function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){
    ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;
    var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};
    w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';
    s.async=true;s.src='https://widget.intercom.io/widget/${process.env.NEXT_PUBLIC_INTERCOM_ID}';
    var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};
    if(document.readyState==='complete'){l();}else if(w.attachEvent){
    w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
  `}
</Script>

Stripe

<Script
  src="https://js.stripe.com/v3/"
  strategy="afterInteractive"
  onLoad={() => {
    console.log('Stripe cargado')
  }}
/>

Mapbox

<Script
  src="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js"
  strategy="afterInteractive"
/>

Eventos del ciclo de vida

Ejecuta código cuando el script carga:

onLoad

<Script
  src="https://example.com/script.js"
  strategy="afterInteractive"
  onLoad={() => {
    console.log('Script cargado correctamente')
    // Inicializar librería
    window.exampleLib.init()
  }}
/>

onReady

<Script
  src="https://example.com/script.js"
  strategy="afterInteractive"
  onReady={() => {
    console.log('Script listo para usar')
  }}
/>

onError

<Script
  src="https://example.com/script.js"
  strategy="afterInteractive"
  onError={(e) => {
    console.error('Error cargando script:', e)
  }}
/>

Scripts inline

Para código JavaScript inline:

<Script id="inline-script" strategy="afterInteractive">
  {`
    console.log('Este código se ejecuta inline');
    window.miVariable = 'valor';
  `}
</Script>

Importante: Scripts inline requieren un id único.

Componente reutilizable

Crea un componente para scripts comunes:

// components/GoogleAnalytics.tsx
import Script from 'next/script'

export default function GoogleAnalytics({ gaId }: { gaId: string }) {
  return (
    <>
      <Script
        src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
        strategy="afterInteractive"
      />
      <Script id="google-analytics" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', '${gaId}');
        `}
      </Script>
    </>
  )
}
// app/layout.tsx
import GoogleAnalytics from '@/components/GoogleAnalytics'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID!} />
      </body>
    </html>
  )
}

Scripts por entorno

Solo carga en producción:

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        
        {process.env.NODE_ENV === 'production' && (
          <Script
            src="https://www.googletagmanager.com/gtag/js"
            strategy="afterInteractive"
          />
        )}
      </body>
    </html>
  )
}

Scripts condicionales

Solo carga si el usuario aceptó cookies:

'use client'

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

export default function ConsentManager() {
  const [accepted, setAccepted] = useState(false)
  
  return (
    <>
      {!accepted && (
        <div className="cookie-banner">
          <p>Usamos cookies para analytics</p>
          <button onClick={() => setAccepted(true)}>
            Aceptar
          </button>
        </div>
      )}
      
      {accepted && (
        <Script
          src="https://www.googletagmanager.com/gtag/js"
          strategy="afterInteractive"
        />
      )}
    </>
  )
}

Patrones avanzados

Lazy loading de librería pesada

'use client'

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

export default function Map() {
  const [mapLoaded, setMapLoaded] = useState(false)
  const [showMap, setShowMap] = useState(false)
  
  return (
    <>
      <button onClick={() => setShowMap(true)}>
        Ver mapa
      </button>
      
      {showMap && (
        <>
          <Script
            src="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js"
            strategy="lazyOnload"
            onLoad={() => setMapLoaded(true)}
          />
          
          {mapLoaded ? (
            <div id="map"></div>
          ) : (
            <div>Cargando mapa...</div>
          )}
        </>
      )}
    </>
  )
}

Múltiples scripts dependientes

'use client'

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

export default function StripeCheckout() {
  const [stripeLoaded, setStripeLoaded] = useState(false)
  
  return (
    <>
      <Script
        src="https://js.stripe.com/v3/"
        strategy="afterInteractive"
        onLoad={() => setStripeLoaded(true)}
      />
      
      {stripeLoaded && (
        <Script id="stripe-init">
          {`
            const stripe = Stripe('${process.env.NEXT_PUBLIC_STRIPE_KEY}');
            // Inicializar Stripe
          `}
        </Script>
      )}
    </>
  )
}

Script con datos del servidor

// app/layout.tsx
export default async function RootLayout({ children }) {
  const config = await fetchConfig()
  
  return (
    <html>
      <body>
        {children}
        
        <Script id="config" strategy="beforeInteractive">
          {`window.APP_CONFIG = ${JSON.stringify(config)};`}
        </Script>
      </body>
    </html>
  )
}

Comparación de estrategias

EstrategiaCuándo cargaUso típicoPerformance
beforeInteractiveAntes de hidrataciónPolyfills, A/B testing⚠️ Bloquea render inicial
afterInteractiveDespués de hidrataciónAnalytics, ads✅ Bueno
lazyOnloadCuando browser idleWidgets, chats✅✅ Excelente
workerEn Web WorkerCálculos pesados✅✅✅ Experimental

Mejores prácticas

1. Usa la estrategia correcta

// ✅ Analytics: afterInteractive
<Script src="/analytics.js" strategy="afterInteractive" />

// ✅ Chat: lazyOnload
<Script src="/chat.js" strategy="lazyOnload" />

// ✅ Polyfill crítico: beforeInteractive (solo en layout)
<Script src="/polyfill.js" strategy="beforeInteractive" />

2. Agrupa scripts relacionados

// components/Analytics.tsx
export default function Analytics() {
  return (
    <>
      <Script src="/ga.js" strategy="afterInteractive" />
      <Script src="/fb-pixel.js" strategy="afterInteractive" />
      <Script src="/hotjar.js" strategy="lazyOnload" />
    </>
  )
}

3. Usa variables de entorno

// ✅ Bien
<Script src={`https://api.com/script.js?key=${process.env.NEXT_PUBLIC_KEY}`} />

// ❌ Mal: API key hardcodeada
<Script src="https://api.com/script.js?key=abc123" />

4. Coloca scripts en layout

// ✅ Bien: En layout (una vez)
// app/layout.tsx
<Script src="/analytics.js" strategy="afterInteractive" />

// ❌ Mal: En cada página (se duplica)
// app/page.tsx
<Script src="/analytics.js" strategy="afterInteractive" />

5. Considera la privacidad

// Solo carga si el usuario acepta
{userAcceptedCookies && (
  <Script src="/tracking.js" strategy="afterInteractive" />
)}

Medir impacto

Con Lighthouse

# Chrome DevTools → Lighthouse
# Métricas importantes:
# - Total Blocking Time (TBT)
# - First Contentful Paint (FCP)
# - Time to Interactive (TTI)

Con Web Vitals

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <SpeedInsights />
      </body>
    </html>
  )
}

Vercel Analytics te muestra el impacto de tus scripts en tiempo real.

Errores comunes

Error: Script duplicado

// ❌ Mal: Script en layout Y en página
// app/layout.tsx
<Script src="/analytics.js" />

// app/page.tsx
<Script src="/analytics.js" />  // Se carga 2 veces

Solución: Scripts globales solo en layout.tsx.

Error: Sin id en inline script

// ❌ Error: Falta id
<Script>
  {`console.log('hola')`}
</Script>

// ✅ Correcto
<Script id="mi-script">
  {`console.log('hola')`}
</Script>

Error: beforeInteractive en página

// ❌ Error: beforeInteractive solo funciona en layout
// app/page.tsx
<Script src="/script.js" strategy="beforeInteractive" />

// ✅ Correcto: En layout
// app/layout.tsx
<Script src="/script.js" strategy="beforeInteractive" />

Scripts de terceros populares

Hotjar

<Script id="hotjar" 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:${process.env.NEXT_PUBLIC_HOTJAR_ID},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>

Mixpanel

<Script id="mixpanel" strategy="afterInteractive">
  {`
    (function(f,b){
      if(!b.__SV){
        window.mixpanel=b;
        var a=f.getElementsByTagName("script")[0];
        var e=f.createElement("script");
        e.async=!0;
        e.src="https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js";
        a.parentNode.insertBefore(e,a);
      }
    })(document,window.mixpanel||[]);
    mixpanel.init("${process.env.NEXT_PUBLIC_MIXPANEL_TOKEN}");
  `}
</Script>

Crisp Chat

<Script id="crisp" strategy="lazyOnload">
  {`
    window.$crisp=[];
    window.CRISP_WEBSITE_ID="${process.env.NEXT_PUBLIC_CRISP_ID}";
    (function(){
      d=document;
      s=d.createElement("script");
      s.src="https://client.crisp.chat/l.js";
      s.async=1;
      d.getElementsByTagName("head")[0].appendChild(s);
    })();
  `}
</Script>

Resumen

Componente Script de NextJS:

  • Controla cuándo se cargan scripts de terceros
  • Tres estrategias: beforeInteractive, afterInteractive, lazyOnload
  • Previene bloqueo del render
  • Eventos del ciclo de vida (onLoad, onError)
  • No duplica scripts en navegación cliente

Estrategias:

  • beforeInteractive: Scripts críticos (raro)
  • afterInteractive: Analytics, ads (común)
  • lazyOnload: Widgets, chats (recomendado para no críticos)

Regla de oro: Usa afterInteractive para analytics, lazyOnload para todo lo demás. Coloca scripts globales en layout.tsx.