In this article
October 26, 2026
October 26, 2026

Localizing AuthKit end to end: Sign-in pages, emails and widgets

AuthKit translates its hosted pages, emails and Widgets into 90 locales. How each one picks the language, what you pass, and which strings stay yours.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Localizing AuthKit means serving its sign-in pages, emails and Widgets in each user's locale. WorkOS translates all three into 90 locales. What differs is how each surface learns the locale: the hosted pages read it from the browser, emails your backend sends take a locale parameter, and Widgets take a locale prop. The strings you write yourself stay yours to translate.

A locale is narrower than a language: it also decides spelling, date formats and number formats. AuthKit's supported list treats regional variants as separate entries, so en-US and en-GB are two locales, as are pt-BR and pt-PT, es-419 and es-ES, and zh-CN and zh-TW.

The usual way teams discover their localization gaps is a prospect in a non-English market sending a screenshot: a translated sign-in page next to an English "Something went wrong" toast from the app behind it. The auth surfaces are handled. The seams are in how the locale travels between them and into your own code. This post walks each surface (hosted pages, emails, Widgets, your app) and covers what's translated, how the locale is chosen and what you have to pass or own.

How each AuthKit surface picks its language. Hosted sign-in pages, and the emails they send, use the browser's accept-language header, never IP, and fall back to the environment's fallback language. Emails sent from your backend use the locale parameter on the API call and fall back to the environment's fallback language. Widgets use the locale prop on WorkOsLocaleProvider and fall back to en-US.
Each surface gets the locale through its own channel: a header for hosted pages, a parameter for API-sent emails, a prop for Widgets.

How do AuthKit's hosted pages choose a language?

AuthKit's hosted UI is localized by default, and users are served the locale that most closely matches their device's OS preference. That covers all user-facing text in the flow, including error messages and the emails the flow sends.

The mechanism is deliberately plain. AuthKit reads the browser's accept-language header and renders the text on the server. The translations are precomputed, so there's no extra latency or network call. IP address is never a factor: a Berlin engineer on vacation in Lisbon still gets German, which is the behavior you want and the one geo-based detection gets wrong.

When the browser doesn't send a preference, or no supported locale matches, the user gets the environment's fallback language, set in the dashboard under Authentication → Features → Localization. Every environment starts at en-US.

If you need one language for everyone, such as a regulated single-market product or a QA environment you want to be deterministic, turn localization off and set the fallback to the language you want.

Branding copy you add yourself is translated too. You write custom headings and link text in English, and the branding editor generates translations into every other language when you save. The language picker in the branding editor lets you preview each one before you ship.

What is the locale on the User object for?

When a user signs in, WorkOS records their preferred locale in the locale property of the User object, as a plain code such as "en-US".

Read it; don't expect it to do anything on its own. The stored locale doesn't drive the hosted pages, which decide per request from accept-language. It's useful everywhere downstream: the language your app renders on first load, the locale you pass when your backend sends an email, and the locale you hand to Widgets.

How do AuthKit emails pick a language?

It depends on what triggered the email.

Emails a hosted flow sends render in the language of the browser that started the flow. A user who clicks "forgot password" in a Japanese browser gets a Japanese reset email, matching the page they came from.

Emails your backend sends have no browser in the request, so you pass the language. Every AuthKit API call that sends an email takes an optional locale: creating a Magic Auth code, creating a password reset, sending a verification email, sending an email change code, and sending or resending an invitation. The value must be one of the supported locale codes. Leave it out and the email goes out in the environment's fallback language, so an admin onboarding a Brazilian team from a US-configured environment sends the whole batch in English.

  
curl https://api.workos.com/user_management/password_reset \
  -X POST \
  -H "Authorization: Bearer sk_example_123456789" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "marcelina@example.com",
    "locale": "pt-BR"
  }'
  

Pull that value from the same places every time: a language the user picked in your app, the locale stored on their User object, or, for invitations, the inviting organization's language. If you have none of those, the environment fallback is the honest answer.

If you'd rather own the email templates entirely, Custom Emails lets you turn off the default invitation, Magic Auth, email verification and password reset emails and send your own from the corresponding events. You don't need it for localization anymore, but it's there when you want your own templates and sending domain.

How do you localize AuthKit Widgets?

Widgets translate their text into the user's preferred language with 90 languages supported out of the box, and format dates for the user's locale. Unlike the hosted pages, Widgets don't detect the locale themselves. You pass it.

Localization ships as a separate, opt-in package, @workos-inc/widgets-i18n, so apps that don't need multiple languages keep the core Widgets package lightweight. Dictionaries load on demand: only the bundle for the end user's locale is downloaded at runtime.

Setup is a provider that wraps your app, with the locale as a prop:

  
import { WorkOsLocaleProvider } from "@workos-inc/widgets-i18n";

function App() {
  return (
    <WorkOsLocaleProvider locale="en-US">
      {/* your app goes here */}
    </WorkOsLocaleProvider>
  );
}
  

Hardcoding en-US defeats the point, so the package ships utilities to resolve a real one. getLocaleCodes() returns the supported codes, isValidLocale() checks a string against them, and getDir() returns the text direction. This helper, from the package docs, negotiates against accept-language on the server:

  
// utils/locale.ts
import {
  getDir,
  getLocaleCodes,
  isValidLocale,
  type LocaleCode,
} from "@workos-inc/widgets-i18n";
import Negotiator from "negotiator";
import * as React from "react";

const DEFAULT_LOCALE = "en-US" satisfies LocaleCode;

/**
 * For apps that support React Server Components, we recommend caching
 * the results of this function using React.cache.
 *
 * @param acceptLanguage - The value of the accept language header from the request.
 * Different frameworks will offer different ways to access this.
 */
export const getLocale = React.cache(
  async (acceptLanguage = DEFAULT_LOCALE): Promise<LocaleCode> => {
    const negotiator = new Negotiator({
      headers: { "accept-language": acceptLanguage },
    });
    const locale = negotiator.language(Array.from(getLocaleCodes()));
    return isValidLocale(locale) ? locale : DEFAULT_LOCALE;
  },
);

export function getLocaleDir(locale: LocaleCode) {
  return getDir(locale);
}
  

Then wire the resolved locale and direction into the root layout. Right-to-left is a layout concern that lives outside the translation: getDir() returns rtl for Arabic, Farsi, Hebrew and Urdu, you set dir on the html element, and Widgets take their direction from the nearest ancestor that has it.

  
import { WorkOsLocaleProvider } from "@workos-inc/widgets-i18n";
import { getLocale, getLocaleDir } from "~/utils/locale";
import { getRequest } from "your-favorite-framework";
import "@workos-inc/widgets/styles.css";

export default async function RootLayout({ children }) {
  const request = getRequest();
  const locale = await getLocale(request.headers.get("accept-language"));
  const direction = getLocaleDir(locale);
  return (
    <WorkOsLocaleProvider locale={locale}>
      <html dir={direction} lang={locale}>
        <body>{children}</body>
      </html>
    </WorkOsLocaleProvider>
  );
}
  
Locale flows inward by containment. The request's accept-language header is resolved once in the root layout by getLocale and getLocaleDir. WorkOsLocaleProvider holds the locale, the html element inside it carries the dir and lang attributes, and the widgets inside read the locale and direction from their nearest ancestor.
Locale flows inward by containment: the request's language resolves once at the root, the provider holds it, and every widget inside reads it, along with the direction set on the surrounding element.

Three things are worth knowing before you ship this:

  • A bad locale fails quietly. An invalid or missing value logs a console warning and falls back to en-US rather than throwing, so a typo'd code degrades to English. Validate with isValidLocale() before you pass it, not after a customer reports it.
  • Data you define isn't translated. Role names, organization names and your own metadata render exactly as you stored them, so a widget in Japanese showing a role called "Billing Admin" is working as designed.
  • Keep the React.cache wrapper. Without it, every server component that calls getLocale() during a request negotiates accept-language again. Each call is cheap, but there's no reason to repeat it.

On React 19, you can skip the dictionary fetch on the client: getDictionary(locale) loads the dictionary on the server, and WorkOsLocaleProvider accepts it as initialMessages.

Which locale signal should win?

accept-language reflects the device's OS preference, which makes it a good default and a poor only signal. A corporate laptop imaged in English and handed to a team in Osaka is the common case; a shared kiosk is another. Use a precedence order instead, and apply it everywhere you control the locale:

  1. A preference the user set explicitly in your app.
  2. The locale stored on the User object at their last sign-in.
  3. accept-language from the current request.
  4. The environment fallback.

The hosted pages follow steps 3 and 4. Widgets and every email your backend sends follow all four, because the locale is an argument you pass. The inconsistency to avoid is a user who set Japanese in your app's settings, gets Japanese Widgets, and then gets an English password reset email because that code path never read the preference.

Who owns which string?

Surface Translated by How the locale is chosen Your job
AuthKit hosted pages and their error messages WorkOS accept-language, else the environment fallback Set the fallback; write custom branding copy in English and save
Emails sent by a hosted AuthKit flow WorkOS The browser that started the flow Nothing
Emails sent from your backend (invitations, Magic Auth, password reset, email verification, email change) WorkOS The locale you pass, else the environment fallback Pass locale on every call
Widgets WorkOS The locale prop; invalid or missing falls back to en-US Resolve the locale, wrap in WorkOsLocaleProvider, set dir
Role names, organization names and your metadata inside Widgets Nobody n/a Translate it yourself if users see it
Your app's UI copy, error states and empty states You Your i18n stack All of it
Your own transactional and lifecycle email, including Custom Emails You Your i18n stack, seeded from the stored locale All of it
Terms, privacy policy, DPA and other legal text You and your counsel Your call All of it, with review

The pattern across the table: WorkOS translates the strings it writes, and the locale travels through whatever channel each surface has. Everything you write stays yours, including the error toast your app shows after AuthKit hands the user back.

Where should you start?

Translating the strings got cheap: WorkOS localized AuthKit into 90 languages in five weeks and wrote up how. Knowing where the boundary sits is the part that still takes work.

Before your next enterprise review, set a browser to a language you don't read and run the full flow: sign up, verify, reset a password, accept an invitation, open a widget. Then trigger the same emails from your backend. Screenshot every string that comes back in English. That list is your actual localization backlog.

Read more