DEV Community

Cover image for I Built an i18n Library That Works with Rails/Laravel and React/Vue
usapopopooon
usapopopooon

Posted on

I Built an i18n Library That Works with Rails/Laravel and React/Vue

The Problem

If you're building a Rails or Laravel backend with a React or Vue SPA frontend, you've probably run into this: managing translation files on both sides is a pain. You've got config/locales/*.yml on the backend and separate translation files on the frontend. Keeping them in sync? Not fun.

I wanted to just use the backend translation files directly in the frontend. And ideally, I wanted something that works across different combinations—so if a project migrates from Rails to Laravel, or React to Vue, the i18n setup doesn't need a complete overhaul.

I looked around, but couldn't find anything that fit. So I built my own.

GitHub logo usapopopooon / bf-i18n

A lightweight i18n library that reads Rails/Laravel i18n formats and makes them usable in JavaScript/TypeScript applications including React and Vue.

bf-i18n

CI npm version License: MIT

bf-i18n logo

A lightweight i18n library that reads Rails/Laravel i18n formats and makes them usable in JavaScript/TypeScript applications including React and Vue.

Features

  • Multi-format support: Rails (%{variable}, key-based pluralization) and Laravel (:variable, pipe-based pluralization)
  • Framework integrations: React hooks/components, Vue composables/directives
  • Vanilla JS/TS ready: Use @bf-i18n/core directly without any framework
  • TypeScript first: Full type safety with Zod runtime validation
  • Lightweight: Minimal dependencies, tree-shakeable
  • Browser locale detection: Automatic locale detection from browser settings
  • Missing key tracking: Debug and identify untranslated strings
  • CLI tools: Parse, validate, convert, and extract translation keys

Packages































Package npm Description
@bf-i18n/core npm Core i18n library (framework-agnostic)
@bf-i18n/react npm React integration (hooks, components, HOC)
@bf-i18n/vue npm Vue integration (plugin, composables, directive)
@bf-i18n/cli npm CLI tool for translation management

Installation

# Core only (vanilla JS/TS)
npm install @bf-i18n/core
# React
npm install @bf-i18n/react

# Vue
npm install @bf-i18n/vue

# CLI tool
npm install
Enter fullscreen mode Exit fullscreen mode

What's Already Out There

Rails + JavaScript

i18n-js (fnando)

Exports Rails YAML files for use in JavaScript. Has both a gem and npm package.

https://www.npmjs.com/package/i18n-js

Solid library, but no React or Vue hooks out of the box. You'd need to wrap it yourself.

@shopify/react-i18n (deprecated)

Shopify's React i18n library with Rails-compatible API.

https://www.npmjs.com/package/@shopify/react-i18n

Optimized for Shopify's ecosystem, doesn't work with Laravel. Also, it's now deprecated.

Laravel + React

laravel-react-i18n

Laravel + React specific. Has a Vite plugin and useLaravelReactI18n hook.

https://www.npmjs.com/package/laravel-react-i18n

Pretty good, but Laravel-only.

Laravel + Vue

laravel-vue-i18n

Laravel + Vue3 specific. Also has Vite plugin and composables.

https://www.npmjs.com/package/laravel-vue-i18n

Also good, but locked to Laravel + Vue.

Summary

Stack Library
Rails + React i18n-js + DIY wrapper, @shopify/react-i18n (deprecated)
Laravel + React laravel-react-i18n
Laravel + Vue laravel-vue-i18n
Rails + Vue Couldn't find one

Nothing that handles both Rails/Laravel backends AND both React/Vue frontends with a consistent API.

What I Built

bf-i18n — the bf stands for "backend-friendly."

Key features:

  • Works with both Rails-style and Laravel-style translation files
  • Same API for React and Vue, so switching frameworks doesn't mean relearning
  • Hooks and composables included out of the box
  • Monorepo structure — install only what you need

Packages

Package Description
@bf-i18n/core Core functionality
@bf-i18n/react React bindings
@bf-i18n/vue Vue bindings
@bf-i18n/cli CLI tools

Usage

Modes

Switch between Rails and Laravel formats with the mode option.

Rails mode

en:
  greeting: "Hello, %{name}!"
  items:
    zero: "No items"
    one: "1 item"
    other: "%{count} items"
Enter fullscreen mode Exit fullscreen mode

Laravel mode

return [
    'greeting' => 'Hello, :name!',
    'items' => '{0} No items|{1} 1 item|[2,*] :count items',
];
Enter fullscreen mode Exit fullscreen mode

Basic Usage

import { createI18n } from '@bf-i18n/core';

const i18n = createI18n({
  defaultLocale: 'en',
  mode: 'rails',
  translations: {
    en: {
      greeting: 'Hello, %{name}!',
    },
    ja: {
      greeting: 'こんにちは、%{name}さん!',
    },
  },
});

i18n.t('greeting', { name: 'World' }); // "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

React

import { I18nProvider, useTranslation } from '@bf-i18n/react';

function MyComponent() {
  const { t, setLocale } = useTranslation();

  return (
    <div>
      <p>{t('greeting', { name: 'React' })}</p>
      <button onClick={() => setLocale('ja')}>日本語</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Vue

<script setup>
import { useTranslation } from '@bf-i18n/vue';

const { t, setLocale } = useTranslation();
</script>

<template>
  <p>{{ t('greeting', { name: 'Vue' }) }}</p>
  <button @click="setLocale('ja')">日本語</button>
</template>
Enter fullscreen mode Exit fullscreen mode

Same API. That's the point.

Browser Locale Detection

Auto-detect from navigator.languages:

const i18n = createI18n({
  defaultLocale: 'en',
  detectBrowserLocale: true,
  translations: { /* ... */ },
});
Enter fullscreen mode Exit fullscreen mode

Missing Key Tracking

Catches missing translations during development:

i18n.t('missing.key'); // Returns "missing.key" as-is

if (i18n.hasMissingKeys()) {
  console.warn(i18n.getMissingKeys());
}
Enter fullscreen mode Exit fullscreen mode

CLI: Key Extraction

Extract t('key') calls from your source code:

bf-i18n extract ./src --output keys.json
Enter fullscreen mode Exit fullscreen mode

Uses regex, so dynamically constructed keys won't be caught.

How It Works

The core package has four classes:

I18n (main)
├── Translator (key resolution, fallbacks)
├── Interpolator (variable substitution)
└── Pluralizer (pluralization)
Enter fullscreen mode Exit fullscreen mode

Interpolation

Rails uses %{variable}, Laravel uses :variable. The regex is built dynamically based on mode:

// interpolator.ts
private buildPattern(): RegExp {
  const { prefix, suffix } = this.options;

  if (suffix) {
    // Rails: %{variable}
    const escapedPrefix = this.escapeRegExp(prefix);
    const escapedSuffix = this.escapeRegExp(suffix);
    return new RegExp(`${escapedPrefix}([a-zA-Z_][a-zA-Z0-9_]*)${escapedSuffix}`, 'g');
  } else {
    // Laravel: :variable
    const escapedPrefix = this.escapeRegExp(prefix);
    return new RegExp(`${escapedPrefix}([a-zA-Z_][a-zA-Z0-9_]*)`, 'g');
  }
}
Enter fullscreen mode Exit fullscreen mode

Pluralization

Rails uses zero/one/other keys. Laravel uses pipe-delimited strings like {0} None|{1} One|[2,*] Many. Both use Intl.PluralRules under the hood:

// pluralizer.ts
resolveKeyBased(
  translations: Record<string, TranslationValue>,
  count: number
): TranslationValue | undefined {
  // Prioritize "zero" key for count=0 (Rails compatibility)
  if (count === 0 && 'zero' in translations) {
    return translations['zero'];
  }

  const category = this.getPluralCategory(count) as PluralCategory;

  if (category in translations) {
    return translations[category];
  }

  // Fallback to "other"
  if ('other' in translations) {
    return translations['other'];
  }

  return undefined;
}
Enter fullscreen mode Exit fullscreen mode

React Re-rendering

Locale changes should trigger re-renders. Using useSyncExternalStore with the I18n class's onChange subscription:

// hooks.ts
export function useTranslation(options: UseTranslationOptions = {}): UseTranslationReturn {
  const i18n = useI18n();

  const locale = useSyncExternalStore(
    (callback) => i18n.onChange(callback),
    () => i18n.locale,
    () => i18n.locale
  );

  // ...
}
Enter fullscreen mode Exit fullscreen mode

What's Next

Might add support for Spring or Django formats at some point. They're pretty different from Rails/Laravel though, so no promises.

Top comments (0)