Añadir funcionalidad multilingüe al blog (NextJS, next-intl, Vercel AI SDK)

힘센캥거루
2025년 11월 25일
3
nextjs

Últimamente he pensado que mi blog necesitaba una funcionalidad multilingüe.

Así que decidí implementar un servicio multilingüe utilizando next-intl.

Imagen subida

1.i18n

Primero, cuando se ofrece un servicio multilingüe hay ciertos principios que se deben seguir.

A esto se le llama internationalization, y como es muy largo, se toma la primera letra i y la última n, y las 18 letras entre ellas para formar i18n.

1) Principios de i18n

(1) Prohibir el hardcodeo de cadenas

No se deben insertar directamente en el código cadenas como "회원가입" (registro); siempre se deben obtener a través de claves de mensaje.

Por ejemplo, supongamos que vamos a crear la página principal del blog. 

Entonces se podría hacer algo como lo siguiente.

// components/Header.tsx
export function Header() {
  return (
    <header>
      <h1>지구과학 블로그</h1>
      <button>로그인</button>
      <button>회원가입</button>
    </header>
  );
}

Si se hace hardcodeo, habría que crear componentes separados para cada idioma.

En lugar de eso, se crean recursos separados por idioma y región para poder modificarlos todos de una sola vez.

Se preparan recursos separados, por ejemplo ko.ts para coreano y en.ts para inglés.

Por supuesto, también se puede usar JSON.

// locales/ko.ts
export const ko = {
  header: {
    title: '지구과학 블로그',
    login: '로그인',
    signup: '회원가입',
  },
};

// locales/en.ts
export const en = {
  header: {
    title: 'Earth Science Blog',
    login: 'Log in',
    signup: 'Sign up',
  },
};

Según el locale, se llaman los contenidos correspondientes para que el idioma pueda cambiar.

(2) Uso de parámetros y soporte de plurales

Contenido como "안녕하세요 힘캥님" se gestiona en la forma `안녕하세요, ${name}님` para que solo cambie el nombre.

Además, en inglés se distingue entre singular y plural, como en 1 comment y 2 comments, por lo que esto debe manejarse a nivel de mensajes.

// locales/ko.ts
export const ko = {
  greeting: '안녕하세요, {name}님',
  comments: {
    zero: '아직 댓글이 없습니다.',
    one: '댓글이 1개 있습니다.',
    other: '댓글이 {count}개 있습니다.',
  },
};

// locales/en.ts
export const en = {
  greeting: 'Hello, {name}',
  comments: {
    zero: 'No comments yet.',
    one: 'There is 1 comment.',
    other: 'There are {count} comments.',
  },
};

(3) Formato de fecha/número/moneda por locale

Cada país tiene distintas formas de representar fechas y números.

Por ejemplo, el 27 de noviembre de 2025 puede representarse como 2025-11-27, pero también como 27/11/2025.

(4) Negociación de locale y valor por defecto (fallback)

Se comprueban primero el idioma del navegador, la URL, las cookies, etc., y si no se encuentra un idioma compatible, se establece defaultLocale.

(5) Separación entre traducción y código

La persona desarrolladora no traduce el idioma sino que solo utiliza las claves.

La persona traductora gestiona únicamente el texto mediante JSON o herramientas separadas.

2. next-intl

Entre muchas librerías, next-intl cumplía con las condiciones de i18n anteriores y tenía una base de usuarios considerable.

Por eso decidí aplicarla también en mi blog.

Primero se instala la librería.

yarn add next-intl

Luego se crea el archivo i18n/config.ts en la carpeta raíz.

// i18n/config.ts
export const defaultLocale = 'ko';
export const locales = ['ko', 'en', 'ja'] as const;
export type Locale = (typeof locales)[number];

Y en i18n/request.ts se crea una función para obtener mensajes desde los Server Components.

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { defaultLocale } from './config';

export default getRequestConfig(async ({ requestLocale }: { requestLocale: Promise<string | undefined> }) => {
  const locale = await requestLocale || defaultLocale;
  return {
    locale,
    messages: (await import(`./message/${locale}.json`)).default,
  };
});

Después se crea una carpeta message y en ella archivos JSON para cada idioma.

Si no existe el archivo JSON correspondiente a un idioma, se produce un error al acceder.

/messages
  ├── ko.json
  ├── en.json
  └── ja.json

Estos archivos deben tener la misma estructura interna.

Si se quiere fastidiar a la persona desarrolladora, se pueden hacer con estructuras distintas.

// ko.json
{
  "Home": {
    "title": "지구과학 블로그",
    "readMore": "더보기"
  }
}

// en.json
{
  "Home": {
    "title": "Earth Science Blog",
    "readMore": "Read more"
  }
}

Ahora toca crear la carpeta [locale].

En el App Router es natural colocar el locale en la ruta más alta.

Se pueden excluir las rutas como api o aquellas que no necesitan locale.

app
 ├── [locale]
 │     ├── layout.tsx
 │     ├── page.tsx
 │     └── ... (otras rutas)
 └── layout.tsx

En el layout.tsx principal se configuran los ajustes que se deseen.

En [locale]/layout.tsx se habilita el uso del locale de la siguiente forma.

// app/[locale]/layout.tsx
import { NextIntlClientProvider, getMessages } from 'next-intl';
import { locales } from '@/i18n/config';
import { notFound } from 'next/navigation';

export default async function LocaleLayout({ children, params }) {
  const { locale } = params;

  if (!locales.includes(locale)) {
    notFound(); // 404 처리
  }

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages} locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Aquí, la función getMessages() carga los mensajes según el locale y los distribuye mediante el provider.

De este modo, los componentes hijos pueden utilizar mensajes según el locale.

Veamos un ejemplo de uso.

// Server Component
import { getTranslations } from 'next-intl/server';

export default async function Page() {
  const t = await getTranslations('Home');

  return <h1>{t('title')}</h1>;
}

// Client Component
'use client';

import { useTranslations } from 'next-intl';

export default function ReadMoreButton() {
  const t = useTranslations('Home');

  return <button>{t('readMore')}</button>;
}

En los Server Components se puede usar getTranslation y en los Client Components, useTranslation.

También en metadata se puede cambiar el valor según el locale usando este método.

// app/[locale]/posts/[slug]/page.tsx
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({ params }) {
  const t = await getTranslations({ locale: params.locale, namespace: 'Post' });

  return {
    title: t('metaTitle'),
    description: t('metaDescription')
  };
}

2. Cambio del esquema de la base de datos

Hasta aquí la dificultad no fue tan alta.

Eso fue hasta que me di cuenta de que tenía que cambiar la base de datos.

El esquema de base de datos que estaba usando era el siguiente.

model Post {
  id               String          @id @default(cuid())
  title            String
  processedContent String?         // 렌더링용 처리된 콘텐츠
  thumbnail        String?
  createdAt        DateTime        @default(now())
  updatedAt        DateTime?
  likes            Int             @default(0)
  categoryId       String?
  ....
}

Si quería que todos los locales compartieran likes y views, tenía que separar processedContent y crear otra tabla según el locale.

Meter en el propio post todo el contenido por locale haría que la tabla fuera demasiado grande y no cumpliría con la forma normal.

Cambiar el esquema en sí no fue sencillo, pero en el momento en que separé el contenido del post, tuve que modificar todas las funciones de mi sitio relacionadas con redacción, lectura y paginación de entradas.

Así que decidí cambiarlo de forma progresiva.

// Dejar Post tal cual
model Post {
  ....
}

model PostLocale {
  id               String   @id @default(cuid())
  postId           String
  locale           String
  title            String
  excerpt          String
  content          String
  ...

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
}

Como las dos tablas están relacionadas, basta con devolver el post de forma diferente usando postId y locale.

Para minimizar los cambios en los componentes, hice que el tipo de salida de la función de obtención de posts que ya usaba y la función que obtiene posts según el locale fuera el mismo.


export async function getLocalizedPostBySlug(
  slug: string,
  locale: string
): Promise<PostContentProps | null> {
  // Obtener el post
  const basePost = await getPostBySlug(slug);
  // Con el post obtenido, obtener el post que corresponde al locale
  const localeEntry = await prisma.postLocale.findFirst({
    where: {
      postId: basePost.id as string,
      locale,
    },
  });

  const localizedContent = localeEntry.processedContent || localeEntry.content;
  // Devolver el contenido obtenido
  return {
    ...basePost,
    title: localeEntry.title,
    excerpt: localeEntry.excerpt,
    category: localeEntry.category ?? basePost.category,
    content: localeEntry.content,
  };
}

Y también apliqué esto a la lectura de las entradas.

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const locale = await getLocale();
  const post =
    locale !== "ko"
      ? await getLocalizedPostBySlug(slug, locale)
      : await getPostBySlug(slug);
  ...
}

Ahora solo queda traducir las entradas e insertarlas en la base de datos.

3. Traducción con Vercel AI SDK

También se podría implementar con Python, pero como quería usar TypeScript puro, decidí utilizar Vercel AI SDK.

Si se usa Generate Text, se puede obtener el contenido generado a través de la API.

import { PrismaClient } from "@prisma/client";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

const prisma = new PrismaClient();

const TARGET_LOCALES = ["en", "zh", "ja", "es"] as const;

async function translatePost(post: {
  id: string;
  title: string;
  content: string;
}) {
  ...
  // 포스트 조회
  for (const locale of TARGET_LOCALES) {
    const exists = await prisma.postLocale.findUnique({
      where: {
        postId_locale: {
          postId: post.id,
          locale,
        },
      },
    });
    // 출력될 형식을 미리 지정
    const payload = JSON.stringify({
      targetLocale: locale,
      title: post.title,
      content: post.content,
    });

    const { text } = await generateText({
      model: openai("gpt-5.1"),
      // 프롬프트 설정
      system:
        "You are a professional translator. Translate the JSON fields `title` and `content` into `targetLocale`. Preserve HTML tags, Markdown, code blocks, links, and inline styles exactly as-is; only translate the human-readable text. Keep technical terms and product names unless a well-known localized form exists. Return the translated JSON with the same keys and no extra commentary.",
      prompt: payload,
    });
    // text에 '''와 같은 글자가 있어서 실제로 쓸 때는 이스케이프 필요함.
    const json = JSON.parse(text);

    await prisma.postLocale.create({
      data: {
        postId: post.id,
        locale,
        title: json.title || post.title,
        content: json.content || post.content,
        category: json.category || null,
        // 내용 생략
      },
    });
    results[locale] = json;
  }
  return results;
}

Creo que tardó más de una hora en traducir unas 170 entradas a 4 idiomas.

Y el coste de la API fue de 16,69 dólares.

Imagen subida

Si lo convertimos, son unos 24.000 won.

Es porque usé GPT-4o, pero aun así, teniendo en cuenta la calidad de la traducción, me parece aceptable.

4. Registro de sitemap.xml

Por último, solo queda modificar sitemap.xml.

Pensaba que había que crear un sitemap separado por idioma, pero por lo visto lo habitual es incluirlo todo en el sitemap del dominio principal.

Registré el sitemap tomando como referencia las entradas publicadas.

...
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await prisma.post.findMany({
    where: { published: true },
    select: { id: true, slug: true, createdAt: true, updatedAt: true },
    orderBy: { updatedAt: "desc" },
  });

  const staticPages: MetadataRoute.Sitemap = locales.flatMap((locale) =>
    staticRoutes.map(({ path, changeFrequency, priority }) => ({
      url: buildLocalizedUrl(path, locale), // 내가 만든 함수
      lastModified: new Date(),
      changeFrequency,
      priority,
    }))
  );
...

5. Conclusiones

Estuve dos meses dándole vueltas a la idea de ofrecer un servicio multilingüe y, al final, implementarlo no me llevó ni 2–3 días.

Una vez más, me di cuenta de que lo importante es intentarlo sin más.

Esta vez lo solucioné más o menos añadiendo una tabla, pero aprendí que una vez que se diseña una base de datos y se crea un servicio acorde, no es nada fácil cambiarlo después.

Volví a darme cuenta de la importancia del diseño del esquema y de la base de datos.

La próxima vez que haga otro proyecto, creo que primero revisaré con más detalle el diseño de la base de datos.

댓글을 불러오는 중...