BETA

Internationalization (i18n)

BUILT-IN

Harpy.js comes with built-in internationalization support, unlike many frameworks that require third-party libraries. Build multilingual applications with ease using our dictionary-based translation system.

🌍 Why Built-in i18n Matters: Many modern frameworks overlook internationalization, forcing developers to integrate complex third-party solutions. Harpy.js includes i18n as a core feature, providing seamless multilingual support out of the box.

✨ Key Features: Type-safe translations, automatic locale detection, dictionary caching, server and client support, and zero configuration required.

Why Harpy.js Has Built-in i18n

Unlike popular frameworks such as Next.js (which only added i18n routing in v10+) or Create React App (which requires react-i18next or similar), Harpy.js includes internationalization as a fundamental feature from day one.

Other Frameworks

  • • Require external libraries (react-intl, i18next)
  • • Complex setup and configuration
  • • Multiple competing solutions
  • • Additional bundle size
  • • Inconsistent patterns across projects

Harpy.js

  • • Built into the core framework
  • • Zero configuration needed
  • • Consistent approach across all projects
  • • Optimized and lightweight
  • • Type-safe by design

Setting Up i18n

i18n is already configured in your Harpy.js project! Just add your translation dictionaries and start using them.

1. Create Dictionary Files

Create JSON files for each language in the src/dictionaries/ directory:

src/dictionaries/en.json

{
  "welcome": "Welcome",
  "home": "Home",
  "about": "About",
  "hero": {
    "title": "Welcome to Harpy",
    "subtitle": "A powerful NestJS + React framework",
    "description": "Built for speed, precision, and adaptability"
  },
  "features": {
    "title": "Why Choose Harpy?",
    "lightning": {
      "title": "Lightning Fast",
      "description": "Optimized SSR with automatic hydration"
    }
  },
  "demo": {
    "title": "Try It Out",
    "counter": "Counter",
    "clicks": "clicks"
  }
}

src/dictionaries/fr.json

{
  "welcome": "Bienvenue",
  "home": "Accueil",
  "about": "À propos",
  "hero": {
    "title": "Bienvenue sur Harpy",
    "subtitle": "Un puissant framework NestJS + React",
    "description": "Conçu pour la vitesse, la précision et l'adaptabilité"
  },
  "features": {
    "title": "Pourquoi choisir Harpy?",
    "lightning": {
      "title": "Ultra Rapide",
      "description": "SSR optimisé avec hydratation automatique"
    }
  },
  "demo": {
    "title": "Essayez-le",
    "counter": "Compteur",
    "clicks": "clics"
  }
}

2. Configure Dictionary Loader

Update src/i18n/get-dictionary.ts to include your languages:

const dictionaries = {
  en: () =>
    import('../dictionaries/en.json', { with: { type: 'json' } }).then(
      (module) => module.default,
    ),
  fr: () =>
    import('../dictionaries/fr.json', { with: { type: 'json' } }).then(
      (module) => module.default,
    ),
  es: () =>
    import('../dictionaries/es.json', { with: { type: 'json' } }).then(
      (module) => module.default,
    ),
};

export type Dictionary = Awaited<ReturnType<typeof dictionaries.en>>;

const dictionaryCache = new Map<string, Dictionary>();

export const getDictionary = async (locale: string): Promise<Dictionary> => {
  if (dictionaryCache.has(locale)) {
    return dictionaryCache.get(locale)!;
  }

  const dict = await (dictionaries[locale as keyof typeof dictionaries]?.() ?? 
                      dictionaries.en());
  
  dictionaryCache.set(locale, dict);
  return dict;
};

💡 Performance Tip: Dictionaries are automatically cached in memory after first load, ensuring fast translation lookups on subsequent requests.

Server-Side Translations

Load translations in your controllers and pass them to your views. Harpy.js handles the rest automatically.

In Your Controller

import { Controller, Get } from '@nestjs/common';
import { JsxRender } from '@hepta-solutions/harpy-core';
import HomePage from './views/homepage';
import { getDictionary } from '../i18n/get-dictionary';

@Controller()
export class HomeController {
  @Get()
  @JsxRender(HomePage)
  async home() {
    // Load dictionary for current locale
    const dict = await getDictionary('en');
    
    return {
      dict,
      locale: 'en',
    };
  }

  @Get(':locale')
  @JsxRender(HomePage)
  async homeWithLocale(@Param('locale') locale: string) {
    // Dynamic locale from URL
    const dict = await getDictionary(locale);
    
    return {
      dict,
      locale,
    };
  }
}

In Your View Component

import { Dictionary } from '../i18n/get-dictionary';

export interface PageProps {
  dict: Dictionary;
  locale: string;
}

export default function HomePage({ dict, locale }: PageProps) {
  return (
    <div>
      <h1>{dict.hero.title}</h1>
      <p>{dict.hero.subtitle}</p>
      <p>{dict.hero.description}</p>
      
      <section>
        <h2>{dict.features.title}</h2>
        <div>
          <h3>{dict.features.lightning.title}</h3>
          <p>{dict.features.lightning.description}</p>
        </div>
      </section>
    </div>
  );
}

✅ Type Safety: The Dictionary type is automatically inferred from your English dictionary, providing autocomplete and type checking for all translation keys!

Client-Side Translations

For interactive components marked with 'use client', use the useI18n hook to access translations and switch locales.

Creating a Language Switcher

'use client';

import { useI18n } from '@hepta-solutions/harpy-core/client';

export function LanguageSwitcher() {
  const { locale, switchLocale, t } = useI18n();

  return (
    <div className="flex gap-2">
      <button
        onClick={() => switchLocale('en')}
        className={`px-3 py-1 rounded ${
          locale === 'en' 
            ? 'bg-blue-600 text-white' 
            : 'bg-gray-200 text-gray-700'
        }`}
      >
        English
      </button>
      <button
        onClick={() => switchLocale('fr')}
        className={`px-3 py-1 rounded ${
          locale === 'fr' 
            ? 'bg-blue-600 text-white' 
            : 'bg-gray-200 text-gray-700'
        }`}
      >
        Français
      </button>
    </div>
  );
}

Using Translations in Client Components

'use client';

import { useI18n } from '@hepta-solutions/harpy-core/client';
import { useState } from 'react';

export function Counter() {
  const { t } = useI18n();
  const [count, setCount] = useState(0);

  return (
    <div>
      <h3>{t('demo.counter')}</h3>
      <p>{count} {t('demo.clicks')}</p>
      <button onClick={() => setCount(count + 1)}>
        {t('demo.increment')}
      </button>
    </div>
  );
}

⚠️ Important: When switching locales on the client, the page will reload to fetch the new dictionary from the server. This ensures SSR consistency and optimal performance.

Advanced Features

Locale Detection

You can detect the user's preferred locale from various sources:

import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

@Controller()
export class HomeController {
  @Get()
  @JsxRender(HomePage)
  async home(@Req() req: Request) {
    // 1. From URL parameter
    const localeFromUrl = req.params.locale;
    
    // 2. From cookie
    const localeFromCookie = req.cookies?.locale;
    
    // 3. From Accept-Language header
    const localeFromHeader = req.headers['accept-language']?.split(',')[0]?.split('-')[0];
    
    // 4. Fallback to default
    const locale = localeFromUrl || 
                   localeFromCookie || 
                   localeFromHeader || 
                   'en';
    
    const dict = await getDictionary(locale);
    
    return { dict, locale };
  }
}

Nested Translations

Organize your translations in a nested structure for better maintainability:

{
  "pages": {
    "home": {
      "title": "Home Page",
      "meta": {
        "description": "Welcome to our homepage"
      }
    },
    "about": {
      "title": "About Us",
      "team": {
        "title": "Our Team",
        "members": {
          "ceo": "Chief Executive Officer",
          "cto": "Chief Technology Officer"
        }
      }
    }
  },
  "common": {
    "buttons": {
      "submit": "Submit",
      "cancel": "Cancel",
      "save": "Save"
    },
    "errors": {
      "required": "This field is required",
      "invalid": "Invalid input"
    }
  }
}

Access nested values with dot notation:

<h1>{dict.pages.about.team.title}</h1>
<button>{dict.common.buttons.submit}</button>
<span>{dict.common.errors.required}</span>

Dynamic Values in Translations

Use template strings for dynamic content:

// Dictionary
{
  "greeting": "Hello, {{name}}!",
  "itemsCount": "You have {{count}} items in your cart"
}

// Usage in component
export function Greeting({ dict, userName }: Props) {
  const greeting = dict.greeting.replace('{{name}}', userName);
  
  return <h1>{greeting}</h1>;
}

// Or create a helper function
function t(key: string, values: Record<string, string>) {
  return key.replace(/{{(\w+)}}/g, (_, k) => values[k] || '');
}

<p>{t(dict.itemsCount, { count: '5' })}</p>

Best Practices

📁Organize by Feature

Structure your dictionaries by feature or page for better maintainability:

{
  "home": { ... },
  "about": { ... },
  "products": { ... },
  "common": { ... }
}

🔑Use Descriptive Keys

Choose clear, descriptive keys that indicate context:

❌ Bad

{
  "btn1": "Submit",
  "txt1": "Hello"
}

✅ Good

{
  "submitButton": "Submit",
  "welcomeMessage": "Hello"
}

🌍Keep English as Source of Truth

Always use English (en.json) as your base dictionary. The TypeScript types are derived from it, ensuring all other languages have the same structure.

🚀Leverage Caching

Dictionaries are cached in memory after first load. Don't worry about calling getDictionary multiple times - it's optimized!

✏️Validate Translations

Create a script to ensure all languages have the same keys:

// scripts/validate-translations.ts
const en = require('../src/dictionaries/en.json');
const fr = require('../src/dictionaries/fr.json');

function getKeys(obj: any, prefix = ''): string[] {
  return Object.keys(obj).flatMap(key => {
    const path = prefix ? `${prefix}.${key}` : key;
    return typeof obj[key] === 'object' 
      ? getKeys(obj[key], path) 
      : [path];
  });
}

const enKeys = getKeys(en).sort();
const frKeys = getKeys(fr).sort();

const missing = enKeys.filter(k => !frKeys.includes(k));
if (missing.length) {
  console.error('Missing FR translations:', missing);
  process.exit(1);
}

Migration from Other i18n Libraries

Coming from react-i18next, react-intl, or next-i18next? Here's how Harpy.js i18n compares:

FeatureOther LibrariesHarpy.js
SetupRequires provider, config filesZero config, built-in
Bundle Size10-50KB extra0KB (built-in)
Type SafetyManual types or noneAutomatic from dictionaries
SSR SupportComplex setup requiredNative SSR support
PerformanceRuntime overheadCached & optimized

🎉 That's It!

Internationalization in Harpy.js is simple, powerful, and built right into the framework. No external dependencies, no complex setup, just straightforward multilingual support.

  • Built-in feature, not an afterthought
  • Type-safe translations with autocompletion
  • Server and client support
  • Automatic caching for performance
  • Zero configuration required