Software localization
Mastering Qwik Localization: A Comprehensive Guide
As applications become more complex, loading more JavaScript upfront increases client bundle sizes and slows initial load times. Frameworks like Next.js tackle this with Server Components, which render on the server to reduce the client’s payload and browser workload. Qwik takes a radically different approach, serializing the application’s state on the server and resuming it on the client, which avoids the need for re-rendering and hydration in the browser.
While Qwik’s instantly interactive “Live HTML” and aggressive lazy-loading can speed up our apps, they make things like internationalization (i18n) a bit more tricky. Thankfully, the Qwik Speak library by Roberto Simonetti simplifies Qwik i18n while adhering to Qwik’s resumability and lazy-loading principles. In this guide, we’ll walk through a Qwik demo app and internationalize it using Qwik Speak. Let’s get started.
🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.
Our demo
The fictitious Etttro is a mock second-hand marketplace for retro hardware.
We won’t cover any e-commerce or CRUD in this guide, keeping the app nice and lean to focus on the i18n.
Packages used
We’ll use the following NPM packages and walk through their installation when needed.
typescript@5.5
— our programming language@builder.io/qwik@1.7
— the core Qwik library@builder.io/qwik-city@1.7
— Qwik’s SSR frameworkqwik-speak@0.23
— the i18n libraryrtl-detect@1.1
— detects the layout direction of a languagetailwindcss@3.4
— for styling, optional for our purposes
Building the starter app
Let’s spin up a new Qwik app from the command line.
npm create qwik@latest
Code language: Bash (bash)
When prompted to select a starter, we can pick the Basic App (Qwik City + Qwik) option. After installing the npm packages, we can install Tailwind CSS by running the following. (Again, this is optional, and we don’t focus much on styling in this guide).
npm run qwik add tailwind
Code language: Bash (bash)
If you are coding along, note that we removed all non-Tailwind boilerplate styles in our demo. So our global.css
looks like the following.
/* src/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Code language: CSS (css)
We can remove most of the boilerplate code that some with the Qwik starter: everything in src/components
, src/media
can be deleted, and so can src/routes/demo
and public/fonts
.
Our root layout file can be simplified to look like the following.
// src/routes/layout.tsx
import { component$, Slot } from "@builder.io/qwik";
import type { RequestHandler } from "@builder.io/qwik-city";
import { routeLoader$ } from "@builder.io/qwik-city";
import Header from "~/components/layout/header";
export const onGet: RequestHandler = async ({
cacheControl,
}) => {
cacheControl({
staleWhileRevalidate: 60 * 60 * 24 * 7,
maxAge: 5,
});
};
export const useServerTimeLoader = routeLoader$(() => {
return {
date: new Date().toISOString(),
};
});
export default component$(() => {
return (
<>
<Header />
<main class="...">
<Slot />
</main>
</>
);
});
Code language: JavaScript (javascript)
🔗 Resource » We omit styles for brevity. You can get all the starter code from our GitHub repo, including styles.
Let’s write the Header
component.
// src/components/layout/header.tsx
import { component$ } from "@builder.io/qwik";
import { Link } from "@builder.io/qwik-city";
export default component$(() => {
return (
<header class="...">
<nav class="...">
<Link href="/">
<span class="...">👾 Etttro</span>
</Link>
<ul class="...">
<li>
<Link href="/">Latest products</Link>
</li>
<li>
<Link href="/about">About us</Link>
</li>
</ul>
</nav>
</header>
);
});
Code language: JavaScript (javascript)
Our app will have three pages:
- A home page listing the latest products on offer.
- A single product page that shows product details.
- A simple about page.
We can create hard-coded product data to simulate data retrieval on the server.
// src/data/retro-hardware.ts
const retroHardware = [
{
id: 1,
title: "Commodore 64",
priceInCents: 14999,
imageUrl: "commodore-64.jpg",
publishedAt: "2024-06-01T10:00:00Z",
description: "Classic Commodore 64 in working condition...",
},
{
id: 2,
title: "Virtual Boy",
// ...
},
// ...
] as const;
export default retroHardware;
export type Product = (typeof retroHardware)[number];
Code language: JavaScript (javascript)
🔗 Resource » Get the entire file from GitHub.
Our home page can now “pull in” this data and display it.
// src/routes/index.tsx
import { component$ } from "@builder.io/qwik";
import {
Link,
routeLoader$,
type DocumentHead,
} from "@builder.io/qwik-city";
import retroHardware, {
type Product,
} from "~/data/retro-hardware";
export const useProducts = routeLoader$<
Readonly<Product[]>
>(() => {
return retroHardware;
});
export default component$(() => {
const productsS = useProducts();
return (
<>
<h1 class="...">Latest products</h1>
<section class="...">
{productsS.value.map((product) => (
<Link
href={`products/${product.id}`}
key={product.id}
>
<article class="...">
<h3 class="...">{product.title}</h3>
<img
class="..."
width={600}
height={600}
alt={product.title}
src={`/product-img/${product.imageUrl}`}
/>
<div class="...">
<p>${product.priceInCents / 100.0}</p>
<p>
{new Date(
product.publishedAt,
).toLocaleDateString("en-US")}
</p>
</div>
<p class="...">
{product.description.slice(0, 65)}...
</p>
</article>
</Link>
))}
</section>
</>
);
});
export const head: DocumentHead = {
title: "Etttro | Retro Hardware Marketplace",
meta: [
{
name: "description",
content: "Etttro is your community second-hand...",
},
],
};
Code language: JavaScript (javascript)
To get to the i18n as quickly as possible, we’ll skip the code for the single product and about pages, but you can get all the starter code from GitHub.
How do I localize my app with Qwik Speak?
Localizing a Qwik app with Qwik Speak involves the following steps.
1. Install and configure the Qwik Speak library.
2. Move hard-coded strings to translation files.
3. Use Qwik Speak’s t()
function to display the localized strings.
4. Set up localized routing.
5. Build a language switcher UI.
6. Automatically extract strings out of our components for translation.
7. Handle dynamic values in translations.
8. Work with plurals in translations.
9. Format localized numbers and dates.
We’ll go through these steps in detail. Let’s start with installing the i18n library.
How do I install and configure Qwik Speak?
The usual npm install
will do us here, except notice that Qwik Speak is installed as a development dependency.
npm install qwik-speak --save-dev
Code language: Bash (bash)
Next, let’s set up the qwikSpeakInline
Vite plugin in vite.config.ts
.
// vite.config.ts
import { defineConfig, type UserConfig } from "vite";
import { qwikVite } from "@builder.io/qwik/optimizer";
import { qwikCity } from "@builder.io/qwik-city/vite";
+ import { qwikSpeakInline } from 'qwik-speak/inline';
import tsconfigPaths from "vite-tsconfig-paths";
import pkg from "./package.json";
//...
export default defineConfig(({ command, mode }): UserConfig => {
return {
plugins: [
qwikCity(),
qwikVite(),
+ qwikSpeakInline({
+ supportedLangs: ['en-US', 'ar-EG'],
+ defaultLang: 'en-US',
+ assetsPath: 'i18n'
+ }),
tsconfigPaths(),
],
optimizeDeps: {
exclude: [],
},
// ...
};
});
// ...
Code language: Diff (diff)
Qwik Speak resolves its translations during SSR (server-side rendering) and inlines them into pre-compiled chunks of our app for the browser, keeping the client-side performant.
A Vite plugin is used to generate these translation chunks at compile time. This is the plugin we configured above. Here’s what we set:
supportedLangs
— the locales that we guarantee translations for, set as language tags; here we’re supportingen-US
(English as used in the United States) andar-EG
(Arabic as used in Egypt). Feel free to support any locales you want.defaultLang
— this needs to be one of oursupportedLangs
and is used as a fallback when we can’t resolve a locale for the user.assetsPath
— the directory, relative to the project root, where translation files will be stored; we set ours toi18n
.
A note on locales
A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en
for English, fr
for French, and es
for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH
for Bahrain, CN
for China, US
for the United States) is recommended for accurate date and number localization. So a complete locale might look like en-US
for American English or zh-CN
for Chinese as used in China.
🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.
🔗 Resource » Learn more about the Qwik Speak Inline Vite plugin from the official docs.
Next, we need to add two configuration files, speak-config.ts
and speak-functions.ts
. These go directly into the src
directory.
// src/speak-config.ts
import type { SpeakConfig } from "qwik-speak";
export const config: SpeakConfig = {
// Note that we expand on the locale config
// here, setting a currency and time zone.
defaultLocale: {
lang: "en-US",
currency: "USD",
timeZone: "America/Los_Angeles",
},
supportedLocales: [
{
lang: "ar-EG",
currency: "USD",
timeZone: "Africa/Cairo",
},
{
lang: "en-US",
currency: "USD",
timeZone: "America/Los_Angeles",
},
],
// Translations available in the whole
// app. These map to files under our
// `i18n/{lang}` directories.
assets: ["app"],
// Translations that require dynamic keys,
// and cannot be set at compile-time.
runtimeAssets: [],
};
Code language: JavaScript (javascript)
// src/speak-functions.ts
import { server$ } from "@builder.io/qwik-city";
import type {
LoadTranslationFn,
Translation,
TranslationFn,
} from "qwik-speak";
/**
* Translation files are lazy-loaded via dynamic import
* and will be split into separate chunks during build.
* Assets names and keys must be valid variable names.
*/
const translationData = import.meta.glob<Translation>(
"/i18n/**/*.json",
);
/**
* Using server$, translation data is always accessed
* on the server.
*/
const loadTranslation$: LoadTranslationFn = server$(
async (lang: string, asset: string) =>
await translationData[`/i18n/${lang}/${asset}.json`](),
);
export const translationFn: TranslationFn = {
loadTranslation$: loadTranslation$,
};
Code language: JavaScript (javascript)
The translation functions determine how our translation files are loaded on the server. Qwik Speak lets us write our translation functions any way we want. We’ll stick to the defaults for this guide.
🗒️ Note » The translation files are loaded from the same directory set when configuring the Vite plugin, /i18n
. Each locale will have a subdirectory e.g. /i18n/en-US
.
Let’s pull this config into the root of our app to provide it with the Qwik Speak context.
// src/root.tsx
import { component$ } from "@builder.io/qwik";
import {
QwikCityProvider,
RouterOutlet,
ServiceWorkerRegister,
} from "@builder.io/qwik-city";
import { RouterHead } from "./components/router-head/router-head";
+ import { useQwikSpeak } from "qwik-speak";
+ import { config } from "./speak-config";
+ import { translationFn } from "./speak-functions";
import "./global.css";
export default component$(() => {
+ useQwikSpeak({ config, translationFn });
return (
<QwikCityProvider>
<head>
<meta charset="utf-8" />
<RouterHead />
<ServiceWorkerRegister />
</head>
<body lang="en" class="...">
<RouterOutlet />
</body>
</QwikCityProvider>
);
});
Code language: Diff (diff)
One last piece of setup: We need to set the base URL of our app explicitly since Qwik Speak uses the base URL to load its translation chunks. This is configured in our app’s SSR entry point.
// src/entry.ssr.tsx
+ import { isDev } from "@builder.io/qwik/build";
import {
renderToStream,
+ type RenderOptions,
type RenderToStreamOptions,
} from "@builder.io/qwik/server";
import { manifest } from "@qwik-client-manifest";
import Root from "./root";
+ import { config } from "./speak-config";
+ export function extractBase({
+ serverData,
+ }: RenderOptions): string {
+ if (!isDev && serverData?.locale) {
+ return "/build/" + serverData.locale;
+ } else {
+ return "/build";
+ }
+ }
export default function (opts: RenderToStreamOptions) {
+ // Let's set the lang attribute on the
+ // <html> tag while we're at it.
+ const lang =
+ opts.serverData?.locale || config.defaultLocale.lang;
return renderToStream(<Root />, {
manifest,
...opts,
+ base: extractBase,
containerAttributes: {
+ lang,
...opts.containerAttributes,
},
serverData: {
...opts.serverData,
},
});
}
Code language: Diff (diff)
serverData.locale
will be set to the resolved locale by Qwik Speak. Right now it will always be the default locale, en-US
. We’ll look at how we can change this in the following sections.
Alright, that’s about it for setup. Let’s see if this all works, shall we? We’ll add our first translations, starting with the app title. Let’s add two translation files, i18n/en-US/app.json
and i18n/ar-EG/app.json
.
// i18n/en-US/app.json
{
"appTitle": "Ettro"
}
Code language: JSON / JSON with Comments (json)
// i18n/ar-EG/app.json
{
"appTitle": "إترو"
}
Code language: JSON / JSON with Comments (json)
As per our config, Qwik Speak expects our translation files to be at i18n/{locale}/{asset}.json
. The {asset}.json
part is just the namespace file; app
is the default namespace file. Now let’s use this translation in our header component.
// src/components/layout/header.tsx
import { component$ } from "@builder.io/qwik";
import { Link } from "@builder.io/qwik-city";
+ import { inlineTranslate } from "qwik-speak";
export default component$(() => {
+ const t = inlineTranslate();
return (
<header class="...">
<nav class="...">
<Link href="/">
<span class="...">
- 👾 Etttro
+ 👾 {t("appTitle")}
</span>
</Link>
<ul class="...">
{/* ... */}
</ul>
</nav>
</header>
);
});
Code language: Diff (diff)
The t()
function tells Qwik Speak to swap in a translation at compile time. Again, app
is the default namespace/asset file, so Qwik Speak knows to look in the app.json
file under the resolved locale for the appTitle
key. The resolved locale is currently the default, en-US
, so if we reload our app it should look exactly as before.
However, if we change the defaultLocale
to Arabic in our speak-config.ts
file, we should see our app title translated to Arabic.
// src/speak-config.ts
import type { SpeakConfig } from "qwik-speak";
export const config: SpeakConfig = {
defaultLocale: {
- lang: "en-US",
+ lang: "ar-EG",
currency: "USD",
timeZone: "America/Los_Angeles",
},
// ...
};
Code language: Diff (diff)
Alright! We got Qwik Speak working. Let’s set up localized routing next.
🔗 Resource » You can get the completed app code from our GitHub repo.
How do I configure localized routing?
We often want localized apps with routes like /foo
for English resources and /ar-EG/foo
for Arabic. The locale segment of each route would resolve to the active locale for the request, and we would serve content for that locale.
We can accomplish this by setting up some middleware and moving our routes to handle the dynamic locale segment.
First, let’s move our routes. We’ll create a directory called [...lang]
under /src/routes
and move all our app routes there.
# BEFORE
.
└── src/
└── routes/
├── about/
│ └── index.tsx
├── products/
│ └── [id]/
│ └── index.ts
└── index.tsx
# AFTER
.
└── src/
└── routes/
└── [...lang]/
├── about/
│ └── index.tsx
├── products/
│ └── [id]/
│ └── index.ts
└── index.tsx
Code language: plaintext (plaintext)
[...lang]
is a catch-all dynamic route segment corresponding to the ar-EG
part of /ar-EG/foo
. We can now use this lang
route param to resolve the active locale in our new plugin middleware:
// src/routes/plugin.ts
import type { RequestHandler } from "@builder.io/qwik-city";
import {
setSpeakContext,
validateLocale,
} from "qwik-speak";
import { config } from "../speak-config";
/**
* This middleware function must only contain the logic to
* set the locale, because it is invoked on every request
* to the server.
* Avoid redirecting or throwing errors here, and prefer
* layouts or pages.
*/
export const onRequest: RequestHandler = ({
params,
locale,
}) => {
let lang: string | undefined = undefined;
if (params.lang && validateLocale(params.lang)) {
lang = config.supportedLocales.find(
(value) => value.lang === params.lang,
)?.lang;
} else {
lang = config.defaultLocale.lang;
}
// Set Qwik locale.
locale(lang);
};
Code language: JavaScript (javascript)
We check for a lang
route param and use Qwik Speak’s validateLocale
function to ensure it’s in the correct format (e.g. en-US
, ar-EG
, it-IT
). If invalid, lang
will be set to the configured default locale, en-US
. If all is well, we further check that lang
corresponds to a locale our app supports.
Whatever lang
ends up being, we set it as the app’s locale using Qwik’s locale()
function. This function is used internally by Qwik Speak to determine the active locale.
🗒️ Note » If lang
is undefined
when we call locale()
, Qwik Speak will resolve the active locale to the default locale, en-US
.
We should see our app title in English if we visit our default routes e.g. /
or /about
. However, if we visit /ar-EG
or /ar-EG/about
, the app title should be in Arabic. We’ve successfully set the app locale using the route prefix.
🔗 Resource » Qwik Speak also supports domain-based routing, where our locales are determined by the top-level domain, e.g. example.ar
or example.it
. Also available is route rewriting, where sub-routes are translated, e.g. example.com/about
is translated to example.com/ar-EG/عنا
.
Forcing a locale prefix
Qwik Speak insists on leaving the default locale out of routes. We can, however, force a route prefix for the default locale. This will cause our English routes to always be /en-US/foo
.
First, let’s handle the case where the lang
route param doesn’t correspond to a locale supported by our app; we want to send the client a 404 error response.
// src/routes/layout.tsx
import { component$, Slot } from "@builder.io/qwik";
import type { RequestHandler } from "@builder.io/qwik-city";
import { routeLoader$ } from "@builder.io/qwik-city";
import Footer from "~/components/layout/footer";
import Header from "~/components/layout/header";
+ import { config } from "~/speak-config";
export const onGet: RequestHandler = async ({
cacheControl,
+ params,
+ send,
}) => {
cacheControl({
staleWhileRevalidate: 60 * 60 * 24 * 7,
maxAge: 5,
});
+ if (
+ !config.supportedLocales.find(
+ (loc) => loc.lang === params.lang,
+ )
+ ) {
+ send(404, "Not Found");
+ }
+ };
export const useServerTimeLoader = routeLoader$(() => {
return {
date: new Date().toISOString(),
};
});
export default component$(() => {
return (
<>
<Header />
<main class="...">
<Slot />
</main>
<Footer />
</>
);
});
Code language: Diff (diff)
Now let’s force a locale prefix from the home route. If we land on /
let’s redirect to /en-US
.
// src/routes/[...lang]/index.tsx
import { component$ } from "@builder.io/qwik";
import {
routeLoader$,
type DocumentHead,
+ type RequestHandler,
} from "@builder.io/qwik-city";
+ import { config } from "~/speak-config";
// ...
+ export const onGet: RequestHandler = async ({
+ params,
+ redirect,
+ }) => {
+ if (!params.lang) {
+ throw redirect(301, `/${config.defaultLocale.lang}/`);
+ }
+ };
// ...
Code language: Diff (diff)
Now if a visitor lands on the /
route, we redirect to the /en-US
route. The rest of our locale-forcing logic will be covered in the next section.
How do I localize my links?
We can roll our own localized link component to ensure our links keep the active locale prefix. Qwik Speak has a handy localizePath function that can help you here if you didn’t force the locale prefix like we did above. We are forcing the locale prefix in this guide, so let’s write our own function to localize any path.
// src/util/i18n/loc-path.ts
import { config } from "~/speak-config";
export default function locPath$(
path: string,
lang: string,
): string {
if (path === "/") {
return `/${lang}`;
}
const pathParts = path
.split("/")
.filter((segment) => segment);
if (
config.supportedLocales.find(
(locale) => locale.lang === pathParts[0],
)
) {
pathParts[0] = lang;
} else {
pathParts.unshift(lang);
}
return `/${pathParts.join("/")}`;
}
Code language: JavaScript (javascript)
Given a path
and a locale code, lang
, locPath$
will convert that path to one prefixed with lang
. Some examples:
locPath$("/", "ar-EG")
→"/ar-EG"
locPath$("/en-US/foo", "ar-EG")
→"/ar-EG/foo"
locPath$("/ar-EG/foo", "en-US")
→"/en-US/foo"
We can now use locPath$
in a new LocLink
component for localized links.
// src/components/i18n/loc-link.tsx
import { Slot, component$ } from "@builder.io/qwik";
import {
Link,
useLocation,
type LinkProps,
} from "@builder.io/qwik-city";
import locPath$ from "~/util/i18n/loc-path";
type LocLinkProps = LinkProps & { href: string };
export default component$(
({ href, ...props }: LocLinkProps) => {
const {
params: { lang },
} = useLocation();
const localizedHref = locPath$(href, lang);
return (
<Link href={localizedHref} {...props}>
<Slot />
</Link>
);
},
);
Code language: JavaScript (javascript)
We’re effectively extending Qwik’s built-in Link
component, and localizing the given href
prop. For example, if we’re given /foo
as the href
value, and the active locale is Arabic, we set the href
on the Link
to be /ar-EG/foo
.
LocLink
can be used as a drop-in replacement for Link
.
// src/components/layout/header.tsx
import { component$ } from "@builder.io/qwik";
- import { Link } from "@builder.io/qwik-city";
+ import LocLink from "../i18n/loc-link";
import { inlineTranslate } from "qwik-speak";
export default component$(() => {
const t = inlineTranslate();
return (
<header class="...">
<div class="...">
<nav class="...">
- <Link href="/">
+ <LocLink href="/">
<span class="text-2xl font-thin">
👾 {t("appTitle")}
</span>
- </Link>
+ </LocLink>
<ul class="...">
<li>
- <Link href="/">
+ <LocLink href="/">
{t("nav.latestProducts")}
- </Link>
+ </LocLink>
</li>
<li>
- <Link href="/about">
+ <LocLink href="/about">
{t("nav.aboutUs")}
- </Link>
+ </LocLink>
</li>
</ul>
</nav>
</div>
</header>
);
});
Code language: Diff (diff)
Now our links will always include the locale prefix for the active locale.
How do I build a language switcher?
We often want to allow our visitors to select their preferred locale. Qwik Speak doesn’t have a function to switch locales, so we need to reload the app when a new locale is selected. Let’s build a locale switcher <select>
component to achieve this.
// src/components/i18n/locale-switcher.tsx
import { $, component$ } from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";
import {
useSpeakLocale,
type SpeakLocale,
} from "qwik-speak";
import { config } from "~/speak-config";
import locPath$ from "~/util/i18n/loc-path";
const langNames: Record<SpeakLocale["lang"], string> = {
"en-US": "English",
"ar-EG": "العربية (Arabic)",
};
export default component$(() => {
const { lang: activeLang } = useSpeakLocale();
const loc = useLocation();
const changeLocale$ = $((evt: Event) => {
const selectedLang = (evt.target as HTMLSelectElement)
.value;
// Reload the whole app/page with the newly
// selected locale.
window.location.href = locPath$(
loc.url.pathname,
selectedLang,
);
});
return (
<select
onChange$={changeLocale$}
class="..."
>
{config.supportedLocales.map(({ lang }) => (
<option
key={lang}
value={lang}
selected={lang === activeLang}
>
{langNames[lang]}
</option>
))}
</select>
);
});
Code language: JavaScript (javascript)
The useSpeakLocale() function returns an object representing the active locale; a lang
string on the object has the locale code (”en-US" | "ar-EG"
). We use this to set the selected language option in the <select>
dropdown.
When the visitor chooses a new language from the dropdown, we reload the app, swapping the newly selected language into the current URL. For example, let’s say the visitor is on a product details page in English and they switch to Arabic: We take the current URL, /en-US/products/1
, use our locPath$
function to convert it to /ar-EG/products/1
, and reload the app with this new URL.
🗒️ Note » If you’re coding along, remember to place the new LocaleSwitcher
component in your Header
component.
🔗 Resource » You can get all the code we cover in this guide from our GitHub repo.
How do I extract translations from code?
Qwik Speak features a handy CLI that can save us time by extracting translations from our code files. Let’s see it in action.
First, we’ll add a new NPM script to our package.json
file.
// package.json
{
"name": "my-qwik-basic-starter",
"description": "Demo App with Routing built-in (recommended)",
// ...
"scripts": {
//...
"start": "vite --open --mode ssr",
"qwik": "qwik",
+ "i18n:extract": "qwik-speak-extract --supportedLangs=en-US,ar-EG --assetsPath=i18n --unusedKeys=true"
},
"devDependencies": {
//...
}
}
Code language: Diff (diff)
The qwik-speak-extract
command will search our code for calls to t(key)
. The command will extract all the key
s found and place them in translation files under the --assetsPath
directory. It will create one file for each entry we provide in --supportedLangs
.
Let’s run the command to see what it does. Recall the translations in our Header
component.
// src/components/layout/header.tsx
import { component$ } from "@builder.io/qwik";
import { inlineTranslate } from "qwik-speak";
import LocLink from "../i18n/loc-link";
import LocaleSwitcher from "../i18n/locale-switcher";
export default component$(() => {
const t = inlineTranslate();
return (
<header class="...">
<div class="...">
<nav class="...">
<LocLink href="/">
<span class="...">
{/* 👇 The extraction script will look
for `t(key)` calls. This will be
extracted into `app.json`. */}
👾 {t("appTitle")}
</span>
</LocLink>
<ul class="...">
<li>
<LocLink href="/">
{/* 👇 This will be extracted into
`nav.json`. */}
{t("nav.latestProducts")}
</LocLink>
</li>
<li>
<LocLink href="/about">
{t("nav.aboutUs")}
</LocLink>
</li>
</ul>
</nav>
<LocaleSwitcher />
</div>
</header>
);
});
Code language: JavaScript (javascript)
Let’s remove all the translation JSON files we created earlier under the i18n
directory, then run npm run i18n:extract
from the command line.
If all goes well, we should see the files re-created by the extraction script.
.
└── i18n/
├── ar-EG/
│ ├── app.json
│ └── nav.json
└── en-US/
├── app.json
└── nav.json
Code language: plaintext (plaintext)
All keys without namespaces, like appTitle
, go into the default app.json
file.
// i18n/en-US/app.json
{
"appTitle": ""
}
// i18n/ar-EG/app.json
{
"appTitle": ""
}
Code language: JSON / JSON with Comments (json)
Keys with namespaces, like nav.latestProducts
, go into files named after the given namespace.
// i18n/en-US/nav.json
{
"nav": {
"aboutUs": "",
"latestProducts": ""
}
}
// i18n/ar-EG/nav.json
{
"nav": {
"aboutUs": "",
"latestProducts": ""
}
}
Code language: JSON / JSON with Comments (json)
The translation files work the same as they did before.
🗒️ Note » qwik-speak-extract
is smart enough not to override existing key/value pairs.
🗒️ Heads up » Every new file/namespace must be added to the src/speak-config.ts
file’s assets
array.
// src/speak-config.ts
import type { SpeakConfig } from "qwik-speak";
export const config: SpeakConfig = {
// ...
supportedLocales: [
//...
],
// Translations available in the whole app
- assets: ["app"],
+ assets: ["app", "nav"],
runtimeAssets: [],
};
Code language: Diff (diff)
If we want to prune keys no longer in our codebase, we can use the --unusedKeys=true
CLI flag as we have above. This will cause the command to remove the keys we’re no longer using.
🔗 Resource » If you don’t like your extracted translations to have blank values, you can provide default translations with your keys that will end up as values in your extracted files. Read about default values and all the extraction CLI options on the Qwik Speak Extract page of the official docs.
🗒️ Note » These files can be uploaded to a string management platform like Phrase Strings, where translators can work on various language files; an automated process can pull the translation files back into the project when ready.
Some notes on translation files
We won’t cover the following in detail here, but we found the following aspects of Qwik Speak translation files noteworthy.
Runtime translations
Qwik Speak translations are compiled on the server and baked into utilizing components as hard-coded strings. This makes our app very performant, as it avoids any translation logic running on the client. However, this means that Qwik Speak needs to statically analyze translation keys at compile time. What if we needed to calculate a translation key at runtime? This is where runtime translations come in. Read about runtime translations in the official docs.
Lazy-loaded translations
In large applications, we may need to delay loading specific translations until they are required. Qwik Speak allows this via lazy loading. The idea is to exclude specific translation files from the speak-config.js
assets
array and load them manually with useSpeak()
. Read about lazy-loading in the official docs.
How do I deal with right-to-left languages?
Arabic, Hebrew, Persian (Farsi), Urdu, and others are laid out right to left. Modern browsers do a good job of accommodating right-to-left layouts. However, we should set the <html dir>
attribute to rtl
to get our pages flowing correctly.
Before that, let’s install a handy little package called rtl-detect that detects the direction of a given locale.
npm install --save-dev rtl-detect @types/rtl-detect
Code language: Bash (bash)
We can install rtl-detect as a dev dependency because we’ll only run it on the server.
Now let’s use the package to set the <html dir>
attribute during SSR (server-side rendering).
// src/entry.ssr.tsx
import { isDev } from "@builder.io/qwik/build";
import {
renderToStream,
type RenderOptions,
type RenderToStreamOptions,
} from "@builder.io/qwik/server";
import { manifest } from "@qwik-client-manifest";
+ import rtlDetect from "rtl-detect";
import Root from "./root";
import { config } from "./speak-config";
export function extractBase(/* ... */) {
// ...
}
export default function (opts: RenderToStreamOptions) {
const lang =
opts.serverData?.locale || config.defaultLocale.lang;
return renderToStream(<Root />, {
manifest,
...opts,
base: extractBase,
// Use container attributes to set attributes on
// the html tag.
containerAttributes: {
lang,
+ // Use rtl-detect to determine the dir of
+ // the current locale and set it as the
+ // `<html dir>` attribute.
+ dir: rtlDetect.getLangDir(lang),
...opts.containerAttributes,
},
serverData: {
...opts.serverData,
},
});
}
Code language: Diff (diff)
With this code in place, our Arabic pages will have a <html dir>
value of rtl
.
And, of course, this causes our page to be laid out from right to left.
🔗 Resource » There’s more to layout localization than simply <html dir>
. Check out our CSS Localization guide for more details.
How do I localize on the server?
Unlike components, we need to supply the current locale explicitly when using inlineTranslate
in server contexts like endpoints and router loaders.
export const useMyTranslatedString = routeLoader$<string>(
(requestEvent) => {
const t = inlineTranslate();
// We need to provide the locale explicitly
// to `t()`. We can get the Qwik locale, from
// `requestEvent.locale()`. We set this in our
// plugin middleware.
const translatedString = t(
"myTranslation",
{},
requestEvent.locale(),
);
return translatedString;
},
);
Code language: JavaScript (javascript)
🔗 Resource » Read more about Server translation in the official docs.
🗒️ Note » If you want to localize your static-side generated (SSG) Qwik apps, check out the Static Site Generation (SSG) of the docs.
How do I localize page metadata?
Since document metadata is generated on the server, it can be localized similarly to other server-side contexts. Let’s provide the translations first.
// i18n/en-US/app.json
{
+ "app": {
+ "meta": {
+ "description": "Etttro is your community second-hand market for all retro electronics.",
+ "title": "Etttro | Retro Hardware Marketplace"
+ }
+ },
"appTitle": "Etttro",
// ...
}
Code language: Diff (diff)
// i18n/ar-EG/app.json
{
+ "app": {
+ "meta": {
+ "description": "إترو هو سوق مجتمعي لجميع الأجهزة الإلكترونية الرجعية المستعملة.",
+ "title": "إترو | سوق الأجهزة الرجعية"
+ }
+ },
"appTitle": "إترو",
// ...
}
Code language: Diff (diff)
Instead of the inline object, we need to use the function form of Qwik Speak’s head
. This allows us to access route params
, including the lang
param, which we can pass to t()
.
// src/routes/[...lang]/index.tsx
import { component$ } from "@builder.io/qwik";
import {
routeLoader$,
type DocumentHead,
} from "@builder.io/qwik-city";
import { inlineTranslate } from "qwik-speak";
// ...
export default component$(() => {
// ...
});
export const head: DocumentHead = ({ params }) => {
const t = inlineTranslate();
// We pass the `lang` route param to `t()` as
// the third argument.
return {
title: t("app.meta.title", {}, params.lang),
meta: [
{
name: "description",
content: t("app.meta.description", {}, params.lang),
},
],
};
};
Code language: JavaScript (javascript)
The title and meta description are translated to Arabic when our home page locale is ar-EG
.
How do I work with basic translation messages?
We’ve covered basic translations a few times by now, so we’ll briefly refresh ourselves before we cover more advanced formatting topics.
Translated strings are added to our translation files, one for each locale.
// i18n/en-US/app.json
{
//...
"water": "Water"
}
Code language: JSON / JSON with Comments (json)
// i18n/ar-EG/app.json
{
//...
"water": "ماء"
}
Code language: JSON / JSON with Comments (json)
We use the t()
function to pull translations into our components.
import { component$ } from "@builder.io/qwik";
import { inlineTranslate } from "qwik-speak";
export default component$(() => {
const t = inlineTranslate();
return <p>{t("water")}</p>
});
Code language: JavaScript (javascript)
The above renders “Water” when the active locale is en-US
and “ماء” when the active locale is ar-EG
.
🗒️ Note » Instead of adding strings manually to translation files, we can use the CLI to extract them automatically. See the extraction section above for more.
How do I work with dynamic values in translation messages?
We sometimes need to inject runtime values into our translations. Qwik Speak’s translation message format allows for this using a {{variable}}
placeholder syntax. Let’s add a user greeting to our home page to demonstrate.
// i18n/en-US/app.json
{
"appTitle": "Ettro",
//...
+ "userGreeting": "Hello, {{username}}."
}
Code language: Diff (diff)
// i18n/ar-EG/app.json
{
"appTitle": "إترو",
//...
+ "userGreeting": "مرحبًا، {{username}}."
}
Code language: Diff (diff)
Notice the {{username}}
syntax in the above messages. This will be replaced by a username
argument when we call t()
.
// src/routes/[...lang]/index.tsx
import { $, component$ } from "@builder.io/qwik";
// ...
import {
inlineTranslate,
type Translation,
} from "qwik-speak";
// ...
// ...
export default component$(() => {
const t = inlineTranslate();
const productsS = useProducts();
return (
<>
<div class="...">
<h1 class="...">
{t("homePageTitle")}
</h1>
+ <p>{t("userGreeting", { username: "Hannah" })}</p>
</div>
<section class="...">
{/* ... */}
</section>
</>
);
});
// ...
Code language: Diff (diff)
The second argument to t()
is a map of key/value pairs that will replace the placeholders at runtime, matching each by key/name.
🔗 Resource » Read more about Params interpolation in the official docs.
How do I work with localized plurals?
Plurals in translation messages are more than just switching between singular and plural; different languages have a variety of plural forms. For instance, English has two forms: “one message” and “many messages.” Meanwhile, some languages like Chinese have only one plural form, whereas Arabic and Russian each have six. To get it right, we must provide the different plural forms and an integer count to select the correct one.
Qwik Speak offers plural support, using the standard JavaScript Intl.PluralRules object under the hood. Let’s add a count to our “Latest products” header to showcase localized plurals.
First, we’ll add our messages. Plural translations have a special structure: each plural form is a key/value pair in a nested object:
// i18n/en-US/app.json
{
"appTitle": "Ettro",
- "latestProducts": "Latest products",
+ "latestProducts": {
+ "one": "{{value}} Latest Product",
+ "other": "{{value}} Latest Products"
+ },
// ...
"userGreeting": "Hello, {{name}}."
}
Code language: Diff (diff)
// i18n/ar-EG/app.json
{
"appTitle": "إترو",
- "latestProducts": "أحدث المنتجات",
+ "latestProducts": {
+ "zero": "لا توجد منتجات",
+ "one": "أحدث منتج",
+ "two": "أحدث منتجين",
+ "few": "أحدث {{value}} منتجات",
+ "many": "أحدث {{value}} منتج",
+ "other": "أحدث {{value}} منتج"
+ },
// ...
"userGreeting": "مرحبًا، {{name}}."
}
Code language: Diff (diff)
As we mentioned, English has two plural forms, one
and other
, while Arabic has six plural forms. At runtime, the {{value}}
placeholder will be replaced by given the integer counter.
🔗 Resource » The CLDR Language Plural Rules page is the canonical, and handy, listing of all languages’ plural forms.
To use plural messages in our components, we call Qwik Speak’s inlinePlural
instead of inlineTranslate
.
import { $, component$ } from "@builder.io/qwik";
// ...
import {
+ inlinePlural,
inlineTranslate,
type Translation,
} from "qwik-speak";
//...
export default component$(() => {
const t = inlineTranslate();
+ const p = inlinePlural();
const productsS = useProducts();
return (
<>
<div class="...">
<h1 class="...">
- {t("latestProducts")}
+ {/* The first argument is an integer
+ counter used to select the plural
+ form. The second argument is the
+ message key. */}
+ {p(productsS.value.length, "latestProducts")}
</h1>
<p>{t("userGreeting", { name: "Hannah" })}</p>
</div>
<section class="...">
{/* ... */}
</section>
</>
);
});
// ...
Code language: Diff (diff)
Now, depending on the number of products, the correct plural form is shown for the active locale.
🗒️ Note » If using the CLI to extract messages, all forms will be extracted for all languages. We need to remove the unused forms for the language ourselves.
🗒️ Note » Unlike other i18n libraries, Qwik Speak doesn’t seem to provide a way to override a language’s standard plural forms. For example, if we wanted to handle the non-standard zero case in English, we would need to write conditional logic to handle this in our component code.
🔗 Resource » Read more about inlinePural in the official docs.
🔗 Resource » We go deep in Pluralization: A Guide to Localizing Plurals if you want to learn more.
How do I add HTML to my translation messages?
Adding HTML markup to translation messages requires using Qwik’s dangerouslySetInnerHTML
prop. Since translations are effectively hard-coded, or inlined, at build time, there is little risk of the XSS (Cross-Site Scripting) attacks normally associated with rendering raw HTML. Let’s add a footer component to our app to demonstrate.
// i18n/en-US/app.json
{
"appTitle": "Etttro",
+ "footerText": "<strong>Etttro</strong> is a demo app made with Qwik & Qwik Speak.",
//...
}
Code language: Diff (diff)
// i18n/ar-EG/app.json
{
"appTitle": "إترو",
+ "footerText": "<strong>إترو</strong> هو تطبيق تجريبي تم تصميمه باستخدام Qwik & Qwik Speak.",
// ...
}
Code language: Diff (diff)
Our new footer component:
// src/components/layout/footer.tsx
import { component$ } from "@builder.io/qwik";
import { inlineTranslate } from "qwik-speak";
export default component$(() => {
const t = inlineTranslate();
return (
<footer class="...">
<div class="...">
<p
class="..."
dangerouslySetInnerHTML={t("footerText")}
></p>
</div>
</footer>
);
});
Code language: JavaScript (javascript)
Again, note the dangerouslySetInnerHTML
, which allows injecting raw innerHTML
into a DOM element.
🗒️ Note » If you’re coding along, tuck the new Footer
component in the root layout to see it render.
How do I format localized numbers?
i18n is more than just string translations. Working with numbers and dates is crucial for most apps, and each region handles number and date formatting differently.
A note on regional formatting
Number and date formatting are determined by region, not just language. For example, the US and Canada use English but have different date formats and measurement units. So it’s better to use a qualified locale (like en-US
) instead of just a language code (en
).
Using a language code alone, such as ar
for Arabic, can lead to inconsistency. Different browsers might default to various regions, like Saudi Arabia (ar-SA
) or Egypt (ar-EG
), resulting in varied date formats due to distinct regional calendars.
Luckily, Qwik Speak provides a number formatting function built on the robust standard Intl.NumberFormat object. Let’s use it to format our product prices.
// src/routes/[...lang]/index.tsx
import { $, component$ } from "@builder.io/qwik";
// ...
import {
inlinePlural,
inlineTranslate,
+ useFormatNumber,
type Translation,
} from "qwik-speak";
import LocLink from "~/components/i18n/loc-link";
// ...
export default component$(() => {
const t = inlineTranslate();
const p = inlinePlural();
+ const fn = useFormatNumber();
const productsS = useProducts();
return (
<>
<div class="...">
{/* ... */}
</div>
<section class="...">
{productsS.value.map((product) => (
<LocLink
href={`products/${product.id}`}
key={product.id}
>
<article class="...">
{/* ... */}
<img
width={600}
height={600}
alt={product.title}
class="block aspect-square w-full"
src={`/product-img/${product.imageUrl}`}
/>
<div class="...">
- <p>${product.priceInCents / 100.0}</p>
+ <p>
+ {fn(product.priceInCents / 100.0, {
+ style: "currency",
+ })}
+ </p>
{/* ... */}
</div>
<p class="...">
{product.description.slice(0, 65)}...
</p>
</article>
</LocLink>
))}
</section>
</>
);
});
// ...
Code language: Diff (diff)
The useFormatNumber
function returns a number formatting function, fn
, which takes a number and formatting options. These options are the same ones the Intl.NumberFormat
constructor accepts.
🔗 Resource » We can format numbers with different precisions, and format them as units, percentages, and more. See all the options Intl.NumberFormat accepts in the MDN docs.
In the above call to fn
, we specify a currency style. fn
is locale-aware and will use the currency specified in speak-config.ts
for the active locale.
// src/speak-config.ts
import type { SpeakConfig } from "qwik-speak";
export const config: SpeakConfig = {
defaultLocale: {
lang: "en-US",
currency: "USD",
timeZone: "America/Los_Angeles",
},
supportedLocales: [
{
lang: "ar-EG",
// We can change the currency for Arabic
// here, but we need to convert it from
// USD in our app if we do.
currency: "USD",
timeZone: "Africa/Cairo",
},
{
lang: "en-US",
currency: "USD",
timeZone: "America/Los_Angeles",
},
],
assets: ["app", "nav"],
runtimeAssets: [],
};
Code language: JavaScript (javascript)
🗒️ Heads up » If you specify a currency in the options to fn
, e.g. fn(29.99, { style: "currency", currency: "EUR" })
, it will be overridden by the currency for the active locale in speak-config.ts
.
🔗 Resource » See the official docs for more info about useFormatNumber.
🔗 Resource » Our Concise Guide to Number Localization goes into numeral systems, separators, and more regarding number localization.
How do I format localized dates?
Like numbers, we often overlook the importance of date and time localization. It’s important to handle date formatting carefully since dates and times are formatted differently worldwide.
Qwik Speak provides a useFormatDate
function as a counterpart to useFormatNumber
. Let’s use useFormatDate
to localize our product dates.
🗒️ Note » Localizing dates is similar to localizing numbers, so we recommend you read the previous section before this one.
import { $, component$ } from "@builder.io/qwik";
// ...
import {
inlinePlural,
inlineTranslate,
+ useFormatDate,
useFormatNumber,
type Translation,
} from "qwik-speak";
import LocLink from "~/components/i18n/loc-link";
// ...
export default component$(() => {
const t = inlineTranslate();
const p = inlinePlural();
const fn = useFormatNumber();
+ const fd = useFormatDate();
const productsS = useProducts();
return (
<>
<div class="...">
{/* ... */}
</div>
<section class="...">
{productsS.value.map((product) => (
<LocLink
href={`products/${product.id}`}
key={product.id}
>
<article class="...">
{/* ... */}
<div class="...">
<p>
{fn(product.priceInCents / 100.0, {
style: "currency",
})}
</p>
<p>
- // We no longer need the custom
- // `toShortDate$` function.
- {toShortDate$(product.publishedAt)}
+ {fd(product.publishedAt, {
+ dateStyle: "short",
+ })}
</p>
</div>
<p class="...">
{product.description.slice(0, 65)}...
</p>
</article>
</LocLink>
))}
</section>
</>
);
});
// ...
Code language: Diff (diff)
Unsurprisingly, the fd
function is built on top of Intl.DateTimeFormat, and takes a date and formatting options arguments. The options that the Intl.DateTimeFormat
accepts can be passed as the second argument to fn
.
🔗 Resource » See the MDN docs for Intl.DateTimeFormat for all available formatting options.
🗒️ Heads up » If we set timeZone
s for our locales in speak-config.ts
they will automatically be used by fn
.
🔗 Resource » See the official docs for more info about useFormatDate.
🔗 Resource » Our Guide to Date and Time Localization covers formatting, time zones, regional calendars, and more.
Take your Qwik localization to the next level
We hope you enjoyed this guide to localizing Qwik apps with Qwik Speak.
🔗 Resource » Get the entire demo code from our GitHub repo.
When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.
With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.
This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.