EmpiezaTuSaaS
Funcionalidades

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.json

Cada 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:

  1. Skip para /api/ y /_next/ (no necesitan i18n)
  2. Ejecutar handleI18nRouting(request) — detecta idioma, redirige/reescribe URLs
  3. Extraer el path sin prefijo de locale (/en/dashboard/dashboard)
  4. Verificar si es ruta protegida o de auth usando lib/access.ts
  5. Redirect con el prefijo de locale correcto si falta sesión o ya está autenticado
proxy.ts
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:

components/landing/language-switcher.tsx
"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.json

Paso 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):

  1. Crea un nuevo fichero JSON: messages/es/dashboard.json y messages/en/dashboard.json
  2. Añade el import en i18n/request.ts
  3. En el componente, usa useTranslations("dashboard") (cliente) o getTranslations("dashboard") (servidor)
  4. 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 locale y alternateLocale
  • alternates.languages con hreflang (es, en, x-default)
  • Canonical URLs apuntando al locale por defecto

El sitemap.ts incluye URLs para ambos idiomas con alternates.languages.

On this page