Skip to main content

Proxy through your backend

By default your apps talk to https://api.i18n-keyless.com directly, and the device cache + 304 revalidation is the scaling story. For very large fleets you can go one step further: put your own server between your apps and the API. Your server becomes the only client we see — your infrastructure absorbs the fan-out, and your API key never ships inside the app binary.

How it works

The SDK config accepts an API_URL. Point it at your server, and your server speaks the same three-route wire format to the real API:

your apps  ──►  your server (API_URL)  ──►  api.i18n-keyless.com
many one
  • GET /translate/:lang — the dictionary reads. Cache them: they carry an ETag, and an unchanged namespace revalidates as a bodyless 304.
  • POST /translate — new strings. Forward as-is.
  • POST /translate/last-used-translations — usage analytics. Forward as-is.

Client side

I18nKeyless.init({
API_URL: "https://your-server.com/i18n", // your proxy — the API key stays server-side
languages: { primary: "fr", supported: ["fr", "en", "es"] },
storage: localStorage,
});

No other client change: the SDK builds the same paths under your API_URL.

Server side (Express, ~40 lines)

import express from "express";

const app = express();
app.use(express.json());

const API = "https://api.i18n-keyless.com";
const KEY = process.env.I18N_KEYLESS_API_KEY!; // never shipped to clients

// In-memory cache of dictionary payloads, revalidated upstream with If-None-Match.
const cache = new Map(); // url -> { etag, body }

app.get("/i18n/translate/:lang", async (req, res) => {
const url = `${API}/translate/${req.params.lang}${
req.query.namespace ? `?namespace=${encodeURIComponent(String(req.query.namespace))}` : ""
}`;
const cached = cache.get(url);
const upstream = await fetch(url, {
headers: {
Authorization: `Bearer ${KEY}`,
Version: req.headers.version as string,
...(cached ? { "If-None-Match": cached.etag } : {}),
},
});
if (upstream.status === 304 && cached) return res.json(cached.body); // fresh from memory
let body;
try {
body = await upstream.json();
} catch {
return res.status(502).json({ ok: false, error: "upstream error", data: { translations: {} } });
}
const etag = upstream.headers.get("etag");
if (upstream.ok && etag) cache.set(url, { etag, body });
res.status(upstream.status).json(body);
});

for (const path of ["/i18n/translate", "/i18n/translate/last-used-translations"]) {
app.post(path, async (req, res) => {
const upstream = await fetch(`${API}${path.replace("/i18n", "")}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
Version: req.headers.version as string,
},
body: JSON.stringify(req.body),
});
res.status(upstream.status).json(await upstream.json());
});
}

Forward the Version header untouched: the API uses it to answer each client in its own language-code dialect.

note

This exact recipe is exercised by an integration test in the API's own suite (proxy-mode.test.ts), against the real endpoints — if the wire format ever changes, that test fails before this page can go stale.

By design, usage analytics count your proxy as one device — which it is, from the API's point of view. That is the whole point of the pattern: a million devices behind your server become one client on ours. If you want per-device numbers, your proxy is the place to measure them.

What you gain

  • One upstream client. A million devices become one server (or a few) on our side; dictionary reads are served from your process memory and revalidated with 304s.
  • Key hygiene. The API key lives in your server env, not in a shippable bundle.
  • Your rules. Add your own auth, per-user throttling, or logging in the proxy.

What you keep

Every guarantee from Reliability & scale still applies on the client: local lookups, source-text fallback, timeouts and retries — the SDK does not care whose URL serves the wire format.

SSR note

If your backend also renders pages, you don't need the proxy for that: use i18n-keyless-node directly on the server (awaitForTranslation) — it keeps dictionaries in process memory and revalidates them the same way. The proxy pattern is for client fleets; the node SDK is for your own server-side rendering.