i18n

hadars has no built-in router, so its i18n module doesn't assume one either. Locale lives in the URL path, translation files are plain static assets, and switching languages happens in place — no full page reload, no server round-trip.

How locale is determined

The default locale is served unprefixed at the root; every other locale gets a path prefix. /about is English, /ro/about is Romanian. This scheme needs no redirect logic on the bare root path — important for static hosting, where there's no request-time signal (no cookie, no Accept-Language header) available to redirect with.

import { parseLocaleFromPath, type HadarsI18nConfig } from 'hadars';

const i18nConfig: HadarsI18nConfig = {
    locales: ['en', 'ro', 'ru'],
    defaultLocale: 'en',
};

// '/about'      -> { locale: 'en', page: '/about' }
// '/ro/about'   -> { locale: 'ro', page: '/about' }
// '/ru'         -> { locale: 'ru', page: '/' }
parseLocaleFromPath('/ro/about', i18nConfig);

Translation files are static assets

Split messages by namespace (one JSON file per page/feature per locale) rather than one giant file per language, so no page pays for strings it doesn't use:

static/
  locales/
    en/
      common.json
      home.json
    ro/
      common.json
      home.json

Anything placed under a project's static/ directory is copied into .hadars/static/ during hadars build, so these files are served identically whether you're running hadars run or deploying the output of hadars export static — no server logic involved in resolving them.

Setup

Resolve the initial locale from the request path in getInitProps, and wrap your app in LocaleProvider. This works the same in live-server and static-export mode — req.pathname is available in both.

import { type HadarsApp, type HadarsRequest, LocaleProvider, parseLocaleFromPath } from 'hadars';

const i18nConfig = { locales: ['en', 'ro', 'ru'], defaultLocale: 'en' };

interface Props { initialLocale: string }

const App: HadarsApp<Props> = ({ initialLocale, ...rest }) => (
    <LocaleProvider initialLocale={initialLocale} {...i18nConfig}>
        {/* your routed pages */}
    </LocaleProvider>
);

export const getInitProps = async (req: HadarsRequest): Promise<Props> => {
    const { locale } = parseLocaleFromPath(req.pathname, i18nConfig);
    return { initialLocale: locale };
};

export default App;

Reading translations

useTranslations resolves a namespace's messages once during SSR (via useServerData, so the initial page has zero client-side waterfall) and re-resolves it from the same static files on every future locale switch.

import { useTranslations } from 'hadars';

const loadMessages = (locale: string, namespace: string) =>
    import(`../../static/locales/${locale}/${namespace}.json`).then(m => m.default);

const Home = () => {
    const { t } = useTranslations('home', loadMessages);
    return <h1>{t('hero.title', { name: 'Hadar' })}</h1>;
};

Switching language without a reload

Call setLocale from useLocale(). Every namespace currently mounted anywhere in the tree is fetched for the new locale in parallel, and the switch only applies once all of them have resolved — so no component can render with the new locale while another is still showing the old one. A locale visited before (via SSR or a prior switch) never touches the network again.

import { useLocale } from 'hadars';

const LanguageSwitcher = () => {
    const { locale, setLocale, isSwitching } = useLocale();
    return (
        <select value={locale} disabled={isSwitching} onChange={e => setLocale(e.target.value)}>
            <option value="en">EN</option>
            <option value="ro">RO</option>
            <option value="ru">RU</option>
        </select>
    );
};

The switch also silently syncs the address bar via history.replaceState (no navigation event fires, so nothing remounts) — so the URL stays correct for reload, sharing, and static hosting, where the URL is the only thing that determines which pre-built page a visitor lands on.

Static export

Enumerate every locale × page combination in paths() so each locale gets its own pre-rendered, crawlable HTML file:

const pages = ['/', '/about', '/blog'];

export default {
    entry: './src/App.tsx',
    paths: () => [
        ...pages,
        ...i18nConfig.locales
            .filter(l => l !== i18nConfig.defaultLocale)
            .flatMap(l => pages.map(p => `/${l}${p === '/' ? '' : p}`)),
    ],
};

Internal links should go through useLocalizedPath so navigation between pages stays within the current locale's pre-built set:

import { useLocalizedPath } from 'hadars';

const Nav = () => {
    const aboutHref = useLocalizedPath('/about'); // '/about' or '/ro/about'
    return <a href={aboutHref}>About</a>;
};

API reference

ExportTypePurpose
LocaleProvidercomponentOwns locale state, the message cache, and atomic locale switching.
useLocalehookReturns { locale, setLocale, isSwitching }.
useTranslationshookRegisters a namespace, returns { t, isSwitching }.
useLocalizedPathhookPrefixes a path with the current locale (default locale stays unprefixed).
parseLocaleFromPathfunctionSplits a pathname into { locale, page }.
localizePathfunctionThe inverse of parseLocaleFromPath.

Live demo

See it running on the i18n demo page — switch languages and watch the network tab: a locale visited once is never fetched again.