Localization
t() returns one of your backend's sentences in the language of the request that is running. You write the sentence in your project's source language; the sentence itself is the key. palbase build collects every t("…") into palbase/strings/ — one file per language — and palbase push ships it with the deploy. Each request is then answered in the language its Accept-Language asks for, falling back to the sentence as you wrote it.
Quick example
// modules/billing/billing.controller.ts
import { Controller, Get, Module, QueryParams, t, z } from "@palbase/backend";
export const ChargeQuery = z.object({ amount: z.string() });
export type ChargeQuery = z.infer<typeof ChargeQuery>;
export const Charge = z.object({ message: z.string() });
export type Charge = z.infer<typeof Charge>;
@Controller("/billing")
export class BillingController {
@Get("/charge")
charge(@QueryParams(ChargeQuery) q: ChargeQuery): Charge {
return { message: t("Kartınız reddedildi ({{amount}} ₺)", { amount: q.amount }) };
}
}
@Module({ controllers: [BillingController] })
export class BillingModule {}
The key is the sentence exactly as written, placeholder and all — Kartınız reddedildi ({{amount}} ₺) — never the text with a value in it. The value arrives with the call and is put in after the translation is chosen, so one key serves every amount.
palbase build --source tr # once: starts palbase/strings/
palbase build --add en --add de # a language: its file, every sentence missing
palbase build --translate # fills the missing ones with AI, as needs_review
palbase push
GET /billing/charge?amount=50 with Accept-Language: en now gets {"message":"Your card was declined (50 ₺)"} and Content-Language: en; one asking for a language the table does not have gets the sentence as written, and Content-Language: tr.
Writing sentences
- The first argument is the key AND the source-language text. Write it as a string literal (or a template literal with no
${…}) in a directt(…)call so the build can collect it. Anything else still works at runtime but is served in the source language, and the build names the place. {{name}}and{{ name }}are replaced withvars.name. Nothing is escaped — the text goes into JSON, not HTML. A missing,nullorundefinedvalue becomes the empty string.- Outside a request — at module load, in a job — there is no caller's language and
t()returns the source text.
The table
palbase/strings/
_meta.json { "version": 2, "source": "tr" }
en.json
de.json
{
"Kartınız reddedildi ({{amount}} ₺)": {
"value": "Your card was declined ({{amount}} ₺)",
"state": "translated"
}
}
_meta.jsonnames the language your sentences are written in; it is what makes the directory a table.- Every other language has one file, named by its tag exactly as the stack writes it (
en.json,en-US.json) —en-us.jsonis refused with the name it should have. The source language has no file: its text is the key. stateistranslated,needs_reviewormissing.missingnever answers; the other two do.- Commit the directory. The stack validates it on every deploy and refuses a table that breaks these rules, naming the file. Other files in the directory (
.DS_Store, aREADME.md) are not part of the table.
Languages
- Add one:
palbase build --add frwritespalbase/strings/fr.jsonwith every sentencemissing. Creatingfr.jsoncontaining{}yourself and building does the same. - Remove one: delete its file.
- Keep them in step: every
palbase buildadds new sentences to every file asmissing, removes the ones your code no longer uses (and prints them), and never changes a translation you wrote. A translation whose{{…}}placeholders differ from its sentence's is printed as a warning. - The source language cannot be changed with
--source: every key is a sentence in it.
Translating with AI
palbase build --translate sends every missing sentence to your project's stack, which translates it with your own OpenAI key. The key never leaves the stack, and the stack stores nothing.
palbase secret set OPENAI_API_KEY --stdin < openai-key.txt # once per environment
palbase build --translate
- A translation lands as
needs_review: it is served at once, and marked so a person can read it and settranslated. - A translation that drops or invents a
{{placeholder}}, or comes back empty, is not written — the sentence staysmissingin that language and the build names it. - Sentences go in batches, and each batch is written before the next is sent, so a failure part-way keeps what was already translated. The build then stops, says why and exits non-zero.
Which language a request gets
The request's Accept-Language, in the order of its q values; after each tag its base language (en-US → en); last, the source language. The first language with an answer wins — the same chain the platform uses for everything it says itself. A JSON response whose handler called t() says which language it is in with Content-Language and Vary: Accept-Language.
Versions
- The directory needs
@palbase/backend41.3.0 or later: the stack your project runs is the one your installed SDK names. With an older SDK the build keeps the single filepalbase/strings.jsonand refuses--addand--translate, naming the upgrade. - A
palbase/strings.jsonfrom an earlier CLI moves intopalbase/strings/on the first build; commit the new directory and the deletion. - Every machine and CI job that pushes the project needs a palbase CLI that carries the directory (0.71.0 or later). The stack refuses a push from an older one rather than ship a release that answers in one language, and a push refuses a stack that does not read the directory.
Testing a handler that translates
// modules/billing/billing.controller.test.ts
import { expect, it } from "bun:test";
import { loadStringsTable, withServices } from "@palbase/backend/test";
import { BillingController } from "./billing.controller";
it("answers in the caller's language", async () => {
const strings = await loadStringsTable(process.cwd());
const body = withServices({}, () => new BillingController().charge({ amount: "50" }), { strings, acceptLanguage: "en" });
expect(body.message).toBe("Your card was declined (50 ₺)");
});
loadStringsTable(root) reads the same table the stack serves; withServices(…, { strings, acceptLanguage }) runs your code as a request with that Accept-Language would run.