DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

i18n en Next.js con DynamoDB, traducciones editables sin redeploy

Un cliente internacional me pidió una cosa aparentemente simple: "quiero que marketing pueda cambiar textos sin molestar a desarrollo". Tenían Next.js con i18n en archivos JSON, cada cambio era un PR, un review, un deploy. Total: 2-3 días de friction para cambiar "Comenzar" por "Empezar". Migré todo a DynamoDB con cache en memoria y un panel simple. Marketing edita, se ve en 60 segundos.

Stack final: Next.js 15, DynamoDB con GSI, Lambda para el panel admin, cache en ISR.

Arquitectura

flowchart TB
    M[Marketing<br/>Panel Admin] --> API[Lambda API]
    API --> DDB[(DynamoDB<br/>translations)]
    API --> Inv[Revalidate Tag]
    Inv --> ISR[Next.js ISR]

    User[Usuario final] --> NextApp[Next.js App]
    NextApp -->|getTranslations| Cache{Cache ISR<br/>60s}
    Cache -->|miss| DDB
    Cache -->|hit| User
Enter fullscreen mode Exit fullscreen mode

La clave: las traducciones se guardan en DynamoDB, Next.js las lee con unstable_cache y las sirve cacheadas por 60 segundos. Cuando marketing cambia algo, una llamada a revalidateTag invalida el cache. El usuario final siempre ve algo cacheado, lo que evita que un spike de tráfico destroce DynamoDB.

Modelo de datos

// Primary key: PK = tenant#locale, SK = namespace#key

interface TranslationItem {
  PK: string;        // "acme#es-CO"
  SK: string;        // "common#cta.submit"
  value: string;     // "Enviar"
  updatedAt: number;
  updatedBy: string;
  history: Array<{
    value: string;
    updatedAt: number;
    updatedBy: string;
  }>;
}

// GSI1 para exportar bulk por namespace:
// GSI1PK = tenant#locale#namespace, GSI1SK = key
Enter fullscreen mode Exit fullscreen mode

Tres razones para esta estructura:

  1. Partition key compuesta: tenant#locale permite multi-tenancy con aislamiento perfecto. Acme en español está en una partición, Acme en inglés en otra.

  2. Sort key jerárquica: namespace#key permite queries eficientes por namespace (begins_with(SK, "common#")) sin escanear toda la partición.

  3. History inline: guardo hasta 10 versiones anteriores en el mismo item. Si marketing hace algo mal, rollback en 1 click. Más de 10, al S3.

Capa de acceso

// src/lib/translations/repository.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  QueryCommand,
  UpdateCommand,
  GetCommand,
} from '@aws-sdk/lib-dynamodb';

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TRANSLATIONS_TABLE!;

export async function getTranslationsForNamespace(
  tenant: string,
  locale: string,
  namespace: string
): Promise<Record<string, string>> {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
      ExpressionAttributeValues: {
        ':pk': `${tenant}#${locale}`,
        ':sk': `${namespace}#`,
      },
    })
  );

  const translations: Record<string, string> = {};
  for (const item of result.Items || []) {
    const key = (item.SK as string).substring(namespace.length + 1);
    translations[key] = item.value;
  }
  return translations;
}

export async function setTranslation(
  tenant: string,
  locale: string,
  namespace: string,
  key: string,
  value: string,
  updatedBy: string
): Promise<void> {
  const pk = `${tenant}#${locale}`;
  const sk = `${namespace}#${key}`;

  const existing = await client.send(
    new GetCommand({
      TableName: TABLE,
      Key: { PK: pk, SK: sk },
    })
  );

  const now = Date.now();
  const newHistoryEntry = existing.Item
    ? [
        {
          value: existing.Item.value,
          updatedAt: existing.Item.updatedAt,
          updatedBy: existing.Item.updatedBy,
        },
        ...(existing.Item.history || []).slice(0, 9),
      ]
    : [];

  await client.send(
    new UpdateCommand({
      TableName: TABLE,
      Key: { PK: pk, SK: sk },
      UpdateExpression: `
        SET #v = :v,
            updatedAt = :ua,
            updatedBy = :ub,
            history = :h,
            GSI1PK = :gsi1pk,
            GSI1SK = :gsi1sk
      `,
      ExpressionAttributeNames: {
        '#v': 'value',
      },
      ExpressionAttributeValues: {
        ':v': value,
        ':ua': now,
        ':ub': updatedBy,
        ':h': newHistoryEntry,
        ':gsi1pk': `${tenant}#${locale}#${namespace}`,
        ':gsi1sk': key,
      },
    })
  );
}

export async function listAllTranslations(
  tenant: string,
  locale: string
): Promise<Record<string, Record<string, string>>> {
  const namespaces: Record<string, Record<string, string>> = {};

  let lastKey: any = undefined;
  do {
    const result = await client.send(
      new QueryCommand({
        TableName: TABLE,
        KeyConditionExpression: 'PK = :pk',
        ExpressionAttributeValues: { ':pk': `${tenant}#${locale}` },
        ExclusiveStartKey: lastKey,
      })
    );

    for (const item of result.Items || []) {
      const [namespace, ...keyParts] = (item.SK as string).split('#');
      const key = keyParts.join('#');
      if (!namespaces[namespace]) namespaces[namespace] = {};
      namespaces[namespace][key] = item.value;
    }

    lastKey = result.LastEvaluatedKey;
  } while (lastKey);

  return namespaces;
}
Enter fullscreen mode Exit fullscreen mode

Integración con Next.js

// src/lib/translations/server.ts
import { unstable_cache, revalidateTag } from 'next/cache';
import { getTranslationsForNamespace } from './repository';

export const getTranslations = (tenant: string) =>
  unstable_cache(
    async (locale: string, namespace: string) => {
      return getTranslationsForNamespace(tenant, locale, namespace);
    },
    ['translations', tenant],
    {
      revalidate: 60,
      tags: [`translations-${tenant}`],
    }
  );

export function invalidateTranslations(tenant: string) {
  revalidateTag(`translations-${tenant}`);
}

export interface Translator {
  t: (key: string, values?: Record<string, string | number>) => string;
}

export async function createTranslator(
  tenant: string,
  locale: string,
  namespace: string
): Promise<Translator> {
  const dict = await getTranslations(tenant)(locale, namespace);

  return {
    t(key, values) {
      const template = dict[key] ?? key;
      if (!values) return template;
      return Object.entries(values).reduce(
        (acc, [k, v]) => acc.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)),
        template
      );
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Uso en Server Components

// src/app/[locale]/products/page.tsx
import { createTranslator } from '@/lib/translations/server';
import { headers } from 'next/headers';

export default async function ProductsPage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const tenant = (await headers()).get('x-tenant-id') || 'default';

  const t = await createTranslator(tenant, locale, 'products');

  return (
    <main>
      <h1>{t.t('page.title')}</h1>
      <p>{t.t('page.description', { count: 42 })}</p>
      <button>{t.t('actions.viewAll')}</button>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cuando page.description vale "Mostrando {count} productos", se renderiza "Mostrando 42 productos". Simple, sin librería externa.

Uso en Client Components

Para componentes cliente necesito pasar las traducciones como props o usar un provider:

// src/components/TranslationProvider.tsx
'use client';

import { createContext, useContext, ReactNode } from 'react';

const TranslationContext = createContext<Record<string, string>>({});

export function TranslationProvider({
  translations,
  children,
}: {
  translations: Record<string, string>;
  children: ReactNode;
}) {
  return (
    <TranslationContext.Provider value={translations}>
      {children}
    </TranslationContext.Provider>
  );
}

export function useTranslation() {
  const dict = useContext(TranslationContext);
  return {
    t: (key: string, values?: Record<string, string | number>) => {
      const template = dict[key] ?? key;
      if (!values) return template;
      return Object.entries(values).reduce(
        (acc, [k, v]) => acc.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)),
        template
      );
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Y lo hidrato desde el Server Component:

// src/app/[locale]/layout.tsx
import { getTranslations } from '@/lib/translations/server';
import { TranslationProvider } from '@/components/TranslationProvider';

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const tenant = 'default';
  const globalDict = await getTranslations(tenant)(locale, 'global');

  return (
    <html lang={locale}>
      <body>
        <TranslationProvider translations={globalDict}>
          {children}
        </TranslationProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Panel admin para marketing

// src/app/admin/translations/page.tsx
'use client';

import { useState, useEffect } from 'react';

export default function TranslationsAdmin() {
  const [locale, setLocale] = useState('es-CO');
  const [namespace, setNamespace] = useState('common');
  const [items, setItems] = useState<Array<{ key: string; value: string }>>([]);
  const [dirty, setDirty] = useState<Record<string, string>>({});

  useEffect(() => {
    fetch(`/api/translations?locale=${locale}&namespace=${namespace}`)
      .then((r) => r.json())
      .then((data) => {
        setItems(
          Object.entries(data).map(([key, value]) => ({
            key,
            value: value as string,
          }))
        );
      });
  }, [locale, namespace]);

  async function saveChanges() {
    await fetch('/api/translations/batch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        locale,
        namespace,
        changes: dirty,
      }),
    });
    setDirty({});
    alert('Guardado. Los cambios aparecen en producción en 60 segundos.');
  }

  return (
    <div className="p-6">
      <div className="flex gap-4 mb-6">
        <select
          value={locale}
          onChange={(e) => setLocale(e.target.value)}
          className="border rounded px-3 py-2"
        >
          <option value="es-CO">Español (Colombia)</option>
          <option value="es-MX">Español (México)</option>
          <option value="en-US">English (US)</option>
          <option value="pt-BR">Português (Brasil)</option>
        </select>

        <select
          value={namespace}
          onChange={(e) => setNamespace(e.target.value)}
          className="border rounded px-3 py-2"
        >
          <option value="common">Common</option>
          <option value="products">Products</option>
          <option value="checkout">Checkout</option>
          <option value="emails">Emails</option>
        </select>

        <button
          onClick={saveChanges}
          disabled={Object.keys(dirty).length === 0}
          className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          Guardar {Object.keys(dirty).length} cambios
        </button>
      </div>

      <table className="w-full border-collapse">
        <thead>
          <tr className="border-b">
            <th className="text-left py-2 w-1/3">Key</th>
            <th className="text-left py-2">Valor</th>
          </tr>
        </thead>
        <tbody>
          {items.map(({ key, value }) => (
            <tr key={key} className="border-b hover:bg-gray-50">
              <td className="py-2 font-mono text-sm text-gray-600">{key}</td>
              <td className="py-2">
                <input
                  type="text"
                  defaultValue={value}
                  onChange={(e) => {
                    setDirty({ ...dirty, [key]: e.target.value });
                  }}
                  className="w-full border rounded px-2 py-1"
                />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

API con invalidación

// src/app/api/translations/batch/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { setTranslation } from '@/lib/translations/repository';
import { invalidateTranslations } from '@/lib/translations/server';
import { getServerSession } from 'next-auth';

export async function POST(req: NextRequest) {
  const session = await getServerSession();
  if (!session?.user?.email) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { locale, namespace, changes } = await req.json();
  const tenant = 'default';

  for (const [key, value] of Object.entries(changes)) {
    await setTranslation(tenant, locale, namespace, key, value as string, session.user.email);
  }

  invalidateTranslations(tenant);

  return NextResponse.json({ updated: Object.keys(changes).length });
}
Enter fullscreen mode Exit fullscreen mode

Comparativa con alternativas

Aspecto JSON files i18next + API Crowdin Mi approach
Edit sin redeploy No
Costo mensual 0 0 Desde 40 USD 1-5 USD
Latencia edit → prod 30 min 5 min 10 min 60 segundos
Historial de cambios git Custom Sí (inline)
Multi-tenant aislado Por folder Custom Enterprise Nativo
Traducción asistida No No No

Crowdin gana en features (memoria de traducción, assistencia IA). Yo gano en costo, control y velocidad para un caso simple.

Lo que aprendí

1. On-demand revalidation es crítico.
Sin revalidateTag, el TTL de 60s dicta la latencia. Con revalidación explícita, el cambio se propaga al primer request después de la llamada a revalidateTag. Combinado con edge CDN, el cambio llega globalmente en 10-15 segundos.

2. DynamoDB Query es rápido pero tiene latencia.
Cada Query tarda 10-30ms. Para 10 namespaces en la misma página, son 300ms acumulados sin cache. El unstable_cache de Next me salva: después del primer usuario, el resto paga 1-2ms de cache hit.

3. JSON files como fallback.
Exporto un snapshot semanal de DynamoDB a JSON files en public/fallback-translations/. Si DynamoDB está caído y Next.js no puede leer, cargo los JSON. La app sigue funcionando con traducciones "stale" pero visibles.

4. Pluralization es tema aparte.
Mi función t() simple no maneja "1 producto" vs "2 productos". Para eso agregué reglas CLDR:

function plural(count: number, singular: string, plural: string) {
  return count === 1 ? singular : plural;
}
t.t('products.count', { count: 5, label: plural(5, 'producto', 'productos') });
Enter fullscreen mode Exit fullscreen mode

Feo pero funciona. Para idiomas con plurales complejos (ruso, árabe) usé Intl.PluralRules.

5. Los keys en código son más fáciles que los valores default.
Algunos frameworks (i18next) usan el texto inglés como key. t('Welcome back, {name}'). Yo uso keys abstractos: t('home.greeting'). Ventaja: cambiar "Welcome back" a "Hi there" es cambiar el valor, no refactorizar 200 archivos. Desventaja: los keys no se autoexplican leyendo el código. Trade-off que acepto.

Cuándo NO usar esto

Si tu app tiene pocos textos y no cambian, JSON files en git son más simples. No metas DynamoDB por el gusto.

Si tu equipo de traducción es grande y necesita memoria de traducción o TM, Crowdin/Lokalise tienen features que no vale reinventar.

Si sirves idiomas RTL (árabe, hebreo), asegúrate que tu layout soporta dir="rtl". Los textos son solo parte de la i18n. Mi sistema no resuelve esto.


El próximo artículo cubre rate limiting en edge con CloudFront y DynamoDB. Cómo defender tu app de bots abusivos sin agregar latencia a usuarios reales.

Top comments (0)