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
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
Tres razones para esta estructura:
Partition key compuesta:
tenant#localepermite multi-tenancy con aislamiento perfecto. Acme en español está en una partición, Acme en inglés en otra.Sort key jerárquica:
namespace#keypermite queries eficientes por namespace (begins_with(SK, "common#")) sin escanear toda la partición.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;
}
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
);
},
};
}
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>
);
}
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
);
},
};
}
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>
);
}
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>
);
}
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 });
}
Comparativa con alternativas
| Aspecto | JSON files | i18next + API | Crowdin | Mi approach |
|---|---|---|---|---|
| Edit sin redeploy | No | Sí | Sí | Sí |
| 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í | Sí (inline) |
| Multi-tenant aislado | Por folder | Custom | Enterprise | Nativo |
| Traducción asistida | No | No | Sí | 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') });
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)