Skip to content

Instantly share code, notes, and snippets.

@JensvandeWiel
Created February 4, 2026 20:13
Show Gist options
  • Select an option

  • Save JensvandeWiel/d7445655ef0c34d09071537674d1e462 to your computer and use it in GitHub Desktop.

Select an option

Save JensvandeWiel/d7445655ef0c34d09071537674d1e462 to your computer and use it in GitHub Desktop.

Frontend Localization - Implementation Code Package

Complete, copy-paste ready code for implementing frontend localization in your project.


1. Backend PHP Code

1.1 Locale Enum (app/Enums/Locale.php)

<?php

declare(strict_types=1);

namespace App\Enums;

enum Locale: string
{
    case EN = 'en';
    case NL = 'nl';

    /**
     * @return array<string>
     */
    public static function values(): array
    {
        return array_map(static fn (self $case) => $case->value, self::cases());
    }

    /**
     * Get the locale from the cookie value.
     *
     * @param  array<mixed>|string|null  $cookieValue
     */
    public static function fromCookie(array|null|string $cookieValue, Locale $default = self::EN): self
    {
        if (is_array($cookieValue)) {
            $cookieValue = $cookieValue[0];
        }
        if (! $cookieValue || ! in_array($cookieValue, self::values(), true)) {
            return $default;
        }

        return self::from($cookieValue);
    }
}

Customization: Add/remove locale cases as needed.


1.2 Locale Middleware (app/Http/Middleware/HandleLocale.php)

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Enums\Locale;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;

final class HandleLocale
{
    /**
     * Handle an incoming request.
     *
     * @param  Closure(Request):Response  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        $userLocale = $request->cookie('locale', App::getLocale());

        // Validate locale
        $isValid = in_array(
            $userLocale,
            Locale::values(),
            true
        );

        if (! $isValid) {
            $userLocale = App::getLocale();
        }

        View::share('locale', $userLocale);
        App::setLocale($userLocale);

        return $next($request);
    }
}

Registration: Add to your bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        HandleLocale::class,
        // ... other middleware
    ]);
})

2. Frontend TypeScript/React Code

2.1 Client i18n Config (resources/js/lib/i18n.client.ts)

import i18n from 'i18next';
import HttpBackend from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';

export const initI18n = async (lng: string) => {
    await i18n
        .use(HttpBackend)
        .use(initReactI18next)
        .init({
            lng: lng || import.meta.env.APP_LOCALE,
            fallbackLng: import.meta.env.APP_FALLBACK_LOCALE,
            debug: import.meta.env.MODE !== 'production',
            interpolation: {
                escapeValue: false,
            },
            backend: {
                loadPath: '/lang/processed_{{lng}}.json',
            },
        });
};

export default i18n;

Dependencies to install:

npm install i18next i18next-http-backend react-i18next

2.2 Server i18n Config (resources/js/lib/i18n.server.ts)

import i18n, { CallbackError, ResourceKey } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next';

const files = import.meta.glob('/lang/processed_*.json', { eager: true });

const loadResources = (
    language: string,
    namespace: string,
    callback: (
        err: CallbackError,
        resources: ResourceKey | boolean | null | undefined,
    ) => void,
) => {
    const key = `/lang/processed_${language}.json`;
    if (files[key]) {
        callback(
            null,
            (files[key] as { default: Record<string, unknown> }).default,
        );
    } else {
        callback(null, {});
    }
};

export const initI18n = async (lng?: string) => {
    await i18n
        .use(resourcesToBackend(loadResources))
        .use(initReactI18next)
        .init({
            lng: lng || import.meta.env.APP_LOCALE,
            fallbackLng: import.meta.env.APP_FALLBACK_LOCALE,
            debug: import.meta.env.MODE !== 'production',
            interpolation: {
                escapeValue: false,
            },
        });
};

export default i18n;

Dependencies to install:

npm install i18next-resources-to-backend

2.3 useLocale Hook (resources/js/hooks/use-locale.tsx)

import { PageProps } from '@/types/global';
import { usePage } from '@inertiajs/react';
import i18n from 'i18next';
import React from 'react';
import Locale = App.Enums.Locale;

export function useLocale() {
    const page = usePage<PageProps>();
    const initialLocale = React.useRef(page.props.locale);
    const [locale, setLocale] = React.useState<Locale>(initialLocale.current);

    const availableLocales: Locale[] = ['en', 'nl'];

    const setLocaleState = React.useCallback((newLocale: Locale) => {
        setLocale(newLocale);
        i18n.changeLanguage(newLocale);
        localStorage.setItem('locale', newLocale);
        if (typeof document !== 'undefined') {
            document.documentElement.lang = newLocale;
        }
    }, []);

    const updateLocale = React.useCallback(
        async (newLocale: Locale) => {
            setLocaleState(newLocale);
            document.cookie = `locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}`;
        },
        [setLocaleState],
    );

    React.useEffect(() => {
        const savedLocale = localStorage.getItem('locale') as Locale | null;
        setLocaleState(savedLocale || initialLocale.current);
    }, [setLocaleState]);

    return { locale, updateLocale, availableLocales } as const;
}

Notes:

  • Update availableLocales to match your locales
  • Requires Inertia.js props type with locale field
  • Requires TypeScript global App.Enums.Locale type

2.4 Locale Toggle Component (resources/js/components/locale-toggle.tsx)

import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Locale = App.Enums.Locale;

export function LocaleToggle() {
    const { locale, updateLocale, availableLocales } = useLocale();
    const { t } = useTranslation();

    const getNextLocale = (current: Locale): Locale => {
        const index = availableLocales.findIndex((loc) => loc === current);
        return availableLocales[(index + 1) % availableLocales.length];
    };

    const getLocaleIconName = (loc: Locale): string => {
        return loc === 'en' ? 'gb' : loc;
    };

    const handleCycle = () => {
        const next = getNextLocale(locale);
        updateLocale(next);
    };

    return (
        <ButtonGroup>
            <Button
                variant="outline"
                onClick={handleCycle}
                size="icon"
                className={'w-10.5'}
            >
                <span className={cn('fi', `fi-${getLocaleIconName(locale)}`)} />
            </Button>
            <DropdownMenu>
                <DropdownMenuTrigger asChild>
                    <Button variant="outline" size="icon" className={'w-6'}>
                        <ChevronDownIcon className="h-4 w-4" />
                    </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                    {availableLocales.map((option) => (
                        <DropdownMenuItem
                            key={option}
                            onClick={() => updateLocale(option)}
                        >
                            <span
                                className={cn(
                                    'fi w-4',
                                    `fi-${getLocaleIconName(option)}`,
                                )}
                            />
                            {t(`locale.${option}`)}
                        </DropdownMenuItem>
                    ))}
                </DropdownMenuContent>
            </DropdownMenu>
        </ButtonGroup>
    );
}

Dependencies:

  • @headlessui/react or custom dropdown
  • lucide-react for icons
  • flag-icons for language flags (optional)

Customize:

  • Replace Button, ButtonGroup, DropdownMenu components with your UI library
  • Update getLocaleIconName() to match your icon/flag system
  • Change w-10.5, w-6 class names to your sizing system

3. Vite Configuration

3.1 Vite Plugin for Translation Processing (resources/js/plugins/laravel-i18n.ts)

import fs from 'fs';
import path from 'path';
import { fromString } from 'php-array-reader';
import { Plugin } from 'vite';

function processTranslations(
    data: Record<string, unknown>,
): Record<string, unknown> {
    const newData: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(data)) {
        const processedKey = key.replace(/:(\w+)/g, '{{$1}}');
        const processedValue =
            typeof value === 'string'
                ? value.replace(/:(\w+)/g, '{{$1}}')
                : value;

        if (
            typeof processedValue === 'string' &&
            processedValue.includes('|')
        ) {
            const parts = processedValue.split('|');
            // Assume first is singular (_one), last is plural (_other)
            const baseKey = processedKey.replace(/\berrors\b/g, 'error');
            newData[`${baseKey}_one`] = parts[0];
            newData[`${baseKey}_other`] = parts[parts.length - 1];
        } else {
            newData[processedKey] = processedValue;
        }
    }

    return newData;
}

export function laravelI18n(langDir = 'lang'): Plugin {
    const generatedFiles: string[] = [];
    const clean = () => {
        generatedFiles.forEach(
            (file) => fs.existsSync(file) && fs.unlinkSync(file),
        );
        generatedFiles.length = 0;
    };

    const processTranslationsFromFiles = () => {
        if (!fs.existsSync(langDir)) return;
        const translations: Record<string, Record<string, unknown>> = {};

        // Parse PHP files
        const locales = fs
            .readdirSync(langDir)
            .filter((f) => fs.statSync(path.join(langDir, f)).isDirectory());
        locales.forEach((locale) => {
            translations[locale] = {};
            const getFiles = (dir: string, prefix = '') => {
                fs.readdirSync(dir).forEach((file) => {
                    const fullPath = path.join(dir, file);
                    if (fs.statSync(fullPath).isDirectory()) {
                        getFiles(
                            fullPath,
                            `${prefix}${file.replace('.php', '')}.`,
                        );
                    } else if (file.endsWith('.php')) {
                        const phpObj = fromString(
                            fs.readFileSync(fullPath, 'utf-8'),
                        );
                        const flatten = (obj: unknown, baseKey = '') => {
                            Object.entries(
                                obj as Record<string, unknown>,
                            ).forEach(([k, v]) => {
                                const newKey = baseKey ? `${baseKey}.${k}` : k;
                                if (
                                    v &&
                                    typeof v === 'object' &&
                                    !Array.isArray(v)
                                )
                                    flatten(v, newKey);
                                else
                                    translations[locale][
                                        `${prefix}${file.replace('.php', '')}.${newKey}`
                                    ] = v;
                            });
                        };
                        flatten(phpObj);
                    }
                });
            };
            getFiles(path.join(langDir, locale));
        });

        // Merge JSON files
        const files = fs
            .readdirSync(langDir)
            .filter((f) => f.endsWith('.json') && !f.startsWith('processed_'));
        files.forEach((file) => {
            const locale = file.includes('php_')
                ? file.split('_')[1].replace('.json', '')
                : file.replace('.json', '');
            const data = JSON.parse(
                fs.readFileSync(path.join(langDir, file), 'utf-8'),
            ) as Record<string, unknown>;
            if (!translations[locale]) translations[locale] = {};
            Object.assign(translations[locale], data);
        });

        // Process and save
        Object.entries(translations).forEach(([locale, data]) => {
            const processed = processTranslations(data);
            const outPath = path.join(langDir, `processed_${locale}.json`);
            fs.writeFileSync(outPath, JSON.stringify(processed, null, 2));
            if (!generatedFiles.includes(outPath)) generatedFiles.push(outPath);
        });
    };

    return {
        name: 'laravel-i18n',
        config: () => processTranslationsFromFiles(),
        handleHotUpdate: ({ file, server }) => {
            if (
                file.endsWith('.php') ||
                (file.endsWith('.json') &&
                    file.includes(`/${langDir}/`) &&
                    !file.includes('processed_'))
            ) {
                processTranslationsFromFiles();
                server.ws.send({ type: 'full-reload' });
            }
        },
        configureServer: () => {
            process.on('exit', clean);
        },
    };
}

Dependencies to install:

npm install php-array-reader

3.2 Vite Config (vite.config.js)

Add to your existing vite.config.js:

import { laravelI18n } from './resources/js/plugins/laravel-i18n.ts';

export default defineConfig({
    plugins: [
        // ... other plugins
        laravelI18n()
    ],
});

4. App Entry Point Setup

4.1 Main App (resources/js/app.tsx)

Add i18n initialization to your Inertia setup:

import { I18nextProvider } from 'react-i18next';
import { default as i18n, initI18n } from './lib/i18n.client';

createInertiaApp({
    title: (title) => (title ? `${title} - ${appName}` : appName),
    resolve: (name) =>
        resolvePageComponent(
            `./pages/${name}.tsx`,
            import.meta.glob('./pages/**/*.tsx'),
        ),
    setup({ el, App, props }) {
        const pageProps: PageProps = props.initialPage.props;
        initI18n(pageProps.locale).then(() => {
            hydrateRoot(
                el,
                <I18nextProvider i18n={i18n}>
                    <App {...props} />
                </I18nextProvider>,
            );
        });
    },
});

4.2 Server Entry Point (resources/js/ssr.tsx)

import { I18nextProvider } from 'react-i18next';
import { default as i18n, initI18n } from './lib/i18n.server';

createServer(async (page) => {
    const pageProps: PageProps = page.props;
    await initI18n(pageProps.locale);
    return createInertiaApp({
        page,
        render: ReactDOMServer.renderToString,
        setup({ App, props }) {
            return (
                <I18nextProvider i18n={i18n}>
                    <App {...props} />
                </I18nextProvider>
            );
        },
    });
});

5. Types Setup

5.1 Extend PageProps (resources/js/types/global.ts)

import { PageProps as InertiaPageProps } from '@inertiajs/core';

declare global {
    namespace App {
        interface Enums {
            Locale: 'en' | 'nl';
        }
    }
}

export interface PageProps extends InertiaPageProps {
    locale: App.Enums.Locale;
    // ... other props
}

6. Usage in Components

6.1 Basic Translation

import { useTranslation } from 'react-i18next';

export function MyComponent() {
    const { t } = useTranslation();
    
    return (
        <div>
            <h1>{t('pages.dashboard.title')}</h1>
            <p>{t('pages.dashboard.welcome')}</p>
        </div>
    );
}

6.2 With Interpolation

const { t } = useTranslation();
const message = t('welcome.user', { name: 'John' });
// Translation: "welcome.user": "Welcome {{name}}"
// Result: "Welcome John"

6.3 With Pluralization

const { t } = useTranslation();
const text = t('item', { count: 5 });
// Translation: "item_one": "1 item", "item_other": "{{count}} items"
// Result: "5 items"

6.4 Language Switcher

import { LocaleToggle } from '@/components/locale-toggle';

export function Header() {
    return (
        <header>
            <LocaleToggle />
        </header>
    );
}

7. Language Files Structure

7.1 Directory Structure

lang/
├── en/
│   ├── messages.php
│   ├── validation.php
│   └── auth.php
├── nl/
│   ├── messages.php
│   ├── validation.php
│   └── auth.php
├── en.json
├── nl.json
├── processed_en.json (generated)
└── processed_nl.json (generated)

7.2 PHP Translation File Example (lang/en/messages.php)

<?php

return [
    'pages' => [
        'dashboard' => [
            'title' => 'Dashboard',
            'welcome' => 'Welcome back',
        ],
    ],
    'buttons' => [
        'submit' => 'Submit',
        'cancel' => 'Cancel',
    ],
    'item' => '1 item|:count items',
];

7.3 JSON Translation File Example (lang/en.json)

{
    "locale.en": "English",
    "locale.nl": "Dutch",
    "errors.validation.required": "This field is required",
    "errors.validation.email": "This must be a valid email"
}

8. Environment Configuration

8.1 .env Setup

APP_LOCALE=en
APP_FALLBACK_LOCALE=en

8.2 vite.config.js Environment Variables

export default defineConfig({
    define: {
        'import.meta.env.APP_LOCALE': JSON.stringify(
            process.env.APP_LOCALE || 'en'
        ),
        'import.meta.env.APP_FALLBACK_LOCALE': JSON.stringify(
            process.env.APP_FALLBACK_LOCALE || 'en'
        ),
    },
});

9. HTTP Endpoint Setup (Laravel)

Add to your routes/web.php or similar:

// Serve processed translation files as static assets
Route::get('/lang/processed_{locale}.json', function ($locale) {
    $path = resource_path("../lang/processed_{$locale}.json");
    
    if (! file_exists($path)) {
        abort(404);
    }
    
    return response()->file($path, [
        'Content-Type' => 'application/json',
        'Cache-Control' => 'public, max-age=31536000, immutable',
    ]);
})->where('locale', '[a-z]+');

Or better, serve as static files from public/lang/:

# Copy processed files to public after build
cp lang/processed_*.json public/lang/

10. Complete Setup Checklist

  • Install dependencies: npm install i18next i18next-http-backend react-i18next i18next-resources-to-backend php-array-reader
  • Create app/Enums/Locale.php
  • Create app/Http/Middleware/HandleLocale.php
  • Register middleware in bootstrap/app.php
  • Create resources/js/lib/i18n.client.ts
  • Create resources/js/lib/i18n.server.ts
  • Create resources/js/hooks/use-locale.tsx
  • Create resources/js/plugins/laravel-i18n.ts
  • Update vite.config.js with plugin
  • Create resources/js/components/locale-toggle.tsx
  • Update resources/js/app.tsx with i18n provider
  • Update resources/js/ssr.tsx with i18n provider
  • Update resources/js/types/global.ts with locale type
  • Create lang/*/ directories and translation files
  • Set up .env variables
  • Set up HTTP endpoint for translation files
  • Test language switching

11. Troubleshooting

Q: Translations not loading?

  • Check that /lang/processed_*.json files are being generated
  • Verify HTTP endpoint returns correct JSON
  • Check browser Network tab for 404 errors

Q: Language doesn't switch?

  • Ensure useTranslation() hook is used in components
  • Check that updateLocale() is being called
  • Verify localStorage and cookies are not blocked

Q: SSR hydration mismatch?

  • Ensure both client and server initialize with same locale
  • Check that initI18n() is called before render
  • Verify pageProps.locale is passed correctly

Q: Pluralization not working?

  • Check translation key has _one and _other suffixes
  • Ensure count parameter is passed to t() function
  • Verify plugin is converting Laravel syntax correctly

12. Quick Migration Path

If you have existing hard-coded strings:

// Before
<button>Submit</button>

// After
import { useTranslation } from 'react-i18next';

export function MyButton() {
    const { t } = useTranslation();
    return <button>{t('buttons.submit')}</button>;
}

Add to translation files:

{
    "buttons.submit": "Submit"
}

That's it! You now have a complete, production-ready localization system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment