Multilingual (i18n) website implementation

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1215
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1043
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

Implementing website multilingual support (i18n)

Multilingual support is not "just plug in a translator". It's an architectural decision that affects URL structure, content storage in database, SEO, caching and deployment. Getting it right the first time is more important than doing it fast.

URL architectural options

Three approaches to organizing URLs for multilingual site:

Strategy Examples When to use
Subdomain ru.example.com, en.example.com Different servers/CDN per region
Path example.com/ru/, example.com/en/ Single server, most cases
Separate domain example.ru, example.com Different legal entities or brands

Path (/ru/, /en/) — most common and simplest to implement.

Server: Laravel + Astrotomic Translatable

composer require astrotomic/laravel-translatable
// database/migrations/..._create_product_translations_table.php
Schema::create('product_translations', function (Blueprint $table) {
    $table->id();
    $table->foreignId('product_id')->constrained()->cascadeOnDelete();
    $table->string('locale', 10)->index();
    $table->string('title');
    $table->text('description')->nullable();
    $table->string('slug')->nullable();
    $table->unique(['product_id', 'locale']);
    $table->timestamps();
});
// app/Models/Product.php
use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract;
use Astrotomic\Translatable\Translatable;

class Product extends Model implements TranslatableContract
{
    use Translatable;

    public array $translatedAttributes = ['title', 'description', 'slug'];

    protected $fillable = ['price', 'sku', 'is_active'];
}
// Creating with translations
Product::create([
    'price'    => 1990,
    'sku'      => 'PROD-001',
    'ru'       => ['title' => 'Умные часы', 'slug' => 'umnye-chasy'],
    'en'       => ['title' => 'Smart Watch', 'slug' => 'smart-watch'],
    'de'       => ['title' => 'Smartwatch', 'slug' => 'smartwatch'],
]);

// Reading by current locale
app()->setLocale('en');
$product->title; // "Smart Watch"

app()->setLocale('ru');
$product->title; // "Умные часы"

Routing with locale prefixes

// routes/web.php
Route::prefix('{locale}')
    ->where(['locale' => 'ru|en|de|fr|uk'])
    ->middleware('setLocale')
    ->group(function () {
        Route::get('/', [HomeController::class, 'index'])->name('home');
        Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
        Route::get('/catalog/{slug}', [ProductController::class, 'show'])->name('product');
    });

// Redirect from / to /ru/ or determined language
Route::get('/', function () {
    return redirect()->route('home', ['locale' => app()->getLocale()]);
});
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next): mixed
{
    $locale = $request->route('locale') ?? session('locale', 'ru');

    if (!in_array($locale, ['ru', 'en', 'de', 'fr', 'uk'])) {
        abort(404);
    }

    App::setLocale($locale);
    session(['locale' => $locale]);

    return $next($request);
}

Frontend: i18next

npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector
// i18n/config.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import HttpBackend from 'i18next-http-backend'
import LanguageDetector from 'i18next-browser-languagedetector'

i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: 'ru',
    supportedLngs: ['ru', 'en', 'de', 'fr', 'uk'],
    ns: ['common', 'catalog', 'checkout'],
    defaultNS: 'common',
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },
    detection: {
      order: ['path', 'cookie', 'localStorage', 'navigator'],
      lookupFromPathIndex: 0,
    },
    interpolation: {
      escapeValue: false, // React already escapes
    },
  })

export default i18n
public/locales/
  ru/
    common.json
    catalog.json
    checkout.json
  en/
    common.json
    catalog.json
    checkout.json
// public/locales/ru/common.json
{
  "nav": {
    "catalog": "Каталог",
    "cart": "Корзина ({{count}})",
    "account": "Личный кабинет"
  },
  "actions": {
    "addToCart": "Добавить в корзину",
    "buyNow": "Купить сейчас"
  },
  "product": {
    "count_one": "{{count}} товар",
    "count_few": "{{count}} товара",
    "count_many": "{{count}} товаров",
    "count_other": "{{count}} товаров"
  }
}
// Using in components
import { useTranslation } from 'react-i18next'

function CatalogHeader({ count }: { count: number }) {
  const { t, i18n } = useTranslation('common')

  return (
    <header>
      <h1>{t('nav.catalog')}</h1>
      <span>{t('product.count', { count })}</span>
      <span>Language: {i18n.language}</span>
    </header>
  )
}

SEO: hreflang, canonical, sitemap

// Generating hreflang for all pages
function hreflangTags(string $routeName, array $params = []): string
{
    $locales = ['ru', 'en', 'de', 'fr', 'uk'];
    $tags = '';

    foreach ($locales as $locale) {
        $url = route($routeName, array_merge($params, ['locale' => $locale]));
        $tags .= "<link rel=\"alternate\" hreflang=\"{$locale}\" href=\"{$url}\" />\n";
    }

    $defaultUrl = route($routeName, array_merge($params, ['locale' => 'ru']));
    $tags .= "<link rel=\"alternate\" hreflang=\"x-default\" href=\"{$defaultUrl}\" />\n";

    return $tags;
}
<!-- sitemap.xml with language alternatives -->
<url>
  <loc>https://example.com/ru/catalog/smart-watch</loc>
  <xhtml:link rel="alternate" hreflang="ru" href="https://example.com/ru/catalog/smart-watch"/>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/catalog/smart-watch"/>
  <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/catalog/smart-watch"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/ru/catalog/smart-watch"/>
</url>

Content translation: workflow

Manual translation via Google Translate API for initial population:

// app/Services/TranslationService.php
use Google\Cloud\Translate\V2\TranslateClient;

class TranslationService
{
    private TranslateClient $client;

    public function __construct()
    {
        $this->client = new TranslateClient([
            'key' => config('services.google_translate.key'),
        ]);
    }

    public function translateBatch(array $texts, string $targetLang, string $sourceLang = 'ru'): array
    {
        $results = $this->client->translateBatch($texts, [
            'source' => $sourceLang,
            'target' => $targetLang,
            'format' => 'html', // preserves HTML tags
        ]);

        return array_column($results, 'text');
    }
}
// artisan command for bulk translation
php artisan translate:products --from=ru --to=en,de,fr,uk --batch=50

Translation caching

Cache translation files in Redis to avoid reading JSON from disk on each request:

// AppServiceProvider
public function boot(): void
{
    if (app()->isProduction()) {
        $this->app->singleton('translator', function ($app) {
            $loader = new CachedTranslationLoader(
                $app['translation.loader'],
                $app['cache.store'],
                ttl: 3600
            );
            return new Translator($loader, $app['config']['app.locale']);
        });
    }
}

Numerals for all languages

// Universal pluralizer via Intl.PluralRules
function createPluralizer(locale: string, forms: Record<string, string>) {
  const rules = new Intl.PluralRules(locale)
  return (n: number) => `${n} ${forms[rules.select(n)] ?? forms.other}`
}

const pluralizers = {
  ru: createPluralizer('ru', { one: 'товар', few: 'товара', many: 'товаров', other: 'товаров' }),
  en: createPluralizer('en', { one: 'item', other: 'items' }),
  de: createPluralizer('de', { one: 'Artikel', other: 'Artikel' }),
  uk: createPluralizer('uk', { one: 'товар', few: 'товари', many: 'товарів', other: 'товарів' }),
}

Timeframe

Setting up i18n infrastructure (routes, middleware, Translatable, i18next) without content translation — 3–4 days. Translating UI to 4–5 languages with automatic initial translation and manual review — another 3–5 days depending on string count. Full launch with SEO configuration and sitemap — 1–1.5 weeks.