Internacionalización (i18n)
Soporte multiidioma con next-intl para las landing pages del boilerplate.
Visión general
El boilerplate incluye soporte completo de internacionalización (i18n) para las 2 landing pages (/, /waitlist) usando next-intl v4.
- Idiomas incluidos: Español (por defecto) e Inglés
- Prefijo de URL:
as-needed— el idioma por defecto (ES) tiene URLs limpias (/,/waitlist), el inglés usa prefijo (/en,/en/waitlist) - Las landing pages fuerzan modo claro (
className="light") independientemente del tema del usuario
Arquitectura
Tres archivos configuran el sistema i18n:
i18n/
├── routing.ts ← Define locales, idioma por defecto y estrategia de prefijo
├── request.ts ← Carga dinámica de mensajes JSON por locale
└── navigation.ts ← Exporta Link, useRouter, usePathname, redirect (locale-aware)i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["es", "en"],
defaultLocale: "es",
localePrefix: "as-needed",
});
export type Locale = (typeof routing.locales)[number];i18n/request.ts
Carga dinámicamente los ficheros JSON de mensajes y los fusiona:
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !hasLocale(routing.locales, locale)) {
locale = routing.defaultLocale;
}
const messages = {
...(await import(`../messages/${locale}/common.json`)).default,
...(await import(`../messages/${locale}/landing.json`)).default,
...(await import(`../messages/${locale}/waitlist.json`)).default,
};
return { locale, messages };
});i18n/navigation.ts
Reemplaza next/link y next/navigation con versiones locale-aware:
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);Importante: En todo el proyecto, usa Link de @/i18n/navigation en vez de next/link.
Ficheros de mensajes
messages/
├── es/
│ ├── common.json ← Header, footer, CTA, checkout
│ ├── landing.json ← Hero, features, pricing, FAQ
│ └── waitlist.json ← Página de lista de espera
└── en/
├── common.json
├── landing.json
└── waitlist.jsonCada fichero JSON usa namespaces anidados:
{
"hero": {
"titleStart": "Lanza tu SaaS en",
"titleHighlight": "días",
"subtitle": "no en meses"
}
}Uso en componentes
Componentes cliente
"use client";
import { useTranslations } from "next-intl";
export function Hero() {
const t = useTranslations("hero");
return <h1>{t("titleStart")} <span>{t("titleHighlight")}</span></h1>;
}Componentes servidor (páginas)
import { setRequestLocale, getTranslations } from "next-intl/server";
type Props = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "waitlist" });
return { title: t("pageTitle") };
}
export default async function WaitlistPage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
return <WaitlistHero />;
}Arrays traducidos
Para listas como FAQ o features, usa t.raw():
const faqItems = t.raw("items") as Array<{ question: string; answer: string }>;Interpolación
// En el JSON: "description": "{name} tiene todo lo que necesitas"
t("description", { name: siteConfig.name })
// En el JSON: "billedAnnually": "Facturado anualmente ({total}€/año)"
t("billedAnnually", { total: price * 12 })proxy.ts: i18n + autenticación
El archivo proxy.ts (equivalente a middleware.ts en Next.js 16) integra la detección de idioma de next-intl con la protección de rutas de Better Auth.
El flujo es:
- Skip para
/api/y/_next/(no necesitan i18n) - Ejecutar
handleI18nRouting(request)— detecta idioma, redirige/reescribe URLs - Extraer el path sin prefijo de locale (
/en/dashboard→/dashboard) - Verificar si es ruta protegida o de auth usando
lib/access.ts - Redirect con el prefijo de locale correcto si falta sesión o ya está autenticado
import createMiddleware from "next-intl/middleware";
import { getSessionCookie } from "better-auth/cookies";
import { routing } from "@/i18n/routing";
import { isAuthRoute, isProtectedRoute } from "@/lib/access";
const handleI18nRouting = createMiddleware(routing);
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith("/api/") || pathname.startsWith("/_next/")) {
return NextResponse.next();
}
const i18nResponse = handleI18nRouting(request);
// Quitar /en del path para hacer matching con las rutas protegidas
const strippedPathname = stripLocalePrefix(pathname);
if (!isProtectedRoute(strippedPathname) && !isAuthRoute(strippedPathname)) {
return i18nResponse;
}
const sessionCookie = getSessionCookie(request);
const locale = getLocaleFromPath(pathname);
const localePrefix = locale === routing.defaultLocale ? "" : `/${locale}`;
if (!sessionCookie && isProtectedRoute(strippedPathname)) {
return NextResponse.redirect(new URL(`${localePrefix}/login`, request.url));
}
if (sessionCookie && isAuthRoute(strippedPathname)) {
return NextResponse.redirect(new URL(`${localePrefix}/dashboard`, request.url));
}
return i18nResponse;
}lib/access.ts no necesita cambios — siempre recibe paths limpios sin prefijo de locale.
Language Switcher
El componente LanguageSwitcher está ubicado en components/landing/language-switcher.tsx y aparece en el Header tanto en la versión desktop (junto a los botones de login) como en el menú móvil.
Es un botón simple que muestra el locale actual (ES / EN) y al hacer click cambia al otro idioma manteniendo la ruta actual:
"use client";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { Globe } from "lucide-react";
import { type Locale } from "@/i18n/routing";
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const nextLocale = locale === "es" ? "en" : "es";
return (
<button
onClick={() => router.replace(pathname, { locale: nextLocale as Locale })}
>
<Globe className="h-4 w-4" />
<span>{locale.toUpperCase()}</span>
</button>
);
}Para añadir más idiomas, convierte el botón toggle en un dropdown usando DropdownMenu de shadcn/ui (ya disponible en el proyecto).
Modo claro en landing pages
Las 2 landing pages fuerzan modo claro con className="light" en su div raíz:
<div className="light bg-background font-kaio relative min-h-screen">
{/* contenido */}
</div>Esto sobrescribe el tema del usuario usando la clase CSS de Tailwind, asegurando que la landing siempre se muestre en modo claro.
Añadir un nuevo idioma
Paso 1: Actualizar routing.ts
Añade el nuevo locale al array:
locales: ["es", "en", "fr"],Paso 2: Crear directorio de mensajes
mkdir messages/fr
cp messages/es/common.json messages/fr/common.json
# Repite para landing.json, waitlist.jsonPaso 3: Traducir los ficheros JSON
Traduce todos los valores (las claves se mantienen iguales).
Paso 4: Actualizar request.ts
Añade los imports dinámicos para el nuevo locale (ya se manejan automáticamente por la ruta messages/${locale}/).
Paso 5: Actualizar el Language Switcher
Añade el nuevo locale a localeNames en components/landing/language-switcher.tsx.
Traducir una página existente
Para traducir una página que actualmente tiene texto hardcodeado (como /dashboard):
- Crea un nuevo fichero JSON:
messages/es/dashboard.jsonymessages/en/dashboard.json - Añade el import en
i18n/request.ts - En el componente, usa
useTranslations("dashboard")(cliente) ogetTranslations("dashboard")(servidor) - Reemplaza el texto hardcodeado con llamadas
t("key")
SEO multiidioma
El layout de [locale] genera automáticamente:
<html lang="es">o<html lang="en">via Script- OpenGraph
localeyalternateLocale alternates.languagescon hreflang (es,en,x-default)- Canonical URLs apuntando al locale por defecto
El sitemap.ts incluye URLs para ambos idiomas con alternates.languages.