Tech stack: React, Next.js, next-i18next

  • _app.tsx
    • Set the whole app receive i18n propreties
  • next-i18next.config.js
    • Edit router for different lang
    • Edit default lang
  • src\i18n\config.ts
    • Edit lang switcher options
  • src\i18n\localests
    • Edit our i18n files in TypeScript
  • src\i18n\generateLocales.js
    • add script in package.json
    • Execute this file to convert our i18n ts files to json files
  • @types\react-i18next.d.ts

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Set the whole app receive i18n propreties

  • add configuration in _app.tsx
import { appWithTranslation } from 'next-i18next'; const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />; export default appWithTranslation(MyApp);

Edit router for different lang, default lang

  • next-i18next.config.js
module.exports = { i18n: { defaultLocale: 'en', locales: ['cn', 'ar', 'en', 'ms', 'id', 'vi', 'zh'], localePath: './src/i18n/locales' }, react: { useSuspense: false } };

Edit lang switcher options

  • src\i18n\config.ts
/** Files to modify locales * next-i18next.config.js * */ /** Please use ISO codes to name locales as possible * https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes * */ import nextI18next from '~/next-i18next.config'; export type Locales = 'cn' | 'ar' | 'en' | 'ms' | 'id' | 'vi' | 'zh'; export const locales: Locales[] = nextI18next.i18n.locales as Locales[]; type localesOptions = { code: Locales; value: string; }; export const localesOptions: localesOptions[] = [ { code: 'cn', value: '简体' }, { code: 'ar', value: 'بالعربية' }, { code: 'en', value: 'English' }, { code: 'ms', value: 'Malay' }, { code: 'id', value: 'Indonesia' }, { code: 'vi', value: 'Việt Nam' }, { code: 'zh', value: '繁體' } ];
  • LangSelector.tsx
    • See below

Edit our i18n files in TypeScript

Because we wanna have type intelligence in our project, but next-i18next yet not support TypeScript, so we write our i18n files in TypeScript, and convert files to json ourselves.

  • src\i18n\localests

Execute this file to convert our i18n ts files to json files

I first wrote this in shell script, but shell commands are different in Linux, Windows and IOS, so I wrote JavaScript version then it's compatible in all above OS.

Basically, it coverts all ts files in src\i18n\localests to json files, and puts all files in src\i18n\locales, you can modify the folder name.

  • src\i18n\generateLocales.js
const des = 'locales'; const src = 'localests'; const outputFormat = '.json'; const desPath = `./${des}`; const srcPath = `./${src}`; const npmScript = 'format'; const { promises: fs, readdirSync, rename, readFile, writeFile } = require('fs-extra'); const { exec } = require('child_process'); /* eslint-disable */ (async () => { // Remove old folder if exist await fs.rmdir(desPath, { recursive: true }, (err) => { if (err) return console.error(err); }); console.log(`1. Remove ${des} folder if exist`); // Copy folder exclude '@types' await copyDir(srcPath, desPath, '@types'); console.log(`2. Copy folder`); let filenames = readdirSync(desPath); for (const file of filenames) { const path = `${desPath}/${file}`; let jsonFiles = readdirSync(path); for (const jsonFile of jsonFiles) { const jsonFilePath = `${path}/${jsonFile}`; // Read file readFile(jsonFilePath, (err, fileBuffer) => { if (err) { console.error(err.message); } const fileContent = fileBuffer.toString(); const contentWeWant = fileContent.match(/(?<= = {)(.*)[^}]*/); const outputContent = `{${contentWeWant[0]}}`; if (contentWeWant[0]) { // Rewrite the file with content we want writeFile(jsonFilePath, outputContent, (err) => { if (err) { return console.log(err); } else { const oldPath = jsonFilePath; const newPath = jsonFilePath.replace(/\.[^.]+$/, outputFormat); // Rename file from whatever to "outputFormat" rename(oldPath, newPath, (err) => { if (err) { return console.log(err); } }); } }); } }); } } console.log('3. Rewrite/Rename file from whatever to .json'); exec(`npm run ${npmScript}`); console.log('4. Format json'); })(); const path = require('path'); const copyDir = async (src, dest, escapeFolder = undefined) => { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (!entry.name.includes(escapeFolder)) { entry.isDirectory() ? await copyDir(srcPath, destPath) : await fs.copyFile(srcPath, destPath); } } };
  • add script in package.json
{ ... "scripts": { ... "locales": "npx prettier --write ./src/i18n/**/*.json" } ... }

LangSelector.tsx

The points we should concern

  • Store user's selection in LocalStorage
  • Browser's function in mobile is different from PC's
  • Browser use ISO code as language code
  • Because we have both traditional Chinese and simplified chinese for the website, so we use 'zh' for traditional Chinese and 'cn' for simplified chinese.

Logic explanation

  • First time or no data in cookie

    • Detect user's browser's language
    • Check if the user is using mobile or PC
    • If browser's language matches the website's language, use it, if not, use default language
  • Have data in cookie

    • Check data in cookie, if value matches the website's language, use the value, if not, then use default language
  • Codes

import { locales, Locales, localesOptions } from '@/i18n/config'; import { getCookie, isUsingMobile, setCookie } from '@/utils'; import { ChevronDownIcon } from '@chakra-ui/icons'; import { Button, Image, Menu, MenuButton, MenuItem, MenuList, Text } from '@chakra-ui/react'; import { i18n } from 'next-i18next'; import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; const LangSelector: React.FC<{}> = () => { useEffect(() => { getCurrentLanguage(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const [language, setLanguage] = useState(i18n?.languages[0]); const router = useRouter(); const currentLang = router.locale as Locales; const getCurrentLanguage = () => { const langInCookie = getCookie('lang') as Locales; if ( langInCookie && locales.includes(langInCookie) && langInCookie !== currentLang ) { handleSetLanguage(langInCookie); } else { let lang = i18n?.languages[0] as Locales; // Check browser language manually const isCN = (window as any)?.navigator.languages.includes('zh-CN') || (window as any)?.navigator.languages.includes('zh-cn') || null; if (isCN) { lang = 'cn'; } else if (isUsingMobile()) { // If it's a mobile, use substr(0, 2) to get lang ISO code, if it matches any of our app locales, set it as language const matchLang = navigator.languages .find((x) => x.substr(0, 2) === navigator.language.substr(0, 2)) .substr(0, 2) as Locales; if (i18n?.languages.includes(matchLang)) { lang = matchLang; } } // to save locale in cookie setCookie('lang', lang, 30); } }; const handleLanguageChange = (locale: Locales) => { handleSetLanguage(locale); }; const handleSetLanguage = (locale: Locales) => { // to show option in select setLanguage(locale); // to change router locale router.push(router.asPath, router.asPath, { locale: locale }); // to save locale in cookie setCookie('lang', locale, 30); }; return ( <Menu> <MenuButton minW="90px" as={Button} rightIcon={<ChevronDownIcon />}> <Image src={`../assets/images/${language}.png`} alt={language}></Image> </MenuButton> <MenuList minW={{ base: '90px', md: '150px' }} zIndex={3}> {locales.map((locale: Locales) => ( <MenuItem key={locale} onClick={() => handleLanguageChange(locale)} _hover={{ bgColor: '#CBD5E0' }} > <Image border="1px" src={`../assets/images/${locale}.png`} alt={locale} ></Image> <Text ml={4} display={{ base: 'none', md: 'block' }}> {localesOptions.find((x) => x.code === locale)?.value || locale} </Text> </MenuItem> ))} </MenuList> </Menu> ); }; export default LangSelector;

src\utils\isUsingMobile.ts

export const isIOS = () => { if (process.browser) { return ( navigator.userAgent.match(/iPhone/i) || navigator.userAgent.match(/iPad/i) || navigator.userAgent.match(/iPod/i) ); } }; export const isUsingMobile = () => { if (process.browser) { return ( isIOS() || navigator.userAgent.match(/Android/i) || navigator.userAgent.match(/webOS/i) || navigator.userAgent.match(/BlackBerry/i) || navigator.userAgent.match(/Windows Phone/i) ); } };

src\utils\cookie.ts

export function setCookie(cname: string, cvalue: string, exdays = -1) { const d = new Date(); d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000); const expires = 'expires=' + d.toUTCString(); document.cookie = cname + '=' + cvalue + ';' + expires + ';path=/'; } export function getCookie(cname: string) { const name = cname + '='; const decodedCookie = decodeURIComponent(document.cookie); const ca = decodedCookie.split(';'); for (let i = 0; i < ca.length; i += 1) { let c = ca[i]; while (c.charAt(0) === ' ') { c = c.substring(1); } if (c.indexOf(name) === 0) { return c.substring(name.length, c.length); } } return ''; }

@types\react-i18next.d.ts

TypeScript magic!!
srouce

import IResources from '../src/i18n/localests/@types'; // react-i18next versions higher than 11.11.0 declare module 'react-i18next' { // eslint-disable-next-line interface CustomTypeOptions { defaultNS: 'en'; resources: IResources; } }