seolab.cl
menú

$ cat javascript-seo-problemas-comunes.md

JavaScript SEO: Soluciones a 15 Problemas Comunes 2026

Resuelve los problemas más comunes de JavaScript SEO: renderizado, CSR vs SSR, hydration, crawling issues con React, Vue, Angular y Next.js en 2026.

SEO técnico · nivel avanzado · 15 min de lectura

El JavaScript moderno ha transformado el desarrollo web, pero sigue siendo el dolor de cabeza número uno para SEOs técnicos. A pesar de que Googlebot ejecuta JavaScript desde 2015 y usa una versión evergreen de Chrome desde 2019, los sitios basados en frameworks como React, Vue, Angular y Next.js enfrentan desafíos únicos de indexación, rendering y performance.

En 2026, el panorama es más complejo que nunca: tenemos Client-Side Rendering (CSR), Server-Side Rendering (SSR), Static Site Generation (SSG), Incremental Static Regeneration (ISR), y híbridos de todo lo anterior. Cada approach tiene implicaciones profundas para SEO, y elegir mal puede significar la diferencia entre un sitio que rankea y uno invisible para Google.

Esta guía cubre los 15 problemas más comunes de JavaScript SEO que veo consistentemente en auditorías de 2026, con soluciones prácticas y ejemplos de código real. Si trabajas con frameworks modernos o tu sitio depende de JavaScript para renderizar contenido, esta guía es esencial.

El Estado de JavaScript SEO en 2026

Primero, aclaremos qué puede y no puede hacer Googlebot en 2026:

✓ Googlebot PUEDE:

  • Ejecutar JavaScript moderno (ES2023+)
  • Renderizar React, Vue, Angular, Svelte, etc.
  • Ejecutar async/await, Promises, fetch API
  • Procesar la mayoría de polyfills y transpilaciones
  • Scrollear páginas (limitado) para lazy loading
  • Ejecutar IntersectionObserver

✗ Googlebot NO PUEDE (o tiene dificultades):

  • Esperar indefinidamente (timeout ~5 segundos)
  • Ejecutar infinite scrolls perfectamente
  • Interactuar con modals que requieren clicks
  • Ejecutar Service Workers de forma confiable
  • Garantizar rendering de contenido que depende de eventos de usuario
  • Procesar WebSockets en tiempo real

Realidad 2026: Aunque Googlebot ejecuta JavaScript, el rendering tiene costos: delay de indexación (render queue), mayor uso de crawl budget, y potencial de errores. Si puedes servir contenido sin JavaScript, hazlo.

Problema #1: Contenido Invisible en el HTML Inicial (CSR Puro)

El Problema

Aplicaciones React/Vue puras con Client-Side Rendering sirven HTML casi vacío:

<!DOCTYPE html>
<html>
<head>
  <title>Mi App</title>
</head>
<body>
  <div id="root"></div>
  <script src="bundle.js"></script>
</body>
</html>

Todo el contenido se genera con JavaScript después de cargar. Googlebot debe esperar rendering, causando:

  • Delay de indexación de días/semanas
  • Riesgo de timeout si rendering tarda >5s
  • Imposibilidad de preview en social media

La Solución: SSR o SSG

Opción A: Server-Side Rendering (Next.js)

// pages/producto/[id].js
export async function getServerSideProps(context) {
  const { id } = context.params;
  const producto = await fetch(`https://api.ejemplo.com/productos/${id}`).then(r => r.json());

  return {
    props: { producto }
  };
}

export default function ProductoPage({ producto }) {
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
      <span>${producto.precio}</span>
    </div>
  );
}

El HTML servido ya contiene el contenido completo.

Opción B: Static Site Generation

// Para contenido que cambia poco
export async function getStaticProps() {
  const posts = await fetch('https://api.ejemplo.com/posts').then(r => r.json());

  return {
    props: { posts },
    revalidate: 3600 // ISR: regenerar cada hora
  };
}

Cuándo Usar Qué

Método Caso de Uso SEO Score
CSR Apps detrás de login, dashboards
SSR Contenido dinámico, personalizado
SSG Blogs, docs, landing pages
ISR Ecommerce, contenido que cambia moderadamente

Problema #2: Hydration Errors Causando Contenido Mismatch

El Problema

El contenido renderizado en servidor no coincide con el client:

// Servidor renderiza:
<div>Última actualización: 2026-04-13 10:30</div>

// Cliente hidrata con:
<div>Última actualización: 2026-04-13 14:45</div>

React/Vue tiran warning, pero peor: Google puede ver una versión y los usuarios otra.

La Solución: useEffect para Contenido Client-Only

import { useState, useEffect } from 'react';

function Timestamp() {
  const [timestamp, setTimestamp] = useState(null);

  useEffect(() => {
    // Solo ejecuta en cliente
    setTimestamp(new Date().toLocaleString());
  }, []);

  // Durante SSR, renderiza placeholder
  if (!timestamp) {
    return <div>Cargando...</div>;
  }

  return <div>Última actualización: {timestamp}</div>;
}

O mejor aún, usa suppressHydrationWarning:

<time suppressHydrationWarning>
  {new Date().toLocaleString()}
</time>

Problema #3: Soft Navigation y URL Changes Sin Actualizar HTML

El Problema

SPAs usan History API para cambiar URLs sin recargar:

// URL cambia de /productos a /productos/zapatillas
history.pushState({}, '', '/productos/zapatillas');

Pero el <title>, meta tags y canonical no se actualizan, causando:

  • Títulos incorrectos en SERPs
  • Meta descriptions que no coinciden
  • Canonical tags apuntando a la página anterior

La Solución: Actualizar Metadata en Cada Navegación

Con React Helmet (React tradicional):

import { Helmet } from 'react-helmet';

function ProductoPage({ producto }) {
  return (
    <>
      <Helmet>
        <title>{producto.nombre} - Mi Tienda</title>
        <meta name="description" content={producto.descripcionCorta} />
        <link rel="canonical" href={`https://ejemplo.com/productos/${producto.id}`} />
        <meta property="og:title" content={producto.nombre} />
        <meta property="og:image" content={producto.imagen} />
      </Helmet>

      <div>
        <h1>{producto.nombre}</h1>
        {/* contenido */}
      </div>
    </>
  );
}

Con Next.js Head:

import Head from 'next/head';

export default function ProductoPage({ producto }) {
  return (
    <>
      <Head>
        <title>{producto.nombre} - Mi Tienda</title>
        <meta name="description" content={producto.descripcionCorta} />
        <link rel="canonical" href={`https://ejemplo.com/productos/${producto.id}`} />
      </Head>

      <h1>{producto.nombre}</h1>
    </>
  );
}

El Problema

Botones o divs con onClick en lugar de <a> tags:

// ❌ Googlebot no puede seguir esto
<div onClick={() => navigate('/productos')}>
  Ver Productos
</div>

<button onClick={() => window.location = '/contacto'}>
  Contactar
</button>

Googlebot no ejecuta clicks. No descubrirá esas páginas.

La Solución: Siempre Usa Tags

// ✅ Googlebot puede rastrear
import Link from 'next/link'; // o react-router Link

<Link href="/productos">
  <a>Ver Productos</a>
</Link>

// ✅ O <a> nativo
<a href="/contacto">Contactar</a>

Si necesitas JavaScript para funcionalidad extra:

<a
  href="/productos"
  onClick={(e) => {
    // Tu lógica custom
    trackEvent('click', 'productos');
    // Deja que el link funcione normalmente
  }}
>
  Ver Productos
</a>

Problema #5: Infinite Scroll Sin Paginación

El Problema

Contenido que solo carga con scroll infinito:

window.addEventListener('scroll', () => {
  if (window.scrollY > threshold) {
    loadMoreProducts();
  }
});

Googlebot tiene capacidad limitada de scroll. Mucho contenido nunca será indexado.

La Solución: Hybrid Approach con Paginación

// URL structure:
// /productos (página 1)
// /productos?page=2
// /productos?page=3

function ProductosPage({ productos, page, totalPages }) {
  const [items, setItems] = useState(productos);
  const [currentPage, setCurrentPage] = useState(page);

  // Infinite scroll para UX
  const loadMore = async () => {
    const nextPage = currentPage + 1;
    const newItems = await fetch(`/api/productos?page=${nextPage}`).then(r => r.json());
    setItems([...items, ...newItems]);
    setCurrentPage(nextPage);

    // Actualiza URL sin recargar
    history.pushState({}, '', `/productos?page=${nextPage}`);
  };

  return (
    <div>
      {items.map(item => <ProductCard key={item.id} {...item} />)}

      {/* Infinite scroll para usuarios */}
      <IntersectionObserver onIntersect={loadMore} />

      {/* Paginación tradicional para bots */}
      <nav aria-label="Paginación de productos">
        {currentPage > 1 && (
          <a href={`/productos?page=${currentPage - 1}`} rel="prev">
            Anterior
          </a>
        )}

        {currentPage < totalPages && (
          <a href={`/productos?page=${currentPage + 1}`} rel="next">
            Siguiente
          </a>
        )}
      </nav>
    </div>
  );
}

Implementa rel="prev" y rel="next" para que Google entienda la serie.

Problema #6: Contenido Detrás de Tabs/Accordions No Indexado

El Problema

Contenido en tabs o accordions que usa display: none:

<div className={activeTab !== 'specs' ? 'hidden' : ''}>
  {/* Especificaciones técnicas importantes */}
</div>

Si el CSS usa display: none, Google históricamente lo ignoraba. En 2026 es más tolerante, pero hay riesgo.

La Solución: Usa aria-hidden en lugar de display: none

/* En lugar de display: none */
.tab-content[aria-hidden="true"] {
  position: absolute;
  left: -10000px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

O mejor: renderiza todo, oculta visualmente:

function ProductTabs({ specs, reviews }) {
  const [activeTab, setActiveTab] = useState('specs');

  return (
    <div>
      {/* Tabs navigation */}
      <div role="tablist">
        <button onClick={() => setActiveTab('specs')} aria-selected={activeTab === 'specs'}>
          Especificaciones
        </button>
        <button onClick={() => setActiveTab('reviews')} aria-selected={activeTab === 'reviews'}>
          Reviews
        </button>
      </div>

      {/* Todo el contenido está en el DOM */}
      <div role="tabpanel" className={activeTab !== 'specs' ? 'visually-hidden' : ''}>
        <h2>Especificaciones</h2>
        {specs}
      </div>

      <div role="tabpanel" className={activeTab !== 'reviews' ? 'visually-hidden' : ''}>
        <h2>Reviews</h2>
        {reviews}
      </div>
    </div>
  );
}

Problema #7: Fetch Calls Fallando en Server-Side Rendering

El Problema

Código que funciona en cliente pero falla en servidor:

// ❌ window no existe en servidor
const apiUrl = window.location.origin + '/api/productos';

// ❌ localStorage no existe en servidor
const userId = localStorage.getItem('userId');

La Solución: Detectar Environment

// Utility helper
export const isServer = typeof window === 'undefined';
export const isClient = !isServer;

// En tu componente/página
function ProductoPage() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // Solo ejecuta en cliente
    if (isClient) {
      const userId = localStorage.getItem('userId');
      fetchUser(userId).then(setUser);
    }
  }, []);

  // SSR renderiza sin user data
  // CSR hidrata y carga user data
}

// Para fetch durante SSR
export async function getServerSideProps(context) {
  // Usa absolute URL
  const protocol = context.req.headers['x-forwarded-proto'] || 'http';
  const host = context.req.headers.host;
  const apiUrl = `${protocol}://${host}/api/productos`;

  const productos = await fetch(apiUrl).then(r => r.json());

  return { props: { productos } };
}

Problema #8: Canonical Tags Dinámicos Incorrectos

El Problema

URLs con parámetros que comparten contenido:

/productos?sort=precio
/productos?sort=popularidad
/productos?filter=nike

Cada una genera canonical diferente, causando contenido duplicado.

La Solución: Canonical Consistente

import { useRouter } from 'next/router';
import Head from 'next/head';

function ProductosPage() {
  const router = useRouter();

  // Canonical siempre apunta a URL base sin parámetros
  const canonicalUrl = `https://ejemplo.com${router.pathname}`;

  return (
    <>
      <Head>
        <link rel="canonical" href={canonicalUrl} />
      </Head>

      {/* contenido */}
    </>
  );
}

O si los parámetros SÍ crean páginas únicas:

const { sort, filter } = router.query;

// Construye canonical incluyendo parámetros relevantes
const params = new URLSearchParams();
if (filter) params.set('filter', filter);

const canonicalUrl = `https://ejemplo.com/productos${params.toString() ? '?' + params.toString() : ''}`;

Problema #9: Redirects Client-Side No Reconocidos

El Problema

Redirects con JavaScript puro:

// ❌ Googlebot puede no seguir esto
useEffect(() => {
  if (!user) {
    window.location = '/login';
  }
}, [user]);

La Solución: Redirects Server-Side cuando sea posible

Next.js getServerSideProps:

export async function getServerSideProps(context) {
  const { req } = context;
  const user = await getUserFromCookie(req);

  if (!user) {
    return {
      redirect: {
        destination: '/login',
        permanent: false // 302
      }
    };
  }

  return { props: { user } };
}

Next.js Middleware (recommended en 2026):

// middleware.js
import { NextResponse } from 'next/server';

export function middleware(request) {
  const user = request.cookies.get('user');

  if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

Problema #10: Structured Data Generado con JavaScript No Validado

El Problema

Schema JSON-LD inyectado con JavaScript tiene errores:

// Fecha mal formateada, rating fuera de rango, etc.
const schema = {
  "@type": "Product",
  "aggregateRating": {
    "ratingValue": "6", // ❌ Fuera de rango 1-5
  },
  "offers": {
    "priceValidUntil": "2026/04/30" // ❌ Formato incorrecto
  }
};

La Solución: Validación + Pruebas Automatizado

// schemas/product.js
export function generateProductSchema(producto) {
  // Validación de datos
  const rating = Math.max(0, Math.min(5, producto.rating)); // Clamp 0-5

  const schema = {
    "@context": "https://schema.org",
    "@type": "Product",
    "name": producto.nombre,
    "image": producto.imagenes,
    "description": producto.descripcion,
    "sku": producto.sku,
    "offers": {
      "@type": "Offer",
      "price": producto.precio.toFixed(2),
      "priceCurrency": "CLP",
      "priceValidUntil": producto.precioValidoHasta.toISOString().split('T')[0], // YYYY-MM-DD
      "availability": producto.enStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock"
    }
  };

  // Solo añade rating si hay reviews
  if (producto.numeroReviews > 0) {
    schema.aggregateRating = {
      "@type": "AggregateRating",
      "ratingValue": rating.toString(),
      "reviewCount": producto.numeroReviews.toString()
    };
  }

  return schema;
}

// En componente
function ProductoPage({ producto }) {
  const schema = generateProductSchema(producto);

  return (
    <>
      <Head>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
        />
      </Head>
      {/* contenido */}
    </>
  );
}

Valida con Google Rich Results Test en staging antes de deploy.

Problema #11: Estado de Loading Sin Contenido Para Googlebot

El Problema

Mostrar solo spinner mientras carga:

function ProductoPage() {
  const [producto, setProducto] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchProducto().then(data => {
      setProducto(data);
      setLoading(false);
    });
  }, []);

  if (loading) {
    return <Spinner />; // ❌ Googlebot puede ver solo esto
  }

  return <div>{producto.nombre}</div>;
}

Si el fetch tarda >5s, Googlebot ve solo spinner.

La Solución: SSR/SSG con Datos Pre-Loaded

// Next.js
export async function getServerSideProps({ params }) {
  const producto = await fetchProducto(params.id);

  return {
    props: { producto }
  };
}

function ProductoPage({ producto }) {
  // Producto ya viene del servidor, no hay loading state
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
    </div>
  );
}

Problema #12: Errores 404 Manejados Solo con JavaScript

El Problema

Página de error que retorna 200 status code:

// ❌ Retorna HTTP 200
function ProductoPage({ producto }) {
  if (!producto) {
    return <div>Producto no encontrado</div>;
  }

  return <div>{producto.nombre}</div>;
}

Google indexa como página válida con contenido "Producto no encontrado".

La Solución: Status Code Correcto desde Servidor

// Next.js
export async function getServerSideProps({ params, res }) {
  const producto = await fetchProducto(params.id);

  if (!producto) {
    res.statusCode = 404;
    return {
      props: { error: '404' }
    };
  }

  return { props: { producto } };
}

function ProductoPage({ producto, error }) {
  if (error === '404') {
    return <Error404 />; // Componente custom de 404
  }

  return <div>{producto.nombre}</div>;
}

Para CSR puro (no recomendado pero si no hay opción):

useEffect(() => {
  if (!producto) {
    // Al menos indica a crawlers
    document.querySelector('meta[name="robots"]')?.setAttribute('content', 'noindex');
  }
}, [producto]);

Problema #13: JavaScript Bundle Demasiado Grande

El Problema

Bundle de 2MB+ bloquea rendering:

// 500KB de dependencies innecesarias
import _ from 'lodash'; // Toda la librería cuando solo usas 2 funciones
import moment from 'moment'; // 300KB cuando solo necesitas formatear fechas

Googlebot tiene timeout. Si rendering tarda >5s, puede indexar parcialmente.

La Solución: Code Splitting + Tree Shaking

Importaciones específicas:

// ❌ Importa toda lodash (500KB)
import _ from 'lodash';
const uniq = _.uniq;

// ✅ Importa solo función necesaria (5KB)
import uniq from 'lodash/uniq';

Dynamic imports para rutas:

// Next.js automático por página
// Cada página es bundle separado

// Para componentes grandes
const Chart = dynamic(() => import('../components/Chart'), {
  loading: () => <p>Cargando gráfico...</p>,
  ssr: false // No renderizar en servidor si es muy pesado
});

Análisis de bundle:

# Next.js
npm run build -- --analyze

# React (con webpack-bundle-analyzer)
npm run build
npx webpack-bundle-analyzer build/bundle-stats.json

Objetivo: FCP <1.8s, LCP <2.5s incluso con JavaScript.

Problema #14: Fetch con Relative URLs Fallando en SSR

El Problema

// ❌ Falla en server (no hay window.location)
const data = await fetch('/api/productos');

La Solución: Absolute URLs en Server

// utils/fetch.js
export async function apiFetch(endpoint) {
  const baseUrl = typeof window === 'undefined'
    ? process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
    : '';

  const url = `${baseUrl}${endpoint}`;
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

// Uso
const productos = await apiFetch('/api/productos');

Problema #15: Meta Tags Sociales (Open Graph) No Dinámicos

El Problema

Open Graph estático en index.html:

<!-- ❌ Mismo OG para todas las páginas -->
<meta property="og:title" content="Mi Sitio">
<meta property="og:image" content="/logo.png">

Shares en redes sociales muestran info genérica.

La Solución: OG Dinámico Por Página

// Next.js con next-seo
import { NextSeo } from 'next-seo';

function ProductoPage({ producto }) {
  const seoConfig = {
    title: producto.nombre,
    description: producto.descripcionCorta,
    canonical: `https://ejemplo.com/productos/${producto.slug}`,
    openGraph: {
      type: 'product',
      url: `https://ejemplo.com/productos/${producto.slug}`,
      title: producto.nombre,
      description: producto.descripcionCorta,
      images: [
        {
          url: producto.imagenPrincipal,
          width: 1200,
          height: 630,
          alt: producto.nombre
        }
      ],
      site_name: 'Mi Tienda'
    },
    twitter: {
      handle: '@mitienda',
      cardType: 'summary_large_image'
    }
  };

  return (
    <>
      <NextSeo {...seoConfig} />
      <div>{/* contenido */}</div>
    </>
  );
}

Qué esperar de cada framework

Next.js (Recomendado para SEO en 2026)

Viene bien resuelto de fábrica: renderizado en el servidor sin configurar nada, división automática del código, optimización de imágenes, componente propio para las etiquetas del <head>, middleware para redirecciones y regeneración incremental de páginas estáticas.

Qué conviene hacer:

  • renderizado en servidor para lo que cambia en cada visita, y estático con regeneración incremental para lo que cambia de vez en cuando;
  • next/image para las imágenes;
  • un generador de sitemap que se actualice solo con el build.

React SPA (Create React App, Vite)

Aquí hay trabajo extra, y conviene saberlo antes de empezar el proyecto: por defecto todo se renderiza en el navegador, hay que montar el prerenderizado o el renderizado en servidor por tu cuenta, y las etiquetas del <head> dinámicas necesitan una librería aparte.

Qué se puede hacer:

  • un servicio de prerenderizado delante de la aplicación;
  • una librería para gestionar el <head>;
  • y si el SEO es un requisito y no un extra, plantearse un framework que traiga renderizado en servidor de serie en vez de añadirlo después. Sale más barato al principio del proyecto que a mitad.

Vue.js (Nuxt)

Nuxt es para Vue lo que Next.js es para React: renderizado en servidor nativo, rutas automáticas a partir de los archivos y una forma propia de gestionar las etiquetas del <head>.

Qué conviene hacer: cargar los datos en el momento del prerenderizado, generar el sitemap con el build y usar el componente de imágenes del propio framework.

Angular Universal

Tiene renderizado en servidor, pero montarlo cuesta más que en Next o Nuxt.

Dos cosas que ahorran problemas: pasar al cliente los datos que ya trajo el servidor, para que no los pida dos veces, y usar los servicios propios del framework para el título y las etiquetas del <head> en lugar de manipular el DOM a mano.

Cómo comprobar que Google ve tu sitio

Herramientas imprescindibles

  1. Google URL Inspection Tool

    • Valida rendering real de Googlebot
    • Ver HTML renderizado
    • Screenshot de cómo ve Google
  2. Chrome DevTools

    • Disable JavaScript para ver content sin JS
    • Network tab para ver fetch timing
    • Coverage para bundle analysis
  3. Screaming Frog (Rendering mode)

    • Crawl con JavaScript rendering habilitado
    • Compara contenido renderizado vs no-renderizado
  4. Lighthouse

    • Performance metrics (LCP, FCP, TBT)
    • SEO audit específico

Pruebas Protocol

# 1. Build production
npm run build

# 2. Ejecuta Lighthouse
lighthouse https://tu-sitio.com --view

# 3. Verifica rendering en URL Inspection (Search Console)

# 4. Compara HTML fuente vs renderizado
curl https://tu-sitio.com > source.html
# Usa URL Inspection "View Crawled Page" > HTML para ver versión renderizada

# 5. Valida structured data
# Google Rich Results Test con cada tipo de página

Conclusión

JavaScript SEO en 2026 es complejo pero manejable si sigues los principios fundamentales: servir contenido en el HTML inicial cuando sea posible (SSR/SSG), asegurar que links sean rastreables, actualizar metadata en navegaciones, y mantener bundles optimizados.

Los 15 problemas cubiertos en esta guía representan el 90% de issues que veo en auditorías reales. La mayoría son evitables usando frameworks modernos como Next.js o Nuxt con configuración adecuada, y siguiendo las mejores prácticas de cada framework.

El futuro apunta a más pre-rendering y menos dependencia de client-side JavaScript para contenido crítico. Google prefiere contenido en HTML estático, y aunque puede ejecutar JavaScript, siempre habrá un costo en tiempo de indexación y crawl budget.

Próximo paso: Audita tu sitio con Google URL Inspection Tool. Compara el HTML fuente con el renderizado. Si hay diferencias significativas, tienes oportunidades de optimización. Prioriza SSR/SSG para páginas de landing importantes y asegura que todo el contenido crítico esté en el HTML inicial.

FUENTES

javascript-seorenderingssrcsrframework-seo
WhatsAppCorreo