Tutorials

How to Localize Better Auth: Error Messages, UI, and Emails (Complete Guide)

Ali Osman Delismen
Ali Osman Delismen
·15 min read
Share
How to Localize Better Auth: Error Messages, UI, and Emails (Complete Guide)

Better Auth is quickly becoming the go-to authentication framework for TypeScript apps. But when your users speak Turkish, German, or Japanese, showing "Invalid email or password" isn't good enough.

This guide covers everything you need to localize Better Auth — from translating 48 error codes to setting up cookie-based locale detection, with zero redeployment needed when you add new languages.

Better i18n is localization infrastructure for modern apps. Free to get started — create your account.


The Three Layers of Auth Localization

Better Auth has three distinct surfaces that need translation:

LayerWhat Gets TranslatedWhere It Happens
Server errorsINVALID_EMAIL_OR_PASSWORD → "Geçersiz e-posta veya şifre"Server-side plugin
UI components"Sign in" → "Giriş Yap"Client-side AuthLocalization
Email templatesVerification emails, password resetsEmail send callbacks

Most guides only cover one layer. This guide covers all three — because your users interact with all three.


All 48 Better Auth Error Codes (with Translations)

Before we get into the setup, here's the complete reference of every error code Better Auth can return. These are the keys you'll need to translate:

Authentication (8 keys)

Error CodeEnglishTurkishGerman
INVALID_EMAIL_OR_PASSWORDInvalid email or passwordGeçersiz e-posta veya şifreUngültige E-Mail oder Passwort
INVALID_PASSWORDInvalid passwordGeçersiz şifreUngültiges Passwort
INVALID_EMAILInvalid emailGeçersiz e-postaUngültige E-Mail
INVALID_TOKENInvalid tokenGeçersiz tokenUngültiges Token
TOKEN_EXPIREDToken expiredToken süresi dolduToken abgelaufen
EMAIL_NOT_VERIFIEDEmail not verifiedE-posta doğrulanmadıE-Mail nicht verifiziert
EMAIL_ALREADY_VERIFIEDEmail is already verifiedE-posta zaten doğrulanmışE-Mail bereits verifiziert
EMAIL_MISMATCHEmail mismatchE-posta uyuşmuyorE-Mail stimmt nicht überein

User Management (8 keys)

Error CodeEnglishTurkish
USER_NOT_FOUNDUser not foundKullanıcı bulunamadı
USER_ALREADY_EXISTSUser already exists.Bu kullanıcı zaten mevcut.
USER_ALREADY_EXISTS_USE_ANOTHER_EMAILUser already exists. Use another email.Bu e-posta adresi zaten kayıtlı. Başka bir e-posta deneyin.
INVALID_USERInvalid userGeçersiz kullanıcı
USER_EMAIL_NOT_FOUNDUser email not foundKullanıcı e-postası bulunamadı
USER_ALREADY_HAS_PASSWORDUser already has a password. Provide that to delete the account.Kullanıcının zaten bir şifresi var. Hesabı silmek için mevcut şifreyi girin.
FAILED_TO_CREATE_USERFailed to create userKullanıcı oluşturulamadı
FAILED_TO_UPDATE_USERFailed to update userKullanıcı güncellenemedi

Session & Password (8 keys)

Error CodeEnglishTurkish
SESSION_EXPIREDSession expired. Re-authenticate to perform this action.Oturum süresi doldu. Bu işlem için yeniden giriş yapın.
SESSION_NOT_FRESHSession is not freshOturum güncel değil
FAILED_TO_CREATE_SESSIONFailed to create sessionOturum oluşturulamadı
FAILED_TO_GET_SESSIONFailed to get sessionOturum bilgisi alınamadı
PASSWORD_TOO_SHORTPassword too shortŞifre çok kısa
PASSWORD_TOO_LONGPassword too longŞifre çok uzun
PASSWORD_ALREADY_SETUser already has a password setKullanıcının zaten bir şifresi var
CREDENTIAL_ACCOUNT_NOT_FOUNDCredential account not foundKimlik bilgisi hesabı bulunamadı

Account, Social & Email Verification (9 keys)

Error CodeEnglishTurkish
ACCOUNT_NOT_FOUNDAccount not foundHesap bulunamadı
SOCIAL_ACCOUNT_ALREADY_LINKEDSocial account already linkedSosyal hesap zaten bağlı
LINKED_ACCOUNT_ALREADY_EXISTSLinked account already existsBağlı hesap zaten mevcut
FAILED_TO_UNLINK_LAST_ACCOUNTYou can't unlink your last accountSon hesap bağlantınızı kaldıramazsınız
PROVIDER_NOT_FOUNDProvider not foundSağlayıcı bulunamadı
ID_TOKEN_NOT_SUPPORTEDid_token not supportedid_token desteklenmiyor
VERIFICATION_EMAIL_NOT_ENABLEDVerification email isn't enabledDoğrulama e-postası etkin değil
EMAIL_CAN_NOT_BE_UPDATEDEmail can not be updatedE-posta adresi güncellenemiyor
FAILED_TO_CREATE_VERIFICATIONUnable to create verificationDoğrulama oluşturulamadı

URL Validation & Security (8 keys)

Error CodeEnglishTurkish
INVALID_ORIGINInvalid originGeçersiz kaynak
INVALID_CALLBACK_URLInvalid callbackURLGeçersiz geri dönüş adresi
INVALID_REDIRECT_URLInvalid redirectURLGeçersiz yönlendirme adresi
INVALID_ERROR_CALLBACK_URLInvalid errorCallbackURLGeçersiz hata yönlendirme adresi
INVALID_NEW_USER_CALLBACK_URLInvalid newUserCallbackURLGeçersiz yeni kullanıcı yönlendirme adresi
MISSING_OR_NULL_ORIGINMissing or null OriginKaynak bilgisi eksik
CALLBACK_URL_REQUIREDcallbackURL is requiredGeri dönüş adresi zorunludur
CROSS_SITE_NAVIGATION_LOGIN_BLOCKEDCross-site navigation login blocked. This request appears to be a CSRF attack.Siteler arası giriş engellendi. Bu istek güvenlik tehdidi olarak algılandı.

Validation (7 keys)

Error CodeEnglishTurkish
VALIDATION_ERRORValidation ErrorDoğrulama hatası
MISSING_FIELDField is requiredBu alan zorunludur
FIELD_NOT_ALLOWEDField not allowed to be setBu alan değiştirilemez
ASYNC_VALIDATION_NOT_SUPPORTEDAsync validation is not supportedAsenkron doğrulama desteklenmiyor
BODY_MUST_BE_AN_OBJECTBody must be an objectİstek gövdesi bir nesne olmalıdır
FAILED_TO_GET_USER_INFOFailed to get user infoKullanıcı bilgileri alınamadı
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIREDPOST method requires deferSessionRefresh to be enabledBu işlem için oturum yapılandırması güncellenmeli

Approach 1: Static Translations (Built-in Plugin)

Better Auth ships an official @better-auth/i18n plugin that accepts a static translation map:

import { betterAuth } from "better-auth";
import { i18n } from "@better-auth/i18n";

export const auth = betterAuth({
  plugins: [
    i18n({
      defaultLocale: "en",
      translations: {
        tr: {
          INVALID_EMAIL_OR_PASSWORD: "Geçersiz e-posta veya şifre",
          USER_NOT_FOUND: "Kullanıcı bulunamadı",
          EMAIL_NOT_VERIFIED: "E-posta doğrulanmadı",
          // ... 45 more keys
        },
        de: {
          INVALID_EMAIL_OR_PASSWORD: "Ungültige E-Mail oder Passwort",
          // ... all keys again
        },
      },
      detection: ["cookie", "header"],
      localeCookie: "locale",
    }),
  ],
});

Pros: Zero dependencies, works out of the box.

Cons:

  • Every translation change requires a code change + redeploy
  • Translations are bundled in your server code — grows linearly with languages
  • No translation management UI — developers edit JSON directly
  • No AI-powered translation — manual work for each new language

This works fine for 2-3 languages. But when you're scaling to 10+ languages or want non-developers to manage translations, you need a different approach.


Better i18n takes a fundamentally different approach: translations live on a CDN, managed from a dashboard, and updated without redeployment.

Step 1: Install the Server SDK

npm install @better-i18n/server

Step 2: Create the i18n Singleton

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

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

Full server SDK docs →

Step 3: Add the Better Auth Provider

// src/auth.ts
import { betterAuth } from "better-auth";
import { createBetterAuthProvider } from "@better-i18n/server/providers/better-auth";
import { i18n } from "./i18n";

export const auth = betterAuth({
  plugins: [
    createBetterAuthProvider(i18n, {
      localeCookie: "locale", // reads user's chosen language from cookie
    }),
  ],
});

That's it on the server. The provider:

  1. Intercepts every Better Auth error response
  2. Reads the user's locale from the locale cookie (falls back to Accept-Language)
  3. Fetches translations from the CDN (cached in-memory for 60 seconds)
  4. Replaces the error message with the translated version

Better Auth integration docs →

Step 4: Persist Locale on the Client

On the client side, use the localeCookie prop so the cookie is written automatically:

// src/App.tsx
import { BetterI18nProvider } from "@better-i18n/use-intl";

function App() {
  return (
    <BetterI18nProvider
      project="your-org/your-project"
      localeCookie  // writes "locale" cookie on every language change
    >
      <YourApp />
    </BetterI18nProvider>
  );
}

The flow:

User picks Turkish → cookie "locale=tr" written
  → Next auth request includes cookie
  → Server reads cookie → fetches Turkish translations from CDN
  → Error message returned in Turkish

Provider docs →

Step 5: Seed Your Translation Keys

The fastest way to seed all 48 keys is with the Better i18n MCP server and an AI agent:

Create all keys from DEFAULT_AUTH_KEYS in the "auth" namespace. Set English values to their defaults, then translate into Turkish, German, Spanish, French, and Japanese.

Or import programmatically:

import { DEFAULT_AUTH_KEYS } from "@better-i18n/server/providers/better-auth";

// DEFAULT_AUTH_KEYS contains all 48 error codes with English defaults
console.log(Object.keys(DEFAULT_AUTH_KEYS).length); // 48

Step 6: Translate from the Dashboard

Go to better-i18n.com, open your project, navigate to the "auth" namespace, and translate. Use the AI Translate button to auto-translate all keys into any language in seconds.

No code change. No redeployment. Translations are live on the CDN within 60 seconds.


Reading Locale in Non-React Code

Your auth client runs at module level — no React hooks available. Use getLocaleCookie():

// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { getLocaleCookie } from "@better-i18n/core";

export const authClient = createAuthClient({
  baseURL: "/api/auth",
  fetchOptions: {
    headers: {
      // Getter reads fresh cookie value on every request
      get "Accept-Language"() {
        return getLocaleCookie("locale") ?? "en";
      },
    },
  },
});

This ensures the server always receives the user's chosen language — not the browser's OS language from Accept-Language.

getLocaleCookie API reference →


Static vs CDN-Based: Full Comparison

Feature@better-auth/i18nbetter-auth-localizationBetter i18n
Translation sourceHardcoded in codeBundled npm packageCDN (runtime)
Add new languageCode change + deployWait for npm updateDashboard — no deploy
Update a translationCode change + deployPR to open-source repoDashboard — live in 60s
Built-in languagesEnglish only31+ languagesAny (AI-translated)
AI translationNoNoYes — one-click
Non-dev can editNoNoYes — dashboard UI
Bundle size impactGrows with languages~50KB per languageZero — fetched at runtime
Locale detectioncookie, header, sessionCustom callbackcookie + Accept-Language
Email template i18nManualNoCDN-based (same system)
MCP / AI agent supportNoNoYes — full MCP server
Free tierN/A (built-in)Open sourceFree to start

Localizing Email Templates

This is the part most guides skip. When Better Auth sends a verification email or password reset, the email template needs to be in the user's language too.

Better Auth provides callbacks for email sending. Combined with Better i18n's server SDK, you can fetch translations at send time:

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

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

export const auth = betterAuth({
  emailAndPassword: {
    sendResetPassword: async ({ user, url }, request) => {
      // Detect locale from request cookie or Accept-Language
      const locale = request
        ? await i18n.detectLocaleFromHeaders(request.headers)
        : "en";

      // Get translations for email namespace
      const t = await i18n.getTranslator(locale, "emails");

      await sendEmail({
        to: user.email,
        subject: t("reset_password_subject"),
        html: `
          <h1>${t("reset_password_title")}</h1>
          <p>${t("reset_password_body")}</p>
          <a href="${url}">${t("reset_password_button")}</a>
        `,
      });
    },
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }, request) => {
      const locale = request
        ? await i18n.detectLocaleFromHeaders(request.headers)
        : "en";
      const t = await i18n.getTranslator(locale, "emails");

      await sendEmail({
        to: user.email,
        subject: t("verify_email_subject"),
        html: `
          <h1>${t("verify_email_title")}</h1>
          <p>${t("verify_email_body")}</p>
          <a href="${url}">${t("verify_email_button")}</a>
        `,
      });
    },
  },
  plugins: [
    createBetterAuthProvider(i18n, { localeCookie: "locale" }),
  ],
});

The same i18n singleton handles both error messages and email templates — one CDN cache, zero extra configuration.


Framework-Specific Setup

Next.js

// middleware.ts — detect locale and set cookie
import { betterI18nMiddleware } from "@better-i18n/next/middleware";

export default betterI18nMiddleware({
  project: "your-org/your-project",
  defaultLocale: "en",
  locales: ["en", "tr", "de", "fr"],
});

Next.js i18n guide →

Vite + React

<BetterI18nProvider
  project="your-org/your-project"
  localeCookie="locale"
>
  <App />
</BetterI18nProvider>

Vite setup guide →

Hono

import { betterI18n } from "@better-i18n/server/hono";
import { i18n } from "./i18n";

app.use("*", betterI18n(i18n));
// c.get("locale") and c.get("t") available in all routes

Hono middleware guide →


Getting Started

Setting up Better Auth localization with Better i18n takes about 10 minutes:

  1. Create a free account at better-i18n.com
  2. Create a project and add your target languages
  3. Install @better-i18n/server and add the Better Auth provider
  4. Seed keys — use the MCP server with AI or import DEFAULT_AUTH_KEYS programmatically
  5. Translate — use AI translation from the dashboard or translate manually
  6. Publish — translations are live on the CDN instantly

No credit card required. Free to get started.

Create your account → · Read the docs →