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 anETag, and an unchanged namespace revalidates as a bodyless304.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.
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.