Skip to main content

Internationalization

Aya provides comprehensive internationalization (i18n) support with 13 locales out of the box. Content, UI, and all user-facing text can be translated and displayed in multiple languages with intelligent fallback mechanisms.

Supported Locales

Aya supports 13 locales across multiple language families:

English

en (default)

Turkish

tr

French

fr

German

de

Spanish

es

Portuguese

pt-PT

Italian

it

Dutch

nl

Japanese

ja

Korean

ko

Russian

ru

Chinese

zh-CN

Arabic

ar (RTL)

Locale Configuration

src/lib/locale-utils.ts
export const SUPPORTED_LOCALES = [
  "en", "tr", "fr", "de", "es", "pt-PT",
  "it", "nl", "ja", "ko", "ru", "zh-CN", "ar"
] as const;

export type SupportedLocaleCode = (typeof SUPPORTED_LOCALES)[number];

export const DEFAULT_LOCALE: SupportedLocaleCode = "en";
export const FALLBACK_LOCALE: SupportedLocaleCode = "en";

export const supportedLocales: Record<SupportedLocaleCode, Locale> = {
  en: {
    code: "en",
    name: "English",
    englishName: "English",
    flag: "🇺🇸",
    dir: "ltr",
  },
  ar: {
    code: "ar",
    name: "العربية",
    englishName: "Arabic",
    flag: "🇸🇦",
    dir: "rtl", // Right-to-left
  },
  // ... other locales
};
RTL Support: Arabic (ar) uses right-to-left text direction. The frontend automatically applies dir="rtl" to the HTML element.

Frontend i18n

The frontend uses i18next for UI translations.

Translation Files

All translations live in src/messages/{locale}.json:
src/messages/en.json
{
  "Navigation.Home": "Home",
  "Navigation.Articles": "Articles",
  "Navigation.Events": "Events",
  "Profile.Edit": "Edit Profile",
  "Profile.Follow": "Follow",
  "Profile.Following": "Following",
  "Story.ReadMore": "Read more",
  "Story.PublishedAt": "Published {{date}}",
  "Comment.Reply": "Reply",
  "Comment.Delete": "Delete"
}
src/messages/tr.json
{
  "Navigation.Home": "Ana Sayfa",
  "Navigation.Articles": "Makaleler",
  "Navigation.Events": "Etkinlikler",
  "Profile.Edit": "Profili Düzenle",
  "Profile.Follow": "Takip Et",
  "Profile.Following": "Takip Ediliyor",
  "Story.ReadMore": "Devamını oku",
  "Story.PublishedAt": "Yayınlanma {{date}}",
  "Comment.Reply": "Yanıtla",
  "Comment.Delete": "Sil"
}
NEVER put English text in non-English locale files. Every value must be properly translated to that locale’s language. When adding new keys, translate them for ALL 13 locales.

Translation Keys Convention

Translation keys use English text as the key:
import { useTranslation } from "@/modules/i18n/context";

function Component() {
  const { t } = useTranslation();

  return (
    <button>{t("Profile.Follow", "Follow")}</button>
  );
}
Pattern: t("Section.Key", "English fallback text")
  • First argument: Namespaced key (e.g., Profile.Follow)
  • Second argument: English fallback text (used if translation missing)

i18n Configuration

src/modules/i18n/config.ts
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import en from "@/messages/en.json";
import tr from "@/messages/tr.json";
// ... import all locales

i18next
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: en },
      tr: { translation: tr },
      // ... all locales
    },
    lng: "en", // Default language
    fallbackLng: "en",
    interpolation: {
      escapeValue: false, // React already escapes
    },
  });

Locale Switching

src/components/locale-switcher.tsx
import { useNavigate, useParams } from "@tanstack/react-router";
import { SUPPORTED_LOCALES, supportedLocales } from "@/config";

export function LocaleSwitcher() {
  const { locale } = useParams({ from: "/$locale" });
  const navigate = useNavigate();

  const handleLocaleChange = (newLocale: string) => {
    // Redirect to same path with new locale
    navigate({
      to: window.location.pathname.replace(`/${locale}/`, `/${newLocale}/`),
    });
  };

  return (
    <select value={locale} onChange={(e) => handleLocaleChange(e.target.value)}>
      {SUPPORTED_LOCALES.map((code) => (
        <option key={code} value={code}>
          {supportedLocales[code].flag} {supportedLocales[code].name}
        </option>
      ))}
    </select>
  );
}

Backend i18n

Database Locale Storage

Locale codes are stored as CHAR(12) in PostgreSQL to accommodate longer codes like pt-PT:
CREATE TABLE "profile_tx" (
  "profile_id" CHAR(26),
  "locale_code" CHAR(12) NOT NULL,  -- Right-padded with spaces!
  "title" TEXT,
  "description" TEXT,
  PRIMARY KEY ("profile_id", "locale_code")
);
CRITICAL: PostgreSQL pads CHAR(12) with spaces. Always use strings.TrimRight(value, " ") when mapping to Go strings:
profile := &profiles.Profile{
    LocaleCode: strings.TrimRight(row.LocaleCode, " "),
    Title:      strings.TrimRight(row.Title, " "),
}

3-Tier Locale Fallback Pattern

This is the most critical i18n pattern in Aya. ALL queries on _tx tables MUST use the 3-tier fallback.

The Problem with 2-Tier Fallback

WRONG (2-tier fallback — drops entities when default_locale doesn’t match):
-- BAD: Only returns profiles that have English OR their default_locale translation
SELECT p.*, pt.*
FROM "profile" p
JOIN "profile_tx" pt ON pt.profile_id = p.id
WHERE (pt.locale_code = 'en' OR pt.locale_code = p.default_locale)
ORDER BY CASE WHEN pt.locale_code = 'en' THEN 0 ELSE 1 END;
Problem: If a profile has translations for tr and fr, but not en or its default_locale, it will disappear from results.

The Solution: 3-Tier Fallback Subquery

CORRECT (3-tier fallback — always finds a translation if one exists):
apps/services/etc/data/default/queries/profiles.sql
-- name: GetProfileBySlug :one
SELECT 
  p.id, p.slug, p.kind, p.profile_picture_uri,
  pt.locale_code, pt.title, pt.description
FROM "profile" p
JOIN "profile_tx" pt ON pt.profile_id = p.id
WHERE p.slug = sqlc.arg(slug)
  -- 3-TIER FALLBACK SUBQUERY
  AND pt.locale_code = (
    SELECT ptx.locale_code FROM "profile_tx" ptx
    WHERE ptx.profile_id = p.id
    ORDER BY CASE
      WHEN ptx.locale_code = sqlc.arg(locale_code) THEN 0  -- Tier 1: Requested locale
      WHEN ptx.locale_code = p.default_locale THEN 1       -- Tier 2: Entity's default
      ELSE 2                                                -- Tier 3: Any available
    END
    LIMIT 1
  )
LIMIT 1;
How it works:
1

Tier 1: Requested Locale

If a translation exists for the requested locale (e.g., en), use it. Priority = 0.
2

Tier 2: Entity's Default Locale

If no requested locale translation exists, use the entity’s default_locale (stored on profile/story). Priority = 1.
3

Tier 3: Any Available

If neither exists, use ANY available translation. Priority = 2.
Result: The query ALWAYS returns a translation if the entity has at least one, ensuring no content disappears.

Story Locale Fallback

For stories, the default locale reference is the author profile’s default:
apps/services/etc/data/default/queries/stories.sql
-- name: GetStoryBySlug :one
SELECT 
  s.id, s.slug, s.kind, s.cover_picture_uri,
  st.locale_code, st.title, st.summary, st.content
FROM "story" s
JOIN "story_tx" st ON st.story_id = s.id
WHERE s.slug = sqlc.arg(slug)
  AND st.locale_code = (
    SELECT stx.locale_code FROM "story_tx" stx
    WHERE stx.story_id = s.id
    ORDER BY CASE
      WHEN stx.locale_code = sqlc.arg(locale_code) THEN 0
      -- Use author profile's default locale as fallback
      WHEN stx.locale_code = (
        SELECT p.default_locale FROM "profile" p WHERE p.id = s.author_profile_id
      ) THEN 1
      ELSE 2
    END
    LIMIT 1
  )
LIMIT 1;

Profile Page Fallback

-- name: GetProfilePage :one
SELECT 
  pp.id, pp.slug, pp.cover_picture_uri,
  ppt.locale_code, ppt.title, ppt.summary, ppt.content
FROM "profile_page" pp
JOIN "profile_page_tx" ppt ON ppt.profile_page_id = pp.id
JOIN "profile" p ON p.id = pp.profile_id
WHERE pp.profile_id = sqlc.arg(profile_id)
  AND pp.slug = sqlc.arg(page_slug)
  AND ppt.locale_code = (
    SELECT pptx.locale_code FROM "profile_page_tx" pptx
    WHERE pptx.profile_page_id = pp.id
    ORDER BY CASE
      WHEN pptx.locale_code = sqlc.arg(locale_code) THEN 0
      WHEN pptx.locale_code = p.default_locale THEN 1  -- Profile's default
      ELSE 2
    END
    LIMIT 1
  )
LIMIT 1;

Auto-Translation

Aya includes AI-powered auto-translation for profile pages:
// Auto-translate a profile page to all supported locales
await backend.autoTranslateProfilePage(
  locale,
  profileSlug,
  pageSlug
);

// Backend uses AI adapter to translate content
// Creates profile_page_tx entries for each locale
pkg/api/business/profiles/auto_translate.go
package profiles

import "context"

func AutoTranslatePage(
    ctx context.Context,
    repo Repository,
    aiService AIService,
    pageID string,
    sourceLocale string,
) error {
    page, _ := repo.GetPageByID(ctx, pageID, sourceLocale)

    targetLocales := []string{"en", "tr", "fr", "de", "es", "pt-PT", "it", "nl", "ja", "ko", "ru", "zh-CN", "ar"}

    for _, targetLocale := range targetLocales {
        if targetLocale == sourceLocale {
            continue // Skip source locale
        }

        // Call AI service for translation
        translated, _ := aiService.Translate(ctx, AITranslateRequest{
            SourceLocale: sourceLocale,
            TargetLocale: targetLocale,
            Title:        page.Title,
            Summary:      page.Summary,
            Content:      page.Content,
        })

        // Save translated version
        _ = repo.CreatePageTranslation(ctx, &ProfilePageTranslation{
            ProfilePageID: pageID,
            LocaleCode:    targetLocale,
            Title:         translated.Title,
            Summary:       translated.Summary,
            Content:       translated.Content,
        })
    }

    return nil
}

Locale Detection

Aya auto-detects user’s preferred locale with the following priority:
1

Cookie

Check SITE_LOCALE cookie (set by locale switcher)
2

Accept-Language Header

Parse Accept-Language HTTP header (server-side) or navigator.language (client-side)
3

Domain Default

Use custom domain’s default locale (if configured)
4

System Default

Fall back to DEFAULT_LOCALE (English)
src/router.tsx
function detectPreferredLocale(requestContext: RequestContext | undefined): SupportedLocaleCode {
  // 1. Check cookie
  const cookieHeader = requestContext?.cookieHeader;
  if (cookieHeader !== undefined) {
    const match = cookieHeader.match(/(?:^|;\s*)SITE_LOCALE=([^;]+)/);
    if (match !== null && isValidLocale(match[1])) {
      return match[1];
    }
  }

  // 2. Check Accept-Language
  const acceptLanguage = requestContext?.acceptLanguageHeader;
  if (acceptLanguage !== undefined) {
    const matched = parseAcceptLanguage(acceptLanguage);
    if (matched !== null) return matched;
  }

  // 3. Domain default (if custom domain)
  const domainConfig = requestContext?.domainConfiguration;
  if (domainConfig?.defaultCulture !== undefined) {
    if (isValidLocale(domainConfig.defaultCulture)) {
      return domainConfig.defaultCulture;
    }
  }

  // 4. System default
  return DEFAULT_LOCALE;
}

URL Structure

All user-facing routes are prefixed with the locale:
/{locale}               → Home page
/{locale}/articles      → Article listing
/{locale}/eser          → Profile page
/{locale}/eser/qa       → Profile Q&A
/{locale}/stories/{slug} → Story detail
Root path behavior:
src/routes/index.tsx
export const Route = createFileRoute("/")({
  loader: async ({ context }) => {
    const preferredLocale = detectPreferredLocale(context.requestContext);

    // Redirect root to /{locale}
    throw redirect({
      to: "/$locale",
      params: { locale: preferredLocale },
    });
  },
});
Visiting https://aya.is/ redirects to https://aya.is/en/ (or user’s preferred locale).

Date & Time Localization

Use Intl.DateTimeFormat for locale-aware date formatting:
src/lib/date.ts
export function formatDate(date: string | Date, locale: string): string {
  const dateObj = typeof date === "string" ? new Date(date) : date;

  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(dateObj);
}

export function formatRelativeTime(date: string | Date, locale: string): string {
  const dateObj = typeof date === "string" ? new Date(date) : date;
  const now = new Date();
  const diffInSeconds = (now.getTime() - dateObj.getTime()) / 1000;

  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });

  if (diffInSeconds < 60) return rtf.format(-Math.floor(diffInSeconds), "second");
  if (diffInSeconds < 3600) return rtf.format(-Math.floor(diffInSeconds / 60), "minute");
  if (diffInSeconds < 86400) return rtf.format(-Math.floor(diffInSeconds / 3600), "hour");
  return rtf.format(-Math.floor(diffInSeconds / 86400), "day");
}
Usage:
formatDate("2024-03-15T10:30:00Z", "en")  // "March 15, 2024"
formatDate("2024-03-15T10:30:00Z", "tr")  // "15 Mart 2024"
formatDate("2024-03-15T10:30:00Z", "ja")  // "2024年3月15日"

formatRelativeTime("2024-03-15T10:30:00Z", "en")  // "2 hours ago"
formatRelativeTime("2024-03-15T10:30:00Z", "tr")  // "2 saat önce"

Best Practices

NEVER use 2-tier fallback with WHERE locale IN (...). Always use the subquery pattern with CASE-based priority.
-- CORRECT
AND tx.locale_code = (
  SELECT ...
  ORDER BY CASE
    WHEN ... THEN 0
    WHEN ... THEN 1
    ELSE 2
  END
  LIMIT 1
)
When adding a new translation key, provide translations for ALL supported locales. Don’t leave English text in non-English files.
// en.json
{"NewFeature.Title": "New Feature"}

// tr.json
{"NewFeature.Title": "Yeni Özellik"}

// fr.json
{"NewFeature.Title": "Nouvelle fonctionnalité"}
// ... all 13 locales
PostgreSQL pads CHAR fields with spaces. Always trim when mapping:
LocaleCode: strings.TrimRight(row.LocaleCode, " "),
Never hardcode date/number formats. Use Intl APIs:
// GOOD
new Intl.NumberFormat(locale).format(1234567.89)
new Intl.DateTimeFormat(locale).format(date)

// BAD
`${month}/${day}/${year}` // US-specific format
Always test UI with Arabic (ar) to ensure RTL layout works correctly. Use dir="rtl" in HTML.

Troubleshooting

Check if you’re using 2-tier fallback instead of 3-tier. Profiles/stories should ALWAYS appear if they have any translation.Use the 3-tier subquery pattern in all _tx table joins.
Ensure you’re trimming CHAR(12) fields:
strings.TrimRight(row.LocaleCode, " ")
PostgreSQL stores 'en' as 'en ' (right-padded to 12 characters).
Missing translation in the locale file. Check src/messages/{locale}.json for the key.Fallback should show English text (second argument to t()).

Next Steps

Frontend Development

Implement i18n in React components

Backend Development

Build locale-aware business logic

Database Guide

Write SQL queries with 3-tier fallback

Profiles

Learn about profile translations

Build docs developers (and LLMs) love