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:
Locale Configuration
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:
{
"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"
}
{
"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 :
Tier 1: Requested Locale
If a translation exists for the requested locale (e.g., en), use it. Priority = 0.
Tier 2: Entity's Default Locale
If no requested locale translation exists, use the entity’s default_locale (stored on profile/story). Priority = 1.
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:
Cookie
Check SITE_LOCALE cookie (set by locale switcher)
Accept-Language Header
Parse Accept-Language HTTP header (server-side) or navigator.language (client-side)
Domain Default
Use custom domain’s default locale (if configured)
System Default
Fall back to DEFAULT_LOCALE (English)
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 :
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:
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
Always use 3-tier fallback in SQL
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
)
Translate all 13 locales when adding keys
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
Trim CHAR(12) locale codes in Go
PostgreSQL pads CHAR fields with spaces. Always trim when mapping: LocaleCode : strings . TrimRight ( row . LocaleCode , " " ),
Use locale-aware formatting for dates/numbers
Always test UI with Arabic (ar) to ensure RTL layout works correctly. Use dir="rtl" in HTML.
Troubleshooting
Profile/story disappears when changing locale
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.
Locale codes don't match in database
Ensure you’re trimming CHAR(12) fields: strings . TrimRight ( row . LocaleCode , " " )
PostgreSQL stores 'en' as 'en ' (right-padded to 12 characters).
Translation keys showing instead of text
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