Better I18NBetter I18N
Server SDK

Better Auth

Translate Better Auth error messages with CDN-based localization

@better-i18n/server/providers/better-auth creates a Better Auth plugin that automatically translates error messages (like "Invalid email or password") into your users' language — fetched from the CDN at runtime.

No redeployment needed. Unlike bundled localization packages, translations are served from the Better i18n CDN. Add a new language or fix a typo from the dashboard — it's live within 60 seconds.

Setup

Install the package

bun add @better-i18n/server
npm install @better-i18n/server
pnpm add @better-i18n/server
yarn add @better-i18n/server

Create the i18n singleton

This instance is shared between Better Auth and your server middleware (Hono, Express, etc.) — one CDN cache for everything.

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

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

Add the provider to Better Auth

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({
  // ...your config
  plugins: [
    createBetterAuthProvider(i18n), 
    // ...other plugins
  ],
});

That's it. The provider hooks into every Better Auth error response, detects the locale from the Accept-Language header, and translates the error message.

Add translations in the dashboard

  1. Go to your project in Better i18n
  2. Create a namespace called auth
  3. Add translation keys matching Better Auth error codes (e.g., INVALID_EMAIL_OR_PASSWORD)
  4. Translate into your target languages
  5. Publish — translations are live instantly

Seed keys with AI. Use the Better i18n MCP server to bulk-create all default keys in one prompt:

Create all keys from Better Auth's core error codes in the "auth" namespace. The keys are: INVALID_EMAIL_OR_PASSWORD, USER_NOT_FOUND, EMAIL_NOT_VERIFIED, SESSION_EXPIRED, PASSWORD_TOO_SHORT, PASSWORD_TOO_LONG, USER_ALREADY_EXISTS, INVALID_TOKEN, TOKEN_EXPIRED, ACCOUNT_NOT_FOUND. Set English values to their default messages.

How It Works

When Better Auth returns an error (e.g., invalid login), the provider:

  1. Detects if the response is an APIError with a body.code (e.g., INVALID_EMAIL_OR_PASSWORD)
  2. Resolves the user's locale from the Accept-Language header
  3. Fetches translations from the CDN — all namespaces are served in a single translations.json per locale, and the provider scopes to the auth namespace automatically
  4. Replaces body.message with the translated string

If a translation is missing, the original English message is preserved and a warning is logged — auth never breaks because of a missing translation.

Client (Accept-Language: tr) → Better Auth → APIError
  → Provider: detectLocale("tr")
  → CDN: /{org}/{project}/tr/translations.json → { "auth": { "INVALID_EMAIL_OR_PASSWORD": "..." } }
  → Response: { "code": "INVALID_EMAIL_OR_PASSWORD", "message": "Geçersiz e-posta veya şifre" }

Translations are cached in memory (60s TTL). After the first request, subsequent lookups are instant — no extra CDN calls per auth error.

Locale Detection

By default, the provider reads the Accept-Language header. For most apps, you'll want cookie-based detection instead — it uses the locale the user actually chose in your app, not their browser/OS language.

Pair with the provider's localeCookie prop — same cookie name on both sides:

src/auth.ts
createBetterAuthProvider(i18n, {
  localeCookie: "locale", 
  // reads the "locale" cookie from the request
  // falls back to Accept-Language if cookie is missing
})
src/App.tsx
// Client writes the cookie automatically:
<BetterI18nProvider localeCookie> {/* default cookie name: "locale" */}
  <App />
</BetterI18nProvider>

Custom callback

For advanced cases (database, user profile, custom header):

src/auth.ts
createBetterAuthProvider(i18n, {
  getLocale: async ({ headers }) => { 
    const userId = getUserIdFromSession(headers);
    return await db.users.getLocale(userId) ?? "en";
  },
})

Options

OptionTypeDefaultDescription
namespacestring"auth"CDN namespace for auth translations
localeCookiestringCookie name to read locale from (pairs with provider's localeCookie prop)
getLocale(ctx) => string | Promise<string>Accept-Language detectionCustom locale resolver (overrides all other detection)
warnOnMissingKeysbooleantrueLog warnings for untranslated error codes

Detection priority: getLocale callback > localeCookie > Accept-Language header > defaultLocale

Default Error Codes

The provider exports DEFAULT_AUTH_KEYS — all Better Auth core error codes with English defaults. Create these keys in your project's auth namespace, then translate them from the dashboard.

Seed all keys with AI. Copy the prompt below into Claude, ChatGPT, or any AI agent with the Better i18n MCP server connected:

Import all Better Auth error codes into my project's "auth" namespace using DEFAULT_AUTH_KEYS from @better-i18n/server/providers/better-auth. Create all 48 keys with their English defaults, then translate them into my project's target languages.

Full Key Reference (48 keys)

KeyDefault English Message
Authentication
INVALID_EMAIL_OR_PASSWORDInvalid email or password
INVALID_PASSWORDInvalid password
INVALID_EMAILInvalid email
INVALID_TOKENInvalid token
TOKEN_EXPIREDToken expired
EMAIL_NOT_VERIFIEDEmail not verified
EMAIL_ALREADY_VERIFIEDEmail is already verified
EMAIL_MISMATCHEmail mismatch
User Management
USER_NOT_FOUNDUser not found
USER_ALREADY_EXISTSUser already exists.
USER_ALREADY_EXISTS_USE_ANOTHER_EMAILUser already exists. Use another email.
INVALID_USERInvalid user
USER_EMAIL_NOT_FOUNDUser email not found
USER_ALREADY_HAS_PASSWORDUser already has a password. Provide that to delete the account.
FAILED_TO_CREATE_USERFailed to create user
FAILED_TO_UPDATE_USERFailed to update user
FAILED_TO_GET_USER_INFOFailed to get user info
Session
SESSION_EXPIREDSession expired. Re-authenticate to perform this action.
SESSION_NOT_FRESHSession is not fresh
FAILED_TO_CREATE_SESSIONFailed to create session
FAILED_TO_GET_SESSIONFailed to get session
Password
PASSWORD_TOO_SHORTPassword too short
PASSWORD_TOO_LONGPassword too long
PASSWORD_ALREADY_SETUser already has a password set
CREDENTIAL_ACCOUNT_NOT_FOUNDCredential account not found
Account & Social
ACCOUNT_NOT_FOUNDAccount not found
SOCIAL_ACCOUNT_ALREADY_LINKEDSocial account already linked
LINKED_ACCOUNT_ALREADY_EXISTSLinked account already exists
FAILED_TO_UNLINK_LAST_ACCOUNTYou can't unlink your last account
PROVIDER_NOT_FOUNDProvider not found
ID_TOKEN_NOT_SUPPORTEDid_token not supported
Email Verification
VERIFICATION_EMAIL_NOT_ENABLEDVerification email isn't enabled
EMAIL_CAN_NOT_BE_UPDATEDEmail can not be updated
FAILED_TO_CREATE_VERIFICATIONUnable to create verification
URL Validation
INVALID_ORIGINInvalid origin
INVALID_CALLBACK_URLInvalid callbackURL
INVALID_REDIRECT_URLInvalid redirectURL
INVALID_ERROR_CALLBACK_URLInvalid errorCallbackURL
INVALID_NEW_USER_CALLBACK_URLInvalid newUserCallbackURL
MISSING_OR_NULL_ORIGINMissing or null Origin
CALLBACK_URL_REQUIREDcallbackURL is required
Security
CROSS_SITE_NAVIGATION_LOGIN_BLOCKEDCross-site navigation login blocked. This request appears to be a CSRF attack.
Validation
VALIDATION_ERRORValidation Error
MISSING_FIELDField is required
FIELD_NOT_ALLOWEDField not allowed to be set
ASYNC_VALIDATION_NOT_SUPPORTEDAsync validation is not supported
BODY_MUST_BE_AN_OBJECTBody must be an object
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIREDPOST method requires deferSessionRefresh to be enabled in session config

You can also access these programmatically:

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

// 40+ keys with English defaults
Object.entries(DEFAULT_AUTH_KEYS).forEach(([key, message]) => {
  console.log(`${key}: ${message}`);
});

This table covers Better Auth core error codes. Plugin-specific codes (admin, two-factor, email-otp, organization) can be added as additional keys in your namespace.

Sharing with Hono Middleware

The i18n singleton is designed to be shared. Use the same instance for both Better Auth and your Hono API:

src/app.ts
import { Hono } from "hono";
import { betterI18n } from "@better-i18n/server/hono";
import { i18n } from "./i18n"; // same singleton

const app = new Hono<{
  Variables: { locale: string; t: Translator };
}>();

app.use("*", betterI18n(i18n)); // Hono routes get c.get("t")

// Better Auth also uses the same i18n → shared CDN cache, zero extra fetches

On this page