מאמרים
בניית פלטפורמת Next.js ב-22 שפות: לקחים מ-GAGA
ניתוב לפי locale, סרבית בכתב לטיני וקירילי, עיצוב עם Intl, גופנים, CI לתרגומים ושערי חליפין עדכניים: לקחים מבניית Next.js ב-22 שפות.
צוות ההנדסה של sigmacode.io10 דקות קריאה
בעמוד הזה (11)
GAGA Menjačnica היא דילרית למט"ח ומתכות יקרות עם כמה סניפים בנובי סאד, סרביה. הצוות שלנו בנה את הפלטפורמה שלהם, menjacnicegaga.rs, עם Next.js ו-React על Vercel. היא רצה ב-22 שפות, כולל סרבית בכתב לטיני וקירילי, גרמנית, סינית, רוסית, טורקית, אוקראינית, יוונית ובולגרית. היא מציגה שערי קנייה ומכירה חיים לצד שערי הייחוס של הבנק הלאומי של סרביה, וכוללת ממיר מטבעות, רשימות שערים להורדה בחמישה פורמטים, מאתר סניפים ומדריכים לזהב וכסף להשקעה.
עשרים ושתיים שפות זו הנקודה שבה בינינו לאומי כבר לא פיצ'ר. בסקייל הזה הוא מעצב את הארכיטקטורה. המאמר הזה מכסה את מה שלדעתנו חשוב כשבונים פלטפורמה כזו. הוא כתוב ל-CTO-ים, בעלי מוצר ומהנדסי frontend שמתכננים אחת כזאת. על הפרויקט עצמו, ראו את מקרה הבוחן של GAGA.
תנו לכל locale כתובת URL משלו#
ההחלטה החשובה ביותר באה ראשונה: כל גרסה שפתית של כל עמוד צריכה כתובת URL יציבה וסריקה משלה. אל תחליפו שפה עם cookie או על ידי ריחוף Accept-Language. מנועי חיפוש לא יכולים לאנדקס את זה, משתמשים לא יכולים לשתף את זה, ו-CDN-ים לא יכולים לשמור את זה במטמון בצורה נקייה.
אנחנו ממליצים על קידומת locale בנתיב, כמו /sr/..., /de/... ו-/zh/.... עם ה-App Router של Next.js, זה אומר סגמנט [locale] בשורש האפליקציה. פרוקסי הבקשות (proxy.ts, בעבר middleware) יכול להפנות ביקור ראשון לברירת מחדל הגיונית על בסיס שפת הדפדפן. משם ואילך, ה-URL הוא מקור האמת היחיד, ומחליף שפה חייב לקשר לאותו עמוד ב-locale האחר, ולא לעמוד הבית של ה-locale האחר.
תכננו את מזהי ה-locale מוקדם והשתמשו בתגי BCP 47 לאורך כל הדרך. סרבית לבדה צריכה שניים, sr-Latn ו-sr-Cyrl. מיפוי שלהם לסגמנטי URL, לערכי hreflang ול-locales של Intl במקום אחד חוסך הרבה צרות בהמשך.
hreflang, x-default ו-sitemaps#
כל עמוד צריך לרשום את כל הגרסאות החלופיות שלו, כולל את עצמו, ולהוסיף x-default למשתמשים שהשפה שלהם לא נתמכת. ב-App Router, ה-metadata API עושה את זה בשבילכם:
// app/[locale]/rates/page.tsx
import type { Metadata } from "next";
const BASE = "https://example.com";
const LOCALES = ["sr-Latn", "sr-Cyrl", "en", "de", "zh", "ru"] as const;
const segment = (l: string) => l.toLowerCase(); // "sr-Latn" -> "sr-latn"
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const path = "/rates";
const languages: Record<string, string> = Object.fromEntries(
LOCALES.map((l) => [l, `${BASE}/${segment(l)}${path}`])
);
languages["x-default"] = `${BASE}/en${path}`;
return {
alternates: { canonical: `${BASE}/${locale}${path}`, languages },
};
}
בנו את ה-sitemap מאותה רשימת locales ואותו רישום routes, עם alternates לכל רשומה. ברגע שתגי hreflang ורשומות ה-sitemap מגיעים מנתיבי קוד נפרדים, הם בסופו של דבר יסתרו זה את זה, ומנועי החיפוש יפסיקו בשקט לסמוך על שניהם.
שני כתבים, שפה אחת#
סרבית נכתבת גם בכתב לטיני וגם בקירילי, ולקוראים רבים יש העדפה חזקה. התייחסו אליהם כשני locales מלאים, לא כ-locale אחד עם מתג תצוגה. לכל אחד יש URL משלו, ערך hreflang משלו ו-attribute lang משלו.
מפתה לכתוב הכול בכתב אחד ולתעתק את השני אוטומטית. לטקסט שוטף, שלב תעתיק שנבדק היטב יכול להיות נקודת פתיחה סבירה. אבל אל תיישמו אותו בעיניים עצומות על הכול:
- שמות עצם ומותגים. שמות חברות, שמות מוצרים ומילים זרות לעיתים קרובות שומרים על הצורה הלטינית שלהם אפילו בטקסט קירילי. ממיר מכני "יעזור" ויהפוך אותם למשהו שאף אחד לא כתב.
- דו-משמעות של דיגרפים. הלטיניים
nj,ljו-džבדרך כלל ממופים לאותיות קיריליות בודדות, אבל לא תמיד. מילים מורכבות ומילים שאולות זרות שוברות את הכלל. מקירילי ללטינית זה דטרמיניסטי. מלטינית לקירילית — לא. - כתובות URL, קודים ומזהים. קודי מטבע כמו
EUR, כתובות אימייל, slugs וכל דבר בתוך placeholders של אינטרפולציה לעולם לא מתעתקים. - חיפוש ומיון. משתמשים עלולים להקליד לטינית בתיבת חיפוש בעמוד קירילי. נרמלו את שני הצדדים לפני שאתם משווים.
מה שאנחנו ממליצים: לאחסן את מקור הקירילית (הכיוון הדטרמיניסטי), או לשמור שני קטלוגים נפרדים, ולשמור רשימת חריגים קטנה שדובר שפת אם אחראי עליה. כל דבר שמכונה הפיקה צריך להיבדק לפני ההשקה.
עצבו מספרים, מטבעות ותאריכים עם Intl#
באתר שערי חליפין, המספרים הם המוצר. סרבית משתמשת בפסיק כמפריד עשרוני, גרמנית מקבצת אלפים עם נקודה, ומשתמשים סיניים מצפים למוסכמות שונות שוב. אל תעצבו ידנית. השתמשו ב-API-ים המובנים של Intl והעבירו את תג ה-locale המלא:
const rateFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
});
const moneyFormatter = (locale: string, currency: string) =>
new Intl.NumberFormat(locale, { style: "currency", currency });
const asOf = (locale: string, date: Date) =>
new Intl.DateTimeFormat(locale, {
dateStyle: "long",
timeStyle: "short",
timeZone: "Europe/Belgrade",
}).format(date);
rateFormatter("sr-Latn").format(117.1234); // "117,1234"
rateFormatter("de").format(117.1234); // "117,1234"
moneyFormatter("en", "EUR").format(1250); // "€1,250.00"
asOf("sr-Cyrl", new Date()); // e.g. "18. септембар 2026. 10:30" (exact output depends on the ICU version)
כמה דברים שכדאי לתכנן להם. הגדירו את אזור הזמן במפורש. עמודים שמרונדרים בשרת אחרת משתמשים באזור הזמן של השרת, שהוא בדרך כלל UTC. צרו formatters פעם אחת והשתמשו בהם מחדש, כי יצירה חוזרת שלהם בטבלה גדולה מצטברת. ושמרו על מספר הספרות העשרוניות גם בשכבת הנתונים וגם ב-UI, כדי שהייצוא והמסך יסכימו.
גופנים וכיסוי גליפים#
עשרים ושתיים שפות משמעותן לטינית, Latin Extended (סרבית, קרואטית, טורקית), קירילית (סרבית, רוסית, אוקראינית, בולגרית), יוונית ותווי CJK. מעט גופני מותג מכסים את כל אלה, ואלה שכן הם גדולים.
מה שחשוב:
- בדקו כיסוי לכל כתב לפני שאתם בוחרים גופן. בדקו מחרוזות אמיתיות, לא "Lorem ipsum". לקירילית סרבית יש אותיות משלה וצורות נטויות מקומיות, לאוקראינית יש אותיות שאין ברוסית, ובולגרית מעדיפה צורות גליפים משלה.
- חתכו subsets לפי כתב וטענו לפי locale.
next/fontתומך ב-subsets כמוlatin,latin-ext,cyrillicו-greek. עמוד גרמני לא צריך להוריד גליפים קיריליים. - אל תאחסנו בעצמכם גופן CJK מלא בכל עמוד. גופנים סיניים יכולים להיות כמה מגה-בייט. מחסנית גופני מערכת ל-CJK, כמו
"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, היא לעיתים קרובות הפשרה הנכונה. - עצבו מחסנית fallback מפורשת והגדירו fallbacks תואמי מדדים, כדי שהטקסט לא יזוז כשגופן הרשת נטען.
זרימת עבודה של תרגום ששורדת 22 שפות#
עם שתיים או שלוש שפות, גיליון אלקטרוני וקצת משמעת מספיקים. עם 22, אתם צריכים pipeline.
קטלוגי הודעות עם מפתחות יציבים. השתמשו בקובץ JSON אחד לכל locale, עם מפתחות שמנוקבים לפי משמעות, כמו rates.table.buy, ולעולם לא לפי טקסט אנגלי. השתמשו ב-ICU MessageFormat לרבים ואינטרפולציה. כללי הרבים שונים מאוד: רוסית, אוקראינית וסרבית מכילות כמה צורות, וסינית בלי צורות בכלל.
שוויון מפתחות ב-CI. מפתח חסר ב-locale אחד הוא הבאג הנפוץ ביותר באתר רב-לשוני, והכי קל לתפוס אותו אוטומטית:
// scripts/check-i18n.ts — run in CI, fail the build on drift
import { readdirSync, readFileSync } from "node:fs";
const dir = "messages";
const flatten = (o: Record<string, unknown>, p = ""): string[] =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object" ? flatten(v as Record<string, unknown>, `${p}${k}.`) : [`${p}${k}`]
);
const load = (f: string) => new Set(flatten(JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))));
const source = load("en.json");
let failed = false;
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "en.json")) {
const keys = load(file);
const missing = [...source].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !source.has(k));
if (missing.length || extra.length) {
failed = true;
console.error(`${file}: missing ${missing.length}, extra ${extra.length}`, { missing, extra });
}
}
process.exit(failed ? 1 : 0);
הרחיבו את אותו סקריפט כדי לבדוק ש-placeholders של אינטרפולציה תואמים בין locales. שם placeholder מתורגם נשבר ב-runtime, לא בזמן ה-build.
תרגום בסיוע AI, עם סקירה אנושית. תרגום מכונה ו-LLM מפיקים היום טיוטות ראשונות טובות, וב-22 שפות זה משנה את הכלכלה. אבל בורסת מטבעות עוסקת בכסף ובאמון. תוויות שערים, הודעות משפטיות ומדריכי השקעה צריכים להיבדק על ידי בן אדם שולף לפני שהם עולים לאוויר. תנו למתרגמים קונטקסט: צילומי מסך, מגבלות תווים ומילון של מונחים קבועים כמו "שער קנייה", "שער מכירה" ו"שער ייחוס".
נתוני זמן אמת בלי ערכים מיושנים#
שערי חליפין משתנים במהלך היום. המלכוד היא לשמור אותם במטמון באגרסיביות של שאר אתר ה-Next.js שמתחיל סטטי.
אסטרטגיות כלליות שאנחנו ממליצים עליהן:
- הפרידו את המעטפת מהנתונים. פריסת העמוד, התרגומים והמדריכים יכולים להיות סטטיים או revalidated לעיתים רחוקות. טבלת השערים צריכה חלון revalidation קצר משלה, או להימשך בצד הלקוח או להיות מוזרמת.
- שמרו על revalidation קצר ומכוון. בחרו חלון שמתאים לתדירות שבה השערים באמת משתנים, ותעדו אותו. איפה שאפשר, השתמשו ב-revalidation על פי דרישה כששערים חדשים מתפרסמים, במקום להסתמך רק על טיימר.
- שמרו במטמון ב-edge בזהירות.
s-maxageקצר עםstale-while-revalidateשומר על עמודים מהירים, אבל ודאו שחלון ה-stale הוא כזה שהעסק יכול לקבל. - הציגו תמיד חותמת "נכון ל-", מעוצבת לפי locale באזור הזמן של הסניף. זו התשובה הכנה לשאלה שכל מערכת caching מעלה: כמה טרי זה?
מקור אמת אחד ל-PDF, Excel, CSV ו-XML#
GAGA מפרסמת את רשימת השערים שלה להורדה כ-PDF, JPG, Excel, CSV ו-XML. חמישה פורמטים יוצרים חמש הזדמנויות שהמספרים לא יסכימו.
הכלל: בנו כל ייצוא מאותו מבנה נתונים מנורמל, אותו אחד שמרנדר את הטבלה על המסך. שימו עיגול, סידור ו-metadata של מטבע במבנה הזה, לא בכל exporter. כל פורמט הופך אז ל-serializer דק. דברים שכדאי לתכנן להם:
- CSV צריך מפריד וקידוד מוצהרים. Excel בהרבה locales אירופיים מצפה לנקודה-פסיק ומטפל ב-UTF-8 טוב יותר עם BOM.
- Excel צריך לקבל תאים מספריים אמיתיים עם פורמטי מספרים, לא מחרוזות מעוצבות מראש, כדי שמשתמשים יוכלו לחשב איתם.
- XML צריך סכמה יציבה ומתועדת, כי מישהו ישתלב איתה.
- PDF ותמונות חייבים להטמיע גופנים שמכסים את הכתב המבוקש, מה שמחזיר אתכם לכיסוי הגליפים.
שימו את חותמת ה"נכון ל-" בכל ייצוא גם כן.
SEO על פני locales רבים#
מעבר ל-hreflang ו-sitemaps:
- תרגמו כותרות, תיאורים ו-metadata של Open Graph לכל locale. אל תשאירו metadata אנגלית על עמוד יווני.
- לוקלזו slugs רק אם אתם יכולים לשמור אותם יציבים. slug ששונה ב-locale אחד אומר הפניות ואשכול hreflang שבור.
- הימנעו מכפילויות דלות. אם ל-locale יש רק תוכן חלקי, שקלו אם הוא צריך להיות מאונדקס בכלל.
- כתובות canonical צריכות להצביע על העמוד עצמו, לעולם לא על גרסה בשפה אחרת.
נגישות#
הגדירו lang על אלמנט ה-html לכל locale, עם התג המלא (sr-Latn, sr-Cyrl), כדי שקוראי מסך יבחרו את הקול וההגייה הנכונים. כשביטוי בשפה אחרת מופיע בתוך עמוד, סמנו אותו עם lang משלו.
אפילו אם אתם לא תומכים בשפה מימין לשמאל היום, תכננו לזה. השתמשו במאפייני CSS לוגיים כמו margin-inline-start במקום margin-left, וגזרו את dir מה-locale. הוספת ערבית מאוחר יותר זולה בהרבה כשהפריסה לא מניחה שמאל-לימין.
בדיקות: routes × locales#
עם 22 locales, באג שמופיע רק באחד מהם קל לפספס. אנחנו ממליצים על smoke test אוטומטי שעובר על כל route ציבורי וכל locale ובודק את הבסיס:
- העמוד מחזיר 200 ומרונדר בלי שגיאות runtime;
- ל-
htmlיש את ה-langהנכון; - ה-alternates של hreflang מלאים ומצביעים על כתובות URL קיימות;
- אין מפתחות הודעות גולמיים (כמו
rates.table.buy) או מחרוזות ריקות גלויים; - הממיר וטבלת השערים מרנדרים מספרים בפורמט הצפוי.
הוסיפו צילומי מסך ויזואליים לשפות הארוכות ביותר. תוויות גרמניות ויווניות לעיתים קרובות שוברות פריסות שנראו טוב באנגלית.
מה כדאי לתכנן#
אם אתם מתחילים פלטפורמת Next.js רב-לשונית, אלה ההחלטות שצריך לקבל ביום הראשון: אסטרטגיית URL עם קידומת locale, רישום אחד של locales שמניע ניתוב, metadata ו-sitemaps, מדיניות ברורה לכתבים ותעתיק, Intl לכל עיצוב, תוכנית גופנים לכל כתב, בדיקות CI לשוויון תרגומים, מדיניות טריות מפורשת לנתונים חיים, ומקור אמת יחיד לכל ייצוא.
שום דבר מזה לא אקזוטי, אבל הרבה יותר זול לתכנן את זה מראש מאשר להתקין בדיעבד. העבודה שלנו מובלת על ידי tech lead עם 20+ שנות ניסיון, וזה סוג היסודות שאנחנו מתמקדים בו ב-Web & Platforms. אם אתם מתכננים משהו דומה, צרו קשר.