Skip to content
better-i18n.com
On this page

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({
  projectId: "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) #

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:

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:

TypeScript
// Context: locale is always present, t is optional (added by localeProcedure)
export interface BaseContext {
  locale: string;
  t?: Translator; // becomes required in the localeProcedure chain
}
TypeScript
// 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;
    }),
});
  • Getting Started — Singleton setup and basic usage.
  • Hono — Hono middleware with full TypeScript support.
  • Express & Fastify — Node.js HTTP adapter — bridge with fromNodeHeaders.
  • API ReferencecreateServerI18n, ServerI18n and all exports.