Better I18NBetter I18N
Server SDK

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(...).

AdapterRuntimeIn createContext
fetchRequestHandlerEdge, CF Workers, Supabase, Next.js Route Handlerreq.headers used directly
createExpressMiddlewareNode.js / ExpressfromNodeHeaders(req.headers) via bridge

Fetch Adapter (Edge / CF Workers / Supabase)

Recommended pattern for environments using Web Standards Headers:

Singleton

src/trpc/i18n.ts
import { createServerI18n } from "@better-i18n/server";

export const i18n = createServerI18n({
  project: "my-org/api",
  defaultLocale: "en",
});

Context

src/trpc/context.ts
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

src/trpc/init.ts
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:

src/trpc/router.ts
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)

src/handler.ts
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.

src/trpc/context.node.ts
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 };
}
src/app.ts
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.

src/trpc/init.ts
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;
    }),
});

On this page