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.
Antes de optimizar, es importante entender exactamente que hace AdSense cuando se carga:
adsbygoogle.js): ~100KB que se descarga y ejecuta, bloqueando el hilo principal durante la evaluacion.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.
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.
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.
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';
}
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.
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.
Mas alla del rendimiento, hay reglas de AdSense que impactan directamente tu codigo:
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"
/>
);
}
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.
strategy="lazyOnload" o, mejor aun, diferido hasta la primera interaccion del usuario.min-height en el contenedor de cada anuncio para eliminar CLS.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.