tRPC
Localized error messages and i18n context in tRPC procedures
The central pattern for i18n in tRPC is to detect the locale in createContext and return both locale and t (Translator). Every procedure gets localized strings with ctx.t("key"); TRPCError.message can be set directly from ctx.t(...).
| Adapter | Runtime | In createContext |
|---|---|---|
fetchRequestHandler | Edge, CF Workers, Supabase, Next.js Route Handler | req.headers used directly |
createExpressMiddleware | Node.js / Express | fromNodeHeaders(req.headers) via bridge |
Fetch Adapter (Edge / CF Workers / Supabase)
Recommended pattern for environments using Web Standards Headers:
Singleton
import { createServerI18n } from "@better-i18n/server";
export const i18n = createServerI18n({
project: "my-org/api",
defaultLocale: "en",
});Context
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { i18n } from "./i18n.js";
import type { Translator } from "@better-i18n/server";
export async function createContext({ req }: FetchCreateContextFnOptions) {
// req.headers is Web Standards Headers — no bridge needed
const locale = await i18n.detectLocaleFromHeaders(req.headers);
const t = await i18n.getTranslator(locale);
return { locale, t };
}
export type Context = Awaited<ReturnType<typeof createContext>>;Init
import { initTRPC } from "@trpc/server";
import type { Context } from "./context.js";
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;Router
Pass ctx.t(...) directly to TRPCError.message:
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, publicProcedure } from "./init.js";
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await db.users.findById(input.id);
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: ctx.t("errors.notFound"), // "Not found" localized per client
});
}
return { user, locale: ctx.locale };
}),
createPost: publicProcedure
.input(z.object({ title: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const existing = await db.posts.findByTitle(input.title);
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: ctx.t("errors.postAlreadyExists"),
});
}
return db.posts.create(input);
}),
});
export type AppRouter = typeof appRouter;Handler (Cloudflare Worker / Supabase Edge / Deno)
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "./trpc/router.js";
import { createContext } from "./trpc/context.js";
export default {
fetch: (req: Request) =>
fetchRequestHandler({
endpoint: "/trpc",
req,
router: appRouter,
createContext,
}),
};Express / Fastify (Node.js Adapter)
In Express and Fastify, req.headers is Node.js IncomingHttpHeaders — use fromNodeHeaders to bridge it to Web Standards Headers.
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import { fromNodeHeaders } from "@better-i18n/server/node";
import { i18n } from "./i18n.js";
export async function createContext({ req }: CreateExpressContextOptions) {
const headers = fromNodeHeaders(req.headers); // IncomingHttpHeaders → Headers
const locale = await i18n.detectLocaleFromHeaders(headers);
const t = await i18n.getTranslator(locale);
return { locale, t };
}import express from "express";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter } from "./trpc/router.js";
import { createContext } from "./trpc/context.node.js";
const app = express();
app.use("/trpc",
createExpressMiddleware({ router: appRouter, createContext })
);Lazy Translator — localeProcedure (Optional)
If some procedures don't require i18n, limit getTranslator calls to only those that do:
getTranslator uses TtlCache, so repeated calls for the same locale return from memory — no CDN round-trip. Even so, localeProcedure keeps the architecture cleaner under high traffic.
import { initTRPC } from "@trpc/server";
import type { Context } from "./context.js";
import { i18n } from "./i18n.js";
import type { Translator } from "@better-i18n/server";
const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure;
// Middleware that resolves the Translator only when needed
export const localeProcedure = publicProcedure.use(async (opts) => {
const translator = await i18n.getTranslator(opts.ctx.locale);
return opts.next({ ctx: { ...opts.ctx, t: translator } });
});Adjust the Context type for this pattern:
// Context: locale is always present, t is optional (added by localeProcedure)
export interface BaseContext {
locale: string;
t?: Translator; // becomes required in the localeProcedure chain
}// Only procedures in the localeProcedure chain receive ctx.t
export const appRouter = router({
healthCheck: publicProcedure.query(() => ({ ok: true })), // no i18n cost
getUser: localeProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
// ctx.t is guaranteed here
const user = await db.users.findById(input.id);
if (!user) throw new TRPCError({ code: "NOT_FOUND", message: ctx.t("errors.notFound") });
return user;
}),
});