neptay
Todas las notas

Ingeniería

Construir un sitio bilingüe con Next.js App Router: i18n, hreflang y datos estructurados bien hechos

Una guía práctica de cómo lanzamos sitios bilingües (EN/TR) con Next.js App Router: enrutamiento por idioma, alternativas hreflang, JSON-LD por idioma y los pequeños trucos de middleware que tratan a ambos idiomas como de primera clase.

En este artículo

La mayoría de los sitios bilingües son sitios monolingües con una capa de traducción añadida a la fuerza. La versión en inglés es la que sale primero, la que tiene los formularios que funcionan y la que los buscadores aprenden primero. La edición traducida es un archivo JSON caído de una memoria de traducción y un menú desplegable en la cabecera que nadie probó.

No es eso lo que queríamos construir. neptay.com empezó en inglés y turco y hoy funciona en diez idiomas desde la misma capa de contenido, con enrutamiento, metadatos, sitemaps, hreflang y datos estructurados que tienen en cuenta el idioma. La edición en turco no es un añadido posterior: es el mismo sitio con otra voz. Este artículo es el recorrido de ingeniería: cómo lo montamos en Next.js App Router, qué poner en el middleware, qué aspecto debe tener hreflang para que Google confíe en él y las pequeñas decisiones estructurales que dan frutos más adelante.

Si vas a lanzar un sitio bilingüe pequeño (la página de un estudio, la carta de un restaurante, una landing regional), puedes copiar este patrón casi tal cual. Si vas a lanzar una tienda en cincuenta idiomas, esta es la base; solo tendrás que cambiar el diccionario por un feed de un CMS.

La forma del espacio de URLs

Hay básicamente tres opciones para organizar las URLs de un sitio bilingüe en Next.js:

  1. Un subdominio por idioma (en.example.com, tr.example.com). Separación fuerte, pero gestionar TLS y analítica sale caro, y en un sitio pequeño fragmenta la experiencia visual.
  2. Un TLD de país por idioma (example.com, example.com.tr). Excelente para la segmentación geográfica, pero solo se justifica si de verdad operas un negocio específico de Turquía; si no, es costoso.
  3. Un prefijo de idioma por ruta (example.com/en/, example.com/tr/). Un solo dominio, un solo despliegue, una sola propiedad de analítica. Es lo que usamos nosotros y lo que deberían usar la mayoría de los sitios bilingües pequeños.

Next.js App Router gestiona los prefijos de idioma con limpieza porque las rutas salen del sistema de archivos. Todo el sitio vive bajo app/[locale]/ y cada página final recibe el idioma como parámetro de ruta. No hay una página de inicio con casos especiales ni una división oculta entre rutas «traducidas» y «no traducidas»: el idioma es solo un segmento más.

La estructura de directorios que usamos

Ejemplo de código
app/
  [locale]/
    layout.tsx       ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
    page.tsx         ← home
    services/page.tsx
    work/
      page.tsx       ← work index
      [slug]/page.tsx← case study
    studio/page.tsx
    contact/page.tsx
    insights/
      page.tsx
      [slug]/page.tsx
    [...rest]/page.tsx ← catch-all → notFound()
    not-found.tsx    ← localized 404
  sitemap.ts
  robots.ts

Fíjate en lo que falta: no hay app/layout.tsx. El layout del idioma es el layout raíz: controla directamente el elemento <html>, así que <html lang="…"> sale directamente del parámetro de ruta. Sin trucos con cabeceras, sin parches en el cliente y, sobre todo, sin ninguna llamada a headers(), que sacaría en silencio todas las rutas de la generación estática. Las secciones que viven fuera del sistema de idiomas (nuestras demos en vivo) tienen su propio layout raíz: Next.js admite varios layouts raíz, y la frontera del idioma es justo el lugar para usarlos.

Middleware: detectar el idioma correcto y redirigir

Cuando alguien llega al dominio raíz, tienes que decidir: ¿adónde lo envías? Tienes tres señales: la URL (si escribió /tr/ explícitamente), la cabecera Accept-Language (lo que pide el navegador) y una cookie (lo que eligió la última vez). La regla que usamos es la aburrida que funciona:

  1. Si la URL ya tiene un prefijo de idioma, se respeta. Se guarda una cookie para recordar la elección.
  2. Si no, y hay una cookie de una visita anterior, se redirige a ese idioma.
  3. Si no, y la cabecera Accept-Language corresponde a un idioma que admitimos, se redirige allí.
  4. Si no, se recurre al idioma predeterminado (inglés).

Lo hacemos en middleware.ts, no en un layout: es más rápido, se ejecuta en el edge y evita desajustes de hidratación. Esta es una versión simplificada del middleware que funciona en neptay.com:

middleware.tsTypeScript
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";

const LOCALE_COOKIE = "neptay.locale";

function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
  if (!header) return defaultLocale;
  const lower = header.toLowerCase();
  if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
  return defaultLocale;
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Live demos live outside the locale system — they handle their own language.
  if (pathname === "/demo" || pathname.startsWith("/demo/")) {
    return NextResponse.next();
  }

  const matchedLocale = locales.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );
  if (matchedLocale) {
    const res = NextResponse.next();
    // Remember the locale so the next bare-domain visit lands on it.
    if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
      res.cookies.set(LOCALE_COOKIE, matchedLocale, {
        path: "/",
        maxAge: 60 * 60 * 24 * 365,
        sameSite: "lax",
      });
    }
    return res;
  }

  // Honour the user's manual choice if it was already remembered.
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
  const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
  const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));

  const url = req.nextUrl.clone();
  url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  // Skip Next internals + static assets so they aren't redirected.
  matcher: [
    "/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
  ],
};

Aquí hay dos detalles que se ganan su sitio. La comprobación de Accept-Language analiza de verdad la lista separada por comas de la cabecera; no es un startsWith: un navegador que envía "en-GB,tr;q=0.8" no debe tratarse como turco solo porque "tr" aparezca en algún lugar. Y la cookie se escribe en cada visita con prefijo de idioma, no solo al cambiar de idioma a mano, así que la siguiente visita al dominio raíz llega al idioma que el visitante leyó por última vez. Sin eso, cambiar de idioma parece roto: un visitante que vuelve y que eligió turco explícitamente acaba rebotado a lo que adivine Accept-Language.

Metadatos, por idioma y por ruta

Cada página del App Router puede exportar generateMetadata. En un sitio bilingüe, en esa función tienen que pasar tres cosas:

  1. Definir el título y la descripción en el idioma actual.
  2. Definir una URL canónica que apunte exactamente a esta ruta (sin problemas de barra final ni rarezas de protocolo).
  3. Definir alternativas hreflang para cada idioma en el que exista esta página, más un x-default.

Next.js lo hace trivial con el objeto alternates.languages. Esta es la forma por ruta:

Ejemplo de códigoTypeScript
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
  if (!isLocale(params.locale)) return {};
  const seo = WORK_SEO[params.locale];
  return {
    title: seo.title,
    description: seo.description,
    alternates: {
      canonical: `/${params.locale}/work`,
      languages: {
        en: "/en/work",
        tr: "/tr/work",
        "x-default": "/en/work",
      },
    },
    openGraph: { title: seo.title, description: seo.description, type: "website" },
  };
}

Dos cosas que la gente hace mal con hreflang:

  • Cada alternativa debe apuntar de vuelta. Si /en/work declara /tr/work como alternativa, /tr/work también debe declarar /en/work. Si faltan las referencias recíprocas, Google ignora todo el grupo hreflang.
  • x-default no es un idioma. Es la URL para los usuarios cuyo idioma no coincide con ninguno de los tuyos. Nosotros lo apuntamos a /en/work porque el inglés es nuestra alternativa más amplia, no porque el inglés sea más importante.

Si tienes muchas páginas, escribe una pequeña función auxiliar que genere el objeto alternates para cualquier ruta. Nosotros lo escribimos a mano en cada página porque solo tenemos unas pocas y la versión explícita es más fácil de depurar cuando estás mirando Search Console a las once de la noche.

Sitemaps y hreflang en el sitemap

El hreflang por ruta en los metadatos es la mitad del trabajo. Google también lee hreflang en el sitemap, y el sitemap es la señal con más autoridad porque cubre todo el sitio a la vez. El App Router tiene una convención integrada, sitemap.ts; así es el nuestro:

app/sitemap.tsTypeScript
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";

const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date();
  return locales.flatMap((locale) =>
    PATHS.map((p) => ({
      url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
      lastModified,
      changeFrequency: "monthly" as const,
      priority: p === "" ? 1 : 0.7,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
        ),
      },
    }))
  );
}

Fíjate en que el sitemap emite una entrada por cada combinación (idioma, ruta), y cada entrada enumera todas las alternativas de idioma. Es repetitivo, y a propósito. Los buscadores prefieren rastrear pares explícitos a adivinar a partir de patrones de URL.

Datos estructurados: un grafo base y extensiones por ruta

El JSON-LD de Schema.org es el segundo pilar de cómo Google entiende un sitio multilingüe. Lo dividimos en dos capas:

  1. Un grafo base (Organization + WebSite) que el layout raíz inyecta una sola vez. Declara a quién pertenece el sitio, dónde está el logo y en qué idiomas se publica.
  2. Esquemas por ruta (CreativeWork, BreadcrumbList, Article, FAQPage) que inyecta cada página. Son pequeños bloques JSON-LD que describen qué es esta página en concreto.

Ambos tienen inLanguage definido con el idioma actual. No te lo saltes: sin inLanguage, Google tiene que deducir el idioma a partir del texto de la página, algo lento e impreciso en páginas cortas.

lib/structured-data.tsTypeScript
// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay",
  url: SITE_URL,
  logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
  knowsLanguage: ["en", "tr"],
  areaServed: "Worldwide",
};

export function articleLd(input: ArticleInput) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: input.headline,
    inLanguage: input.locale,            // ← critical for bilingual sites
    datePublished: input.datePublished,
    author: { "@id": `${SITE_URL}/#organization` },
    publisher: { "@id": `${SITE_URL}/#organization` },
    // …
  };
}

El patrón del diccionario

Las cadenas de traducción viven en un único diccionario tipado, exportado como un mapa const indexado por idioma. No está de moda: el consejo actual es usar un framework como next-intl o react-intl con el formato de mensajes ICU. Para un sitio pequeño, el diccionario tipado es más rápido, más ligero y detecta las traducciones que faltan en tiempo de compilación:

Ejemplo de códigoTypeScript
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];

export interface Dictionary {
  locale: Locale;
  nav: { services: string; work: string; studio: string; contact: string };
  // …
}

export const dictionaries: Record<Locale, Dictionary> = {
  en: { /* … */ },
  tr: { /* … */ },
};

Si falta la traducción al turco de una cadena, TypeScript protesta durante el build. Si se añade una clave en inglés pero no en turco, el build falla. Eso vale mucho cuando tienes dos idiomas: significa que la única forma de publicar es mantenerlos sincronizados.

El patrón se rompe hacia los veinte idiomas o cuando personas que no son ingenieras necesitan editar los textos. Llegado ese punto, pasa el diccionario a un CMS y acepta que las traducciones irán con retraso. Para dos idiomas y el sitio de un estudio, TypeScript puro gana.

Renderizado estático, caché en el edge y por qué importa para el SEO

Todo lo anterior es neutral en rendimiento en una sola carga de página. Donde importa es a escala. Al mantener cada página renderizada de forma estática (sin consultas a base de datos en tiempo de ejecución ni llamadas de traducción por petición), cada idioma de cada página es un documento HTML cacheable y servido desde la caché del edge. El tiempo hasta el primer byte es el que diga tu CDN: normalmente 30–80 ms en cualquier parte del planeta.

Eso importa para el SEO por dos razones: las Core Web Vitals dan mucho peso al TTFB y al LCP, y el rastreador de Google tiene un presupuesto. Una página estática en la caché del edge le cuesta al rastreador un viaje de ida y vuelta barato; una página renderizada en el servidor puede costarle diez veces más. El presupuesto de rastreo no es un problema para el sitio de cinco páginas de un estudio, pero enseguida lo es para un blog multilingüe con doscientos artículos.

Lo que todavía mejoraríamos

Una lista honesta de lo que aún vale la pena hacer en un sitio bilingüe con App Router una vez resuelto lo básico:

  • Imágenes OpenGraph por idioma. Ahora mismo la imagen OG es la misma en ambos idiomas; lo ideal es que el titular de la imagen también esté traducido.
  • Detección automática de desfases de traducción. Un paso de CI que compare las claves de los diccionarios EN y TR y falle si no coinciden. Fácil y valioso.
  • Un selector de idioma que mantenga al usuario en la misma página si existe traducción y que, si no existe, ofrezca una alternativa con elegancia (y un aviso).

Si estás definiendo el alcance de un sitio bilingüe y quieres una segunda opinión sobre la arquitectura, o quieres que lo construyamos nosotros, escríbenos a hello@neptay.com.

Neptay Media & Technology Services

Hablemos

¿Tienes un proyecto parecido?

Los métodos de estos artículos son los mismos que aplicamos en los proyectos de clientes: software, automatización con IA, contenido, redes sociales y producción de directos. Cuéntanos qué tienes en mente; respondemos en menos de 24 horas.