Table of Contents
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:
| Layer | What Gets Translated | Where It Happens |
|---|---|---|
| Server errors | INVALID_EMAIL_OR_PASSWORD → "Geçersiz e-posta veya şifre" | Server-side plugin |
| UI components | "Sign in" → "Giriş Yap" | Client-side AuthLocalization |
| Email templates | Verification emails, password resets | Email 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 Code | English | Turkish | German |
|---|---|---|---|
INVALID_EMAIL_OR_PASSWORD | Invalid email or password | Geçersiz e-posta veya şifre | Ungültige E-Mail oder Passwort |
INVALID_PASSWORD | Invalid password | Geçersiz şifre | Ungültiges Passwort |
INVALID_EMAIL | Invalid email | Geçersiz e-posta | Ungültige E-Mail |
INVALID_TOKEN | Invalid token | Geçersiz token | Ungültiges Token |
TOKEN_EXPIRED | Token expired | Token süresi doldu | Token abgelaufen |
EMAIL_NOT_VERIFIED | Email not verified | E-posta doğrulanmadı | E-Mail nicht verifiziert |
EMAIL_ALREADY_VERIFIED | Email is already verified | E-posta zaten doğrulanmış | E-Mail bereits verifiziert |
EMAIL_MISMATCH | Email mismatch | E-posta uyuşmuyor | E-Mail stimmt nicht überein |
User Management (8 keys)
| Error Code | English | Turkish |
|---|---|---|
USER_NOT_FOUND | User not found | Kullanıcı bulunamadı |
USER_ALREADY_EXISTS | User already exists. | Bu kullanıcı zaten mevcut. |
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL | User already exists. Use another email. | Bu e-posta adresi zaten kayıtlı. Başka bir e-posta deneyin. |
INVALID_USER | Invalid user | Geçersiz kullanıcı |
USER_EMAIL_NOT_FOUND | User email not found | Kullanıcı e-postası bulunamadı |
USER_ALREADY_HAS_PASSWORD | User 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_USER | Failed to create user | Kullanıcı oluşturulamadı |
FAILED_TO_UPDATE_USER | Failed to update user | Kullanıcı güncellenemedi |
Session & Password (8 keys)
| Error Code | English | Turkish |
|---|---|---|
SESSION_EXPIRED | Session expired. Re-authenticate to perform this action. | Oturum süresi doldu. Bu işlem için yeniden giriş yapın. |
SESSION_NOT_FRESH | Session is not fresh | Oturum güncel değil |
FAILED_TO_CREATE_SESSION | Failed to create session | Oturum oluşturulamadı |
FAILED_TO_GET_SESSION | Failed to get session | Oturum bilgisi alınamadı |
PASSWORD_TOO_SHORT | Password too short | Şifre çok kısa |
PASSWORD_TOO_LONG | Password too long | Şifre çok uzun |
PASSWORD_ALREADY_SET | User already has a password set | Kullanıcının zaten bir şifresi var |
CREDENTIAL_ACCOUNT_NOT_FOUND | Credential account not found | Kimlik bilgisi hesabı bulunamadı |
Account, Social & Email Verification (9 keys)
| Error Code | English | Turkish |
|---|---|---|
ACCOUNT_NOT_FOUND | Account not found | Hesap bulunamadı |
SOCIAL_ACCOUNT_ALREADY_LINKED | Social account already linked | Sosyal hesap zaten bağlı |
LINKED_ACCOUNT_ALREADY_EXISTS | Linked account already exists | Bağlı hesap zaten mevcut |
FAILED_TO_UNLINK_LAST_ACCOUNT | You can't unlink your last account | Son hesap bağlantınızı kaldıramazsınız |
PROVIDER_NOT_FOUND | Provider not found | Sağlayıcı bulunamadı |
ID_TOKEN_NOT_SUPPORTED | id_token not supported | id_token desteklenmiyor |
VERIFICATION_EMAIL_NOT_ENABLED | Verification email isn't enabled | Doğrulama e-postası etkin değil |
EMAIL_CAN_NOT_BE_UPDATED | Email can not be updated | E-posta adresi güncellenemiyor |
FAILED_TO_CREATE_VERIFICATION | Unable to create verification | Doğrulama oluşturulamadı |
URL Validation & Security (8 keys)
| Error Code | English | Turkish |
|---|---|---|
INVALID_ORIGIN | Invalid origin | Geçersiz kaynak |
INVALID_CALLBACK_URL | Invalid callbackURL | Geçersiz geri dönüş adresi |
INVALID_REDIRECT_URL | Invalid redirectURL | Geçersiz yönlendirme adresi |
INVALID_ERROR_CALLBACK_URL | Invalid errorCallbackURL | Geçersiz hata yönlendirme adresi |
INVALID_NEW_USER_CALLBACK_URL | Invalid newUserCallbackURL | Geçersiz yeni kullanıcı yönlendirme adresi |
MISSING_OR_NULL_ORIGIN | Missing or null Origin | Kaynak bilgisi eksik |
CALLBACK_URL_REQUIRED | callbackURL is required | Geri dönüş adresi zorunludur |
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED | Cross-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 Code | English | Turkish |
|---|---|---|
VALIDATION_ERROR | Validation Error | Doğrulama hatası |
MISSING_FIELD | Field is required | Bu alan zorunludur |
FIELD_NOT_ALLOWED | Field not allowed to be set | Bu alan değiştirilemez |
ASYNC_VALIDATION_NOT_SUPPORTED | Async validation is not supported | Asenkron doğrulama desteklenmiyor |
BODY_MUST_BE_AN_OBJECT | Body must be an object | İstek gövdesi bir nesne olmalıdır |
FAILED_TO_GET_USER_INFO | Failed to get user info | Kullanıcı bilgileri alınamadı |
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED | POST method requires deferSessionRefresh to be enabled | Bu 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.
Approach 2: CDN-Based Translations with Better i18n (Recommended)
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",
});
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:
- Intercepts every Better Auth error response
- Reads the user's locale from the
localecookie (falls back toAccept-Language) - Fetches translations from the CDN (cached in-memory for 60 seconds)
- Replaces the error message with the translated version
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
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_KEYSin 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.
Static vs CDN-Based: Full Comparison
| Feature | @better-auth/i18n | better-auth-localization | Better i18n |
|---|---|---|---|
| Translation source | Hardcoded in code | Bundled npm package | CDN (runtime) |
| Add new language | Code change + deploy | Wait for npm update | Dashboard — no deploy |
| Update a translation | Code change + deploy | PR to open-source repo | Dashboard — live in 60s |
| Built-in languages | English only | 31+ languages | Any (AI-translated) |
| AI translation | No | No | Yes — one-click |
| Non-dev can edit | No | No | Yes — dashboard UI |
| Bundle size impact | Grows with languages | ~50KB per language | Zero — fetched at runtime |
| Locale detection | cookie, header, session | Custom callback | cookie + Accept-Language |
| Email template i18n | Manual | No | CDN-based (same system) |
| MCP / AI agent support | No | No | Yes — full MCP server |
| Free tier | N/A (built-in) | Open source | Free 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"],
});
Vite + React
<BetterI18nProvider project="your-org/your-project" localeCookie="locale" > <App /> </BetterI18nProvider>
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
Getting Started
Setting up Better Auth localization with Better i18n takes about 10 minutes:
- Create a free account at better-i18n.com
- Create a project and add your target languages
- Install
@better-i18n/serverand add the Better Auth provider - Seed keys — use the MCP server with AI or import
DEFAULT_AUTH_KEYSprogrammatically - Translate — use AI translation from the dashboard or translate manually
- Publish — translations are live on the CDN instantly
No credit card required. Free to get started.
