← Volver al blog
·9 min de lectura

Integrar Google AdSense en sitios Next.js sin afectar el rendimiento

Next.jsMonetizacion

Monetizar un sitio con Google AdSense es tentador, pero tiene un costo real en rendimiento. El script de AdSense pesa alrededor de 100KB, carga scripts adicionales de forma dinamica, inserta iframes, y puede provocar layout shifts visibles. En un sitio Next.js optimizado que obtiene 95+ en Lighthouse, agregar AdSense de forma ingenua puede dejarte en 60. Este articulo cubre estrategias concretas para integrar AdSense minimizando ese impacto.

Como AdSense afecta el rendimiento

Antes de optimizar, es importante entender exactamente que hace AdSense cuando se carga:

  1. Script principal (adsbygoogle.js): ~100KB que se descarga y ejecuta, bloqueando el hilo principal durante la evaluacion.
  2. Scripts secundarios: el script principal carga scripts adicionales para targeting, bidding y rendering de anuncios.
  3. Iframes: cada slot de anuncio crea uno o mas iframes con contenido propio.
  4. Layout shifts: si el espacio del anuncio no esta reservado, el contenido de la pagina se mueve cuando el anuncio aparece, afectando directamente el CLS (Cumulative Layout Shift).
  5. Conexiones de red: se establecen conexiones a multiples dominios de Google (pagead2.googlesyndication.com, googleads.g.doubleclick.net, etc.).

El impacto se refleja principalmente en tres Core Web Vitals: LCP (Largest Contentful Paint) por el bloqueo del hilo principal, CLS por los layout shifts, y INP (Interaction to Next Paint) por la competencia de recursos durante la carga.

Cargar el script de AdSense correctamente

La forma mas basica de cargar AdSense en Next.js es usando el componente next/script:

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

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body>
        {children}
        <Script
          async
          src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
          crossOrigin="anonymous"
          strategy="lazyOnload"
        />
      </body>
    </html>
  );
}

La clave aqui es strategy="lazyOnload". Next.js ofrece tres estrategias de carga para scripts externos:

  • beforeInteractive: carga antes de la hidratacion. Nunca uses esto para AdSense.
  • afterInteractive (default): carga inmediatamente despues de la hidratacion. Mejor, pero sigue compitiendo con tu contenido.
  • lazyOnload: carga despues de que todo el contenido de la pagina se haya cargado, durante el tiempo idle del navegador. Esta es la opcion correcta para AdSense.

Para ir un paso mas alla, puedes retrasar la carga hasta que el usuario interactue con la pagina:

'use client';

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

export function AdSenseLoader() {
  const [shouldLoad, setShouldLoad] = useState(false);

  useEffect(() => {
    const events = ['scroll', 'mousemove', 'touchstart', 'keydown'];
    const trigger = () => {
      setShouldLoad(true);
      events.forEach(e => window.removeEventListener(e, trigger));
    };

    // Cargar despues de 5 segundos o al primer input del usuario
    const timer = setTimeout(trigger, 5000);
    events.forEach(e => window.addEventListener(e, trigger, { once: true }));

    return () => {
      clearTimeout(timer);
      events.forEach(e => window.removeEventListener(e, trigger));
    };
  }, []);

  if (!shouldLoad) return null;

  return (
    <Script
      async
      src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
      crossOrigin="anonymous"
      strategy="lazyOnload"
    />
  );
}

Esto asegura que Lighthouse mida la pagina sin la penalizacion de AdSense, ya que el bot no genera interacciones de usuario.

Componente AdSlot reutilizable

Crea un componente para cada slot de anuncio que maneje la reserva de espacio y la inicializacion:

'use client';

import { useEffect, useRef, useState } from 'react';

interface AdSlotProps {
  adSlot: string;
  adFormat?: 'auto' | 'rectangle' | 'horizontal' | 'vertical';
  fullWidth?: boolean;
  style?: React.CSSProperties;
}

declare global {
  interface Window {
    adsbygoogle: Array<Record<string, unknown>>;
  }
}

export function AdSlot({
  adSlot,
  adFormat = 'auto',
  fullWidth = true,
  style,
}: AdSlotProps) {
  const adRef = useRef<HTMLModElement>(null);
  const [isFilled, setIsFilled] = useState(false);
  const initialized = useRef(false);

  useEffect(() => {
    if (initialized.current) return;
    initialized.current = true;

    try {
      (window.adsbygoogle = window.adsbygoogle || []).push({});
    } catch (e) {
      console.error('AdSense push error:', e);
    }
  }, []);

  useEffect(() => {
    if (!adRef.current) return;

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === 'attributes' || mutation.type === 'childList') {
          const ad = adRef.current;
          if (!ad) return;

          const hasContent = ad.querySelector('iframe') !== null;
          const isUnfilled = ad.getAttribute('data-ad-status') === 'unfilled';

          if (hasContent && !isUnfilled) {
            setIsFilled(true);
          }
        }
      }
    });

    observer.observe(adRef.current, {
      attributes: true,
      childList: true,
      subtree: true,
    });

    return () => observer.disconnect();
  }, []);

  if (process.env.NODE_ENV === 'development') {
    return (
      <div
        style={{
          minHeight: 250,
          background: 'repeating-linear-gradient(45deg, #f0f0f0, #f0f0f0 10px, #e0e0e0 10px, #e0e0e0 20px)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          border: '2px dashed #999',
          color: '#666',
          fontSize: 14,
          ...style,
        }}
      >
        Ad Slot: {adSlot}
      </div>
    );
  }

  return (
    <div
      style={{
        minHeight: isFilled ? undefined : 250,
        overflow: 'hidden',
        ...style,
      }}
    >
      <ins
        ref={adRef}
        className="adsbygoogle"
        style={{ display: 'block' }}
        data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
        data-ad-slot={adSlot}
        data-ad-format={adFormat}
        data-full-width-responsive={fullWidth ? 'true' : 'false'}
      />
    </div>
  );
}

Este componente resuelve varios problemas a la vez. Veamos cada uno.

Prevenir Cumulative Layout Shift (CLS)

El CLS es el problema mas visible de AdSense. Cuando un anuncio se carga, ocupa espacio que antes no existia, empujando el contenido visible hacia abajo. La solucion es reservar el espacio con min-height antes de que el anuncio cargue:

<div style={{ minHeight: 250, overflow: 'hidden' }}>
  <ins className="adsbygoogle" ... />
</div>

El min-height: 250px reserva espacio para un anuncio rectangular estandar (300x250). Cuando el anuncio carga, ocupa ese espacio sin mover nada. El overflow: hidden previene que anuncios mas grandes que el contenedor causen scroll horizontal.

Para anuncios que no se llenan (cuando AdSense no tiene un anuncio para mostrar), puedes ocultar el espacio reservado usando el MutationObserver que vimos en el componente. Cuando data-ad-status es "unfilled", puedes colapsar el contenedor:

const isUnfilled = ad.getAttribute('data-ad-status') === 'unfilled';
if (isUnfilled) {
  ad.parentElement.style.minHeight = '0';
  ad.parentElement.style.height = '0';
}

Lazy loading de anuncios below the fold

Los anuncios que estan debajo del viewport inicial no necesitan cargarse inmediatamente. Usa Intersection Observer para cargarlos solo cuando el usuario se acerca:

'use client';

import { useEffect, useRef, useState } from 'react';
import { AdSlot } from './AdSlot';

interface LazyAdProps {
  adSlot: string;
  rootMargin?: string;
}

export function LazyAd({ adSlot, rootMargin = '200px' }: LazyAdProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (!containerRef.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin }
    );

    observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, [rootMargin]);

  return (
    <div ref={containerRef} style={{ minHeight: 250 }}>
      {isVisible && <AdSlot adSlot={adSlot} />}
    </div>
  );
}

El rootMargin: '200px' hace que el anuncio empiece a cargar 200px antes de entrar al viewport, dandole tiempo a AdSense de rellenar el slot antes de que el usuario llegue.

Modo desarrollo vs produccion

En desarrollo, AdSense no sirve anuncios reales y suele generar errores en la consola. El componente AdSlot ya maneja esto renderizando un placeholder visual en desarrollo:

if (process.env.NODE_ENV === 'development') {
  return (
    <div style={{
      minHeight: 250,
      background: 'repeating-linear-gradient(...)',
      // ...
    }}>
      Ad Slot: {adSlot}
    </div>
  );
}

Esto te permite disenar y maquetar alrededor de los anuncios sin depender de que AdSense funcione en local.

Politicas de AdSense que afectan la implementacion

Mas alla del rendimiento, hay reglas de AdSense que impactan directamente tu codigo:

  • Densidad de anuncios: Google puede rechazar tu sitio si hay mas anuncios que contenido. No pongas mas de 3 bloques de anuncios en una pagina corta.
  • Contenido suficiente: cada pagina que muestra anuncios debe tener contenido sustancial. Las paginas "thin content" pueden causar la suspension de tu cuenta.
  • No forzar clics: nunca pongas anuncios donde el usuario pueda hacer clic accidentalmente (cerca de botones, en menus desplegables).
  • Anuncios identificables: los anuncios deben ser distinguibles del contenido. No disfraces anuncios como parte de tu sitio.

Si tu sitio tiene trafico desde la Union Europea, necesitas integracion con una plataforma de consent (CMP). Google ofrece su propia solucion con Google Funding Choices, o puedes usar un CMP certificado por el TCF v2.0.

La implementacion basica consiste en no cargar el script de AdSense hasta tener consentimiento:

export function AdSenseLoader() {
  const [consent, setConsent] = useState(false);

  useEffect(() => {
    // Verificar si el usuario ya dio consentimiento
    const stored = localStorage.getItem('ad-consent');
    if (stored === 'granted') {
      setConsent(true);
    }

    // Escuchar evento del banner de cookies
    window.addEventListener('consent-granted', () => {
      setConsent(true);
      localStorage.setItem('ad-consent', 'granted');
    });
  }, []);

  if (!consent) return null;

  return (
    <Script
      async
      src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
      crossOrigin="anonymous"
      strategy="lazyOnload"
    />
  );
}

Medir el impacto real

Antes y despues de implementar AdSense, mide con Lighthouse en modo produccion. Los numeros tipicos que veo en proyectos reales son:

| Metrica | Sin AdSense | Con AdSense (sin optimizar) | Con AdSense (optimizado) | |---------|-------------|---------------------------|-------------------------| | LCP | 1.2s | 2.8s | 1.5s | | CLS | 0.01 | 0.35 | 0.02 | | INP | 80ms | 200ms | 95ms |

La diferencia entre una integracion sin optimizar y una optimizada es dramatica, especialmente en CLS.

Para obtener datos reales de tus usuarios, usa la API de Web Vitals:

import { onCLS, onLCP, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  // Enviar a tu servicio de analytics
  console.log(metric.name, metric.value);
}

onCLS(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics);

Lighthouse mide en condiciones de laboratorio. Los datos de campo (Real User Monitoring) te daran la imagen completa de como AdSense afecta a tus usuarios reales, con sus dispositivos y conexiones variadas.

Recapitulando las optimizaciones clave

  1. Carga el script con strategy="lazyOnload" o, mejor aun, diferido hasta la primera interaccion del usuario.
  2. Reserva espacio con min-height en el contenedor de cada anuncio para eliminar CLS.
  3. Oculta slots no llenados con MutationObserver para evitar espacios vacios.
  4. Usa Intersection Observer para cargar anuncios below the fold solo cuando el usuario se acerca.
  5. Renderiza placeholders en desarrollo para poder disenar sin depender de AdSense.
  6. Mide antes y despues con Lighthouse y Web Vitals para confirmar que tus optimizaciones funcionan.

AdSense y rendimiento no son mutuamente excluyentes. Con estas tecnicas, puedes monetizar tu sitio Next.js manteniendo scores de Lighthouse por encima de 90 y una experiencia de usuario fluida.