Get Translation Bundle
Full APPROVED translation bundle for a locale. Flat map keyed by "namespace.key". ETag + If-None-Match for conditional GET, optional ?v=N for 1-year immutable cache.
GET /api/v1/translations/:locale โ ๐ Public ยท Rate limit: 60 req / minute
Returns every APPROVED translation for :locale as a single flat key-value map. Keys are formatted <namespace>.<key> (e.g. auth.login.title or common.button.save); values are the raw translated strings. DRAFT translations never leak through this endpoint.
Three-layer cache + 304 short-circuit. L1 = in-process map (60s), L2 = Redis (5 min), L3 = HTTP CDN (Cache-Control header). On top of all that, the response carries an ETag of the form "i18n-<locale>-<version>". Send the same value back in If-None-Match and the server returns 304 Not Modified with no body โ your existing bundle in client memory is still valid.
Pin the version with ?v=N to get a 1-year immutable cache. When you read versions[locale] from GET /locales, pass the same number as ?v=N here. If the server's current version matches, the response carries Cache-Control: public, max-age=31536000, immutable โ the CDN and the browser will never re-validate this URL until you change N. Without ?v, the server sends public, max-age=60, stale-while-revalidate=300 (short cache + revalidation).
APPROVED-only filter. The Prisma query is where: { languageCode, status: 'APPROVED' }. Translations in DRAFT status are excluded entirely โ they exist only in the admin tooling. A missing key does not throw: clients should fall back to the default locale's value (or to the key itself) when a lookup misses.
Unknown locale returns an empty bundle, not 404. The server doesn't validate that :locale exists in the Language table โ it just runs the WHERE clause and gets zero rows. If you GET /api/v1/translations/zz, you get 200 OK with {} and a version of 0. Validate locale codes client-side against GET /locales before issuing the bundle fetch.
Request
Path parameters
| Param | Type | Required | Notes |
|---|---|---|---|
locale | string | โ | ISO 639-1 code from GET /locales (e.g. en, tr). Not validated server-side โ unknown codes return an empty bundle. |
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
v | integer | โ | The expected version (from versions[locale] in GET /locales). When it matches the current server version, response gets the 1-year immutable cache header instead of the short SWR header. Mismatch = ignored, short cache used. |
Headers
| Header | Required | Notes |
|---|---|---|
If-None-Match | optional | Pass the previous response's ETag value verbatim. On match, the server returns 304 with no body. |
No body, no auth required.
Response
200 OK โ Record<string, string> (flat map)
The body is not wrapped in the standard { success, data } envelope โ this endpoint returns the bundle directly so it can be consumed by mobile clients and SSR layers without unwrap boilerplate.
{
"auth.login.title": "Sign in to your account",
"auth.login.button": "Sign in",
"common.button.save": "Save",
"common.button.cancel": "Cancel",
"errors.invalid_credentials": "Invalid email or password"
}304 Not Modified
Returned when If-None-Match matches the current ETag. No body.
Response headers (always)
| Header | Value | Notes |
|---|---|---|
ETag | "i18n-<locale>-<version>" | Use this on the next request as If-None-Match. |
Cache-Control | public, max-age=31536000, immutable OR public, max-age=60, stale-while-revalidate=300 | Immutable variant only when ?v=N was sent and matched. |
Errors
| HTTP | Reason |
|---|---|
429 | Rate limit exceeded (60 req/min โ but you should hit the CDN for almost every request). |
No other documented error paths. Unknown :locale returns 200 + empty {}.
Side effects
cache.getVersion(locale)โ readsi18n:version:<locale>from Redis. Returns0if Redis cache is unavailable.- Build ETag =
"i18n-<locale>-<version>". IfIf-None-Match === ETagโres.status(304); return(no body, no further work). translationService.getBundle(locale)โ multi-layer cache lookup:- L1 (in-process map, 60s TTL) โ if hit, return.
- L2 (Redis, key
i18n:bundle:<locale>, 5 min TTL) โ if hit, promote to L1, return. - On miss:
prisma.translation.findMany({ where: { languageCode: locale, status: 'APPROVED' }, include: { translationKey: { select: { namespace, key } } } }).
- Project to flat map: each row becomes
<namespace>.<key>: <value>. - Write the map to L2 (Redis, 5 min) and L1 (60s).
- Set
ETagheader. SetCache-Controlbased on whether?v=<currentVersion>was sent (immutable) or not (60s + 300s SWR). - Return the bundle. No DB writes.
Cache invalidation (write-side, not part of this endpoint)
When an admin updates / approves / deletes a translation, i18n-cache.service.ts:invalidateLocale(locale) runs:
- Clear L1 entries for the locale.
- DELETE Redis L2 keys (
i18n:bundle:<locale>plus everyi18n:bundle:<locale>:<namespace>). - INCR Redis
i18n:version:<locale>โ bumps the integer inversions[locale]returned byGET /locales. - PUBLISH
{ locale }to thei18n:invalidatechannel โ every other api-core instance subscribed to it clears its in-process L1 for that locale.
Code samples
curl -i https://api.bio.re/api/v1/translations/en
# ETag: "i18n-en-42"
# Cache-Control: public, max-age=60, stale-while-revalidate=300# When you already know version=42 from GET /locales, lock the cache:
curl -i 'https://api.bio.re/api/v1/translations/en?v=42'
# ETag: "i18n-en-42"
# Cache-Control: public, max-age=31536000, immutablecurl -i https://api.bio.re/api/v1/translations/en \
-H 'If-None-Match: "i18n-en-42"'
# HTTP/1.1 304 Not Modified (no body)type TranslationBundle = Record<string, string>;
async function getBundle(
locale: string,
opts?: { version?: number; ifNoneMatch?: string },
): Promise<{ bundle: TranslationBundle | null; etag: string | null; status: 200 | 304 }> {
const url = new URL(`https://api.bio.re/api/v1/translations/${locale}`);
if (opts?.version != null) url.searchParams.set('v', String(opts.version));
const headers: Record<string, string> = {};
if (opts?.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
const res = await fetch(url, { headers });
const etag = res.headers.get('ETag');
if (res.status === 304) {
return { bundle: null, etag, status: 304 };
}
if (!res.ok) {
throw new Error(`Bundle fetch failed: ${res.status}`);
}
return { bundle: (await res.json()) as TranslationBundle, etag, status: 200 };
}import { useQuery } from '@tanstack/react-query';
// Pair this hook with useLocales(). When versions[locale] changes upstream,
// the queryKey changes too โ TanStack treats it as a fresh cache entry.
export function useTranslationBundle(locale: string, version: number) {
return useQuery({
queryKey: ['i18n', 'bundle', locale, version],
queryFn: async () => {
const url = `/api/v1/translations/${locale}?v=${version}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Bundle fetch failed: ${res.status}`);
return (await res.json()) as Record<string, string>;
},
enabled: version > 0, // skip while version is unknown
staleTime: Infinity, // version-pinned URL โ never stales until version bumps
gcTime: 60 * 60_000, // keep prior versions around 1h for back-nav
});
}Try it
Path Parameters
ISO 639-1 locale code
Query Parameters
Version number โ if matches current version, returns immutable cached response
Header Parameters
ETag from previous response for conditional GET (304 on match)
Response Body
application/json
curl -X GET "https://loading/api/v1/translations/en"{}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/i18n/public-i18n.controller.ts | 60โ92 (getBundle โ ETag, ?v= cache pinning, SWR fallback) |
| Service (bundle build) | apps/api-core/src/modules/i18n/translation.service.ts | 301โ336 (getBundle โ APPROVED-only filter, <namespace>.<key> flat map) |
| Cache layers | apps/api-core/src/modules/i18n/i18n-cache.service.ts | 70โ89 (getBundle / setBundle), 8โ9 (TTL constants), 189โ219 (L1+L2 lookup) |
| Versioning | apps/api-core/src/modules/i18n/i18n-cache.service.ts | 93โ110 (getVersion/incrementVersion), 124โ148 (invalidateLocale โ INCR + PUBLISH) |
| Prisma model | packages/prisma/prisma/schema.prisma | Translation lines 2417โ2434 (filter: languageCode = ? AND status = 'APPROVED'); TranslationKey lines 2400โ2415 (provides namespace, key) |
List Locales
Active language catalog plus a per-locale version map. Sanitized output โ only code, name, nativeName, isRtl, isDefault. Use the version map to decide when to refetch translation bundles.
Get Namespace Bundle
Single-namespace translation bundle. Like the full bundle but scoped to one namespace and with non-prefixed keys. Same ETag + version-pinning semantics.