Keting Media
Volver al blog
Desarrollo Web, Software
5 min lectura

Un useEffect dejó nuestro blog invisible
para Google y las IA

C

Autor

Carlos Beuvrin

Publicado

jul 2026

Un useEffect dejó nuestro blog invisible para Google y las IA

Durante meses, el listado de nuestro blog servía cero enlaces de artículo en el HTML inicial. Ni uno. Los artículos existían, se veían perfectos en el navegador, y para cualquier rastreador que no ejecutara JavaScript simplemente no estaban ahí.

Lo descubrimos por casualidad. Y el error es tan común que vale la pena contarlo con el código delante.

El síntoma

La prueba es de diez segundos:

curl -s https://tu-sitio.com/blog | grep -c 'href="/blog/'

Si eso devuelve 0 y tu blog tiene cuarenta artículos, tienes el problema. En nuestro caso devolvía exactamente eso: cero.

La versión sin terminal: abre las herramientas de desarrollo, desactiva JavaScript y recarga. Lo que veas es aproximadamente lo que ve un rastreador.

Por qué pasaba

El page.tsx del listado empezaba así:

"use client";

export default function BlogPage() {
    const [mounted, setMounted] = useState(false);
    const [dbArticles, setDbArticles] = useState([]);

    useEffect(() => {
        const load = async () => {
            const { data } = await supabase.from('articles').select('*');
            setDbArticles(data);
        };
        load();
        setMounted(true);
    }, []);

    // ...

    if (!mounted) return null;   // ← aquí muere todo

Tres decisiones que por separado parecen razonables y juntas son letales:

  • "use client" en la página entera. Toda la ruta pasa a ser cliente.
  • Los datos se piden en un useEffect. Que solo corre en el navegador, nunca en el servidor.
  • if (!mounted) return null. El guardia clásico contra errores de hidratación. En el servidor mounted es false, así que el HTML que se genera está literalmente vacío.

El resultado: el servidor devuelve un cascarón. El navegador lo rellena en milisegundos y para ti todo se ve bien. Para un rastreador sin JavaScript, tu blog no tiene contenido.

Con las IA duele más que con Google

Googlebot ejecuta JavaScript. Tarde, con presupuesto limitado y a regañadientes, pero lo ejecuta. Así que un sitio así acaba indexándose, con retraso.

Los rastreadores de los modelos de lenguaje no. GPTBot, ClaudeBot y PerplexityBot no ejecutan JavaScript — está medido sobre tráfico real de red por Vercel en The Rise of the AI Crawler. Lo que no está en el HTML no existe para ellos. No hay segunda pasada.

Y aquí está lo perverso: el problema es invisible desde dentro. Tu sitio funciona, se ve bien, pasa las revisiones de diseño. Nadie abre una terminal para mirar el HTML crudo.

El arreglo

Separar la obtención de datos de la interactividad. La página pasa a ser un componente de servidor que trae los datos, y todo lo que necesita estado se va a un componente de cliente hijo:

// app/blog/page.tsx — SERVIDOR (sin "use client")
import { BlogClient } from "./BlogClient";

export const revalidate = 3600;

export default async function BlogPage() {
    const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
    const { data } = await supabase
        .from('articles')
        .select('slug, title, excerpt, category, image, author, date');

    return <BlogClient articles={data ?? []} />;
}
// app/blog/BlogClient.tsx — CLIENTE
"use client";

export function BlogClient({ articles }) {
    const [activeCategory, setActiveCategory] = useState("Todos");
    // filtros, animaciones, todo lo interactivo
}

Los filtros, las animaciones y el estado siguen funcionando igual. La diferencia es que ahora los títulos y los enlaces viajan en el HTML inicial.

Resultado medido: de 0 a 28 enlaces de artículo en el HTML el día del arreglo. Hoy son 26, porque borramos duplicados por el camino.

Un detalle que importa: en el select pedimos solo las columnas que usan las tarjetas. Traer la columna content metía el HTML completo de cada artículo en el payload — unos 112 KB de más que nadie leía.

Las otras dos trampas del mismo tipo

Si vas a auditar tu propio sitio, estas dos son de la misma familia y me han mordido después.

ssr: false es un agujero invisible. Un componente cargado con dynamic(() => import('./X'), { ssr: false }) nunca aparece en el HTML estático. Si verificas la traducción o el contenido de tu sitio con grep sobre el HTML generado, esos componentes te van a dar por buenos sin haberlos mirado jamás. Nos pasó: dos secciones del home seguían en español dentro de la versión inglesa y el grep decía que todo estaba limpio, porque nunca estuvieron en el archivo.

Para verificar esos hay que medir el DOM ya hidratado, con un navegador de verdad:

chrome --headless=new --dump-dom https://tu-sitio.com/en > dom.html

El payload RSC arrastra lo que le pases. Si un componente "use client" recibe por props un objeto con los dos idiomas, Next serializa ambos dentro del HTML para poder hidratar. Resultado: tu página en inglés lleva toda la prosa en español escondida en un <script>. No se ve, pero los rastreadores leen el HTML crudo. La solución es pasar solo el idioma que se renderiza, no el objeto completo.

Lo que esto no arregla

Ser rastreable es la condición de entrada, no la ventaja. Que tu contenido esté en el HTML no significa que una IA vaya a recomendarte: eso depende de cosas que viven fuera de tu web y que, además, nadie puede garantizarte — lo escribí con las cifras y las fuentes en este artículo.

Pero al revés sí es absoluto: si no estás en el HTML, no hay nada que optimizar. Es el primer sitio donde mirar, cuesta diez segundos comprobarlo, y una cantidad sorprendente de sitios bien construidos lo suspenden.

Prueba el curl de arriba en tu sitio. Serán los diez segundos peor invertidos de tu semana, o los mejores.