1. Introducción contextual
En la última década, la velocidad de carga y la estructuración semántica de los sitios web se han convertido en factores críticos para el posicionamiento en buscadores. Google afirma que 70 % de los usuarios abandonan una página que tarda más de 5 s en cargar y que Core Web Vitals forman parte del algoritmo de ranking desde 2021. En el ecosistema JavaScript, Next.js se ha consolidado como la solución de referencia para aplicaciones React con renderizado híbrido (SSR, SSG, ISR).
El App Router, introducido en Next.js 13, reemplaza al antiguo pages/ y permite una arquitectura basada en componentes de servidor y cliente, rutas anidadas y layouts compartidos. Esta flexibilidad abre nuevas posibilidades para el SEO técnico, pero también implica decisiones de diseño que deben alinearse con los requisitos de indexación, velocidad y accesibilidad.
A nivel global, el tráfico orgánico representa ≈ 53 % de todas las visitas a sitios web, y la diferencia de 0,5 % en la posición media de resultados puede traducirse en decenas de miles de visitas adicionales para un e‑commerce promedio. Por eso, resolver los retos de SEO técnico en Next.js (App Router) es crítico hoy: la competencia se vuelve cada vez más feroz y los motores de búsqueda penalizan los sitios que no cumplen con los estándares de rendimiento y estructura de datos.
En este artículo desglosaremos los fundamentos, la arquitectura recomendada y la implementación paso a paso, con ejemplos de código listos para producción, casos de uso reales y una comparativa de estrategias de renderizado. Al final tendrás un plan de acción concreto para llevar tu aplicación Next.js al siguiente nivel de visibilidad.
2. Fundamentos y arquitectura
2.1 Conceptos clave
| Concepto | Qué es | Por qué importa para SEO |
|---|---|---|
| App Router | Sistema de rutas basado en el directorio app/ con componentes de servidor (.server.tsx) y cliente (.tsx). | Permite generar HTML en el servidor por ruta, lo que garantiza que los crawlers reciban contenido renderizado. |
| Server‑Components (SC) | Componentes que se ejecutan exclusivamente en el servidor y nunca llegan al bundle del cliente. | Reducen el JavaScript enviado al navegador, mejorando LCP y FID. |
| Metadata API | Exportación de metadata desde cada ruta para definir <title>, <meta> y openGraph. | Centraliza la gestión de etiquetas SEO y evita duplicados. |
Dynamic Routes + generateStaticParams | Generación estática de rutas dinámicas en tiempo de build o bajo demanda (ISR). | Garantiza URLs amigables y pre‑renderizadas para crawlers. |
| Edge Middleware | Código que se ejecuta en la capa Edge antes de la petición. | Permite redirecciones 301, canonicalización y detección de bots sin tocar el código de la página. |
2.2 Decisiones de diseño y trade‑offs
| Decisión | Ventajas | Desventajas |
|---|---|---|
| SSR (Server‑Side Rendering) en todas las rutas | Contenido siempre actualizado; SEO garantizado. | Mayor tiempo de respuesta en alta carga; mayor coste de infraestructura. |
| SSG (Static Site Generation) con ISR | Excelente performance; bajo coste; actualizaciones bajo demanda. | No apto para datos que cambian cada segundo (p. ej. precios en tiempo real). |
| Uso intensivo de Server‑Components | Reducción del bundle, mejor Core Web Vitals. | No se puede usar estado local de React; requiere pensar en separación de lógica. |
| Edge Middleware para redirecciones | Latencia mínima; lógica centralizada. | Limitado a 50 ms de ejecución; no permite operaciones de I/O pesadas. |
2.3 Diagrama conceptual (descrito en texto)
- Cliente → solicita
GET /productos/123. - Edge Middleware intercepta, verifica
User‑Agenty aplica canonicalización. - App Router resuelve la ruta
app/productos/[id]/page.tsx. - Server‑Component
ProductDetail.server.tsxconsulta la base de datos vía Supabase (PostgreSQL) usando Repository Pattern. - Metadata exportada en
page.tsxgenera<title>,<meta description>yJSON‑LD. - HTML se envía al cliente, React Hydration ocurre solo en los componentes marcados como cliente.
3. Implementación práctica
A continuación se muestra la evolución de una página de detalle de producto, desde una versión naïve (solo React cliente) hasta una arquitectura optimizada con Server‑Components, Metadata y Edge Middleware.
3.1 Versión 1 – Enfoque básico (Cliente‑only)
// app/productos/[id]/page.tsx
import React, { useEffect, useState } from "react";
import axios from "axios";
interface Producto {
id: string;
nombre: string;
precio: number;
descripcion: string;
}
/**
* Página cliente que carga datos mediante fetch en el navegador.
* No es amigable para crawlers porque el HTML inicial está vacío.
*/
export default function ProductoDetalle() {
const [producto, setProducto] = useState<Producto | null>(null);
const [error, setError] = useState<string>("");
// Extraemos el id de la URL usando la API del navegador
const id = new URLSearchParams(window.location.search).get("id") ?? "";
useEffect(() => {
if (!id) {
setError("ID de producto no especificado");
return;
}
axios
.get<Producto>(`/api/productos/${id}`)
.then((res) => setProducto(res.data))
.catch(() => setError("Error al cargar el producto"));
}, [id]);
if (error) return <p>{error}</p>;
if (!producto) return <p>Cargando...</p>;
return (
<article>
<h1>{producto.nombre}</h1>
<p>{producto.descripcion}</p>
<strong>{producto.precio.toLocaleString("es-ES", { style: "currency", currency: "EUR" })}</strong>
</article>
);
}
Problemas SEO
- El HTML entregado al crawler es solo un
<div id="root"></div>. - No hay etiquetas
<title>ni<meta description>. - El contenido depende de JavaScript; Google lo renderiza, pero otros bots pueden no hacerlo.
3.2 Versión 2 – Server‑Component + Metadata (SSR)
// app/productos/[id]/page.tsx
import type { Metadata } from "next";
import ProductDetailServer from "./ProductDetail.server";
import { notFound } from "next/navigation";
/**
* Exportamos metadata estático basado en el id de la ruta.
* Next.js ejecuta esta función en el servidor antes de renderizar.
*/
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const producto = await fetchProducto(params.id);
if (!producto) {
// Si no existe, devolvemos metadata genérica y dejamos que la página 404 se encargue.
return {
title: "Producto no encontrado",
description: "El producto solicitado no está disponible.",
};
}
return {
title: `${producto.nombre} - Tienda Online`,
description: producto.descripcion.slice(0, 150),
openGraph: {
title: producto.nombre,
description: producto.descripcion,
images: [{ url: producto.imagenUrl }],
},
robots: "index, follow",
};
}
/**
* Función auxiliar que consulta la base de datos vía Supabase.
* Se ejecuta en el servidor, por lo que podemos usar credenciales secretas.
*/
async function fetchProducto(id: string) {
const supabase = (await import("@supabase/supabase-js")).createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { data, error } = await supabase.from("productos").select("*").eq("id", id).single();
if (error) {
console.error("Error al obtener producto:", error);
return null;
}
return data;
}
/**
* Renderizamos el componente de servidor que ya tiene los datos.
*/
export default async function ProductoPage({ params }: { params: { id: string } }) {
const producto = await fetchProducto(params.id);
if (!producto) {
notFound(); // Lanza 404 y Next.js muestra la página de error.
}
return <ProductDetailServer producto={producto} />;
}
// app/productos/[id]/ProductDetail.server.tsx
import type { ReactNode } from "react";
interface Props {
producto: {
id: string;
nombre: string;
precio: number;
descripcion: string;
imagenUrl: string;
};
}
/**
* Server Component: no se envía JavaScript al cliente.
* Solo contiene markup estático, ideal para SEO.
*/
export default function ProductDetailServer({ producto }: Props): ReactNode {
return (
<article>
<h1>{producto.nombre}</h1>
<img src={producto.imagenUrl} alt={producto.nombre} loading="lazy" width={600} height={400} />
<p>{producto.descripcion}</p>
<strong>
{producto.precio.toLocaleString("es-ES", { style: "currency", currency: "EUR" })}
</strong>
</article>
);
}
Mejoras
- El HTML llega ya renderizado con contenido real.
metadatagenera<title>,<meta description>y OpenGraph.- No se envía código JavaScript innecesario, mejorando LCP.
3.3 Versión 3 – ISR + Edge Middleware + Repository Pattern
3.3.1 Repository (acceso a datos)
// src/repositories/productRepository.ts
import { createClient } from "@supabase/supabase-js";
export interface Producto {
id: string;
nombre: string;
precio: number;
descripcion: string;
imagenUrl: string;
}
/**
* Implementación del patrón Repository.
* Centraliza el acceso a Supabase y permite mockear en tests.
*/
export class ProductoRepository {
private supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
async findById(id: string): Promise<Producto | null> {
const { data, error } = await this.supabase
.from<Producto>("productos")
.select("*")
.eq("id", id)
.single();
if (error) {
console.error("Supabase error:", error);
return null;
}
return data;
}
async findAllIds(): Promise<string[]> {
const { data, error } = await this.supabase.from("productos").select("id");
if (error) {
console.error("Supabase error:", error);
return [];
}
return data.map((row) => row.id);
}
}
3.3.2 ISR – generateStaticParams
// app/productos/[id]/page.tsx (continúa)
import { ProductoRepository } from "@/src/repositories/productRepository";
export const revalidate = 60; // ISR: re‑generar cada 60 s
export async function generateStaticParams() {
const repo = new ProductoRepository();
const ids = await repo.findAllIds();
return ids.map((id) => ({ id }));
}
/* El resto del archivo permanece igual, pero ahora la página se
genera estáticamente en build y se revalida cada minuto. */
3.3.3 Edge Middleware (canonical + bot detection)
// middleware.ts (ubicado en la raíz del proyecto)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Middleware ejecutado en la Edge Network.
* - Redirige HTTP → HTTPS.
* - Añade canonical si la URL tiene parámetros de tracking.
* - Bloquea bots maliciosos conocidos.
*/
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
// 1️⃣ Forzar HTTPS
if (url.protocol === "http") {
url.protocol = "https";
return NextResponse.redirect(url);
}
// 2️⃣ Canonical: eliminar ?utm_* de la query
const cleanSearch = url.searchParams
.entries()
.filter(([k]) => !k.startsWith("utm_"))
.map(([k, v]) => `${k}=${v}`)
.join("&");
if (cleanSearch !== url.search) {
url.search = cleanSearch;
const response = NextResponse.rewrite(url);
response.headers.set("Link", `<${url.toString()}>; rel="canonical"`);
return response;
}
// 3️⃣ Bloqueo simple de bots sospechosos
const ua = request.headers.get("user-agent")?.toLowerCase() ?? "";
if (ua.includes("curl") || ua.includes("wget")) {
return new NextResponse("Access denied", { status: 403 });
}
// Continuar con la petición normal
return NextResponse.next();
}
// Aplicar middleware a todas las rutas excepto /api/*
export const config = {
matcher: "/((?!api/).*)",
};
Ventajas de la versión 3
- ISR combina la velocidad del SSG con la frescura de datos (re‑validación cada minuto).
- Edge Middleware garantiza que los crawlers reciban URLs canónicas y evita contenido duplicado.
- Repository Pattern facilita pruebas unitarias y mantiene la lógica de acceso a datos aislada.
3.4 Otros ejemplos de código (5‑8 bloques)
3.4.1 JSON‑LD estructurado (Product schema)
// app/productos/[id]/StructuredData.tsx
import type { Producto } from "@/src/repositories/productRepository";
interface Props {
producto: Producto;
}
/**
* Genera JSON‑LD siguiendo el schema.org/Product.
* Se inserta dentro de <head> mediante next/head.
*/
export default function StructuredData({ producto }: Props) {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "Product",
name: producto.nombre,
image: producto.imagenUrl,
description: producto.descripcion,
sku: producto.id,
offers: {
"@type": "Offer",
priceCurrency: "EUR",
price: producto.precio,
availability: "https://schema.org/InStock",
},
};
return (
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
3.4.2 Lazy loading de componentes cliente (por ejemplo, carrito)
// app/components/CartButton.client.tsx
"use client";
import { useState } from "react";
/**
* Cliente‑only component que se carga bajo demanda.
* Ideal para interactividad que no afecta al SEO.
*/
export default function CartButton() {
const [added, setAdded] = useState(false);
const handleAdd = () => {
// Simular llamada a API
fetch("/api/cart", { method: "POST", body: JSON.stringify({ productId: "123" }) })
.then(() => setAdded(true))
.catch(console.error);
};
return (
<button onClick={handleAdd} disabled={added} aria-pressed={added}>
{added ? "Añadido al carrito" : "Añadir al carrito"}
</button>
);
}
// Dentro de ProductDetailServer.tsx
import dynamic from "next/dynamic";
const CartButton = dynamic(() => import("../components/CartButton.client"), {
ssr: false, // nunca se renderiza en el servidor
});
export default function ProductDetailServer({ producto }: Props) {
return (
<article>
{/* ... contenido estático ... */}
<CartButton />
</article>
);
}
