BIO.RE
i18n

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

ParamTypeRequiredNotes
localestringโœ“ISO 639-1 code from GET /locales (e.g. en, tr). Not validated server-side โ€” unknown codes return an empty bundle.

Query parameters

ParamTypeDefaultNotes
vintegerโ€”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

HeaderRequiredNotes
If-None-MatchoptionalPass 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)

HeaderValueNotes
ETag"i18n-<locale>-<version>"Use this on the next request as If-None-Match.
Cache-Controlpublic, max-age=31536000, immutable OR public, max-age=60, stale-while-revalidate=300Immutable variant only when ?v=N was sent and matched.

Errors

HTTPReason
429Rate 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

  1. cache.getVersion(locale) โ€” reads i18n:version:<locale> from Redis. Returns 0 if Redis cache is unavailable.
  2. Build ETag = "i18n-<locale>-<version>". If If-None-Match === ETag โ†’ res.status(304); return (no body, no further work).
  3. 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 } } } }).
  4. Project to flat map: each row becomes <namespace>.<key>: <value>.
  5. Write the map to L2 (Redis, 5 min) and L1 (60s).
  6. Set ETag header. Set Cache-Control based on whether ?v=<currentVersion> was sent (immutable) or not (60s + 300s SWR).
  7. 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 every i18n:bundle:<locale>:<namespace>).
  • INCR Redis i18n:version:<locale> โ€” bumps the integer in versions[locale] returned by GET /locales.
  • PUBLISH { locale } to the i18n:invalidate channel โ€” 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, immutable
curl -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

GET
/api/v1/translations/{locale}

Path Parameters

locale*string

ISO 639-1 locale code

Query Parameters

v?number

Version number โ€” if matches current version, returns immutable cached response

Header Parameters

if-none-match?string

ETag from previous response for conditional GET (304 on match)

Response Body

application/json

curl -X GET "https://loading/api/v1/translations/en"
{}
Empty

Source

SourcePathLines
Controllerapps/api-core/src/modules/i18n/public-i18n.controller.ts60โ€“92 (getBundle โ€” ETag, ?v= cache pinning, SWR fallback)
Service (bundle build)apps/api-core/src/modules/i18n/translation.service.ts301โ€“336 (getBundle โ€” APPROVED-only filter, <namespace>.<key> flat map)
Cache layersapps/api-core/src/modules/i18n/i18n-cache.service.ts70โ€“89 (getBundle / setBundle), 8โ€“9 (TTL constants), 189โ€“219 (L1+L2 lookup)
Versioningapps/api-core/src/modules/i18n/i18n-cache.service.ts93โ€“110 (getVersion/incrementVersion), 124โ€“148 (invalidateLocale โ€” INCR + PUBLISH)
Prisma modelpackages/prisma/prisma/schema.prismaTranslation lines 2417โ€“2434 (filter: languageCode = ? AND status = 'APPROVED'); TranslationKey lines 2400โ€“2415 (provides namespace, key)

On this page