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.
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
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/coredirectly 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
Core i18n library (framework-agnostic)
@bf-i18n/react
React integration (hooks, components, HOC)
@bf-i18n/vue
Vue integration (plugin, composables, directive)
@bf-i18n/cli
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…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"
Laravel mode
return [
'greeting' => 'Hello, :name!',
'items' => '{0} No items|{1} 1 item|[2,*] :count items',
];
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!"
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>
);
}
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>
Same API. That's the point.
Browser Locale Detection
Auto-detect from navigator.languages:
const i18n = createI18n({
defaultLocale: 'en',
detectBrowserLocale: true,
translations: { /* ... */ },
});
Missing Key Tracking
Catches missing translations during development:
i18n.t('missing.key'); // Returns "missing.key" as-is
if (i18n.hasMissingKeys()) {
console.warn(i18n.getMissingKeys());
}
CLI: Key Extraction
Extract t('key') calls from your source code:
bf-i18n extract ./src --output keys.json
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)
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');
}
}
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;
}
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
);
// ...
}
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)