Software localization

Mastering Qwik Localization: A Comprehensive Guide

We localize a Qwik app with the Qwik Speak i18n library, covering all the ins and outs of localization.
Qwik Speak logo on a blue background with HTML code snippets and decorative dots, representing the theme of localization in the context of Qwik application development

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.

Screenshot of the Etttro demo app's home page showing three latest products: a Commodore 64 for $149.99 dated 2024-06-01, a Nintendo Virtual Boy for $229 dated 2024-06-05, and a Sony Walkman for $199 dated 2024-06-10. The app has a dark green background and includes navigation links for "Latest products" and "About us."

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 framework
  • qwik-speak@0.23 — the i18n library
  • rtl-detect@1.1 — detects the layout direction of a language
  • tailwindcss@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@latestCode 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 tailwindCode 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 supporting en-US (English as used in the United States) and ar-EG (Arabic as used in Egypt). Feel free to support any locales you want.
  • defaultLang — this needs to be one of our supportedLangs 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 to i18n.

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.

Screenshot of the Etttro demo app's header showing the app title "Etttro" with a pixelated alien icon, and navigation links for "Latest products" and "About us" on a dark green background.

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)

Screenshot of the Etttro demo app's header showing the app title in Arabic with a pixelated alien icon, and navigation links for "Latest products" and "About us" in English on a dark green background.

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.

An animation showing various links in the app being clicked. The browser address bar always shows the en-US/ locale route prefix whenever a link is clicked.
Note that the en-US prefix is always present in the browser address bar.

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.

An animation showing the locale switcher dropdown in action. The dropdown is clicked, revealing options for English and Arabic. When Arabic is selected, the current page is shown with Arabic translations. When English is selected, the page is shown with English translations.

🗒️ 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 keys 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.

Screenshot of the browser inspector DOM view showing the HTML element with attributes: lang="ar-EG", and dir="rtl".

And, of course, this causes our page to be laid out from right to left.

Screenshot of the Etttro demo app's home page showing the latest products laid out right-to-left. The header is in Arabic with a language switcher button labeled "(Arabic) العربية". The products displayed are a Sony Walkman for $199 dated 2024-06-10, a Nintendo Virtual Boy for $229 dated 2024-06-05, and a Commodore 64 for $149.99 dated 2024-06-01. The app has a dark green background.

🔗 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)
Screenshot of the browser DOM inspector showing the metadata of the HTML document. The title tag contains (in Arabic) "إتترو | سوق الأجهزة الرجعية". The meta description tag contains (in Arabic) "إتترو هو سوق مجتمعي لجميع الأجهزة الإلكترونية الرجعية المستعملة." Other visible elements include canonical link, viewport meta tag, and favicon link.
The title and meta description are translated to Arabic when our home page locale is ar-EG.

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.

Screenshots of the user greeting in English and Arabic. The English greeting reads, "Hello, Hannah." The Arabic one reads, "مرحباً هنا".

🔗 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.

Screenshot comparing plural forms in English (en-US) and Arabic (ar-EG) for the phrase "Latest Product." In English, "one" maps to "1 Latest Product" and "other" maps to "3 Latest Products." In Arabic, "zero" maps to "لا توجد منتجات," "one" maps to "أحدث منتج," "two" maps to "أحدث منتجين," "few" maps to "أحدث 3 منتجات," "many" maps to "أحدث 11 منتج," and "other" maps to "أحدث 100 منتج."

🗒️ 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 &amp; Qwik Speak.",
  //...
}
Code language: Diff (diff)
// i18n/ar-EG/app.json

{
  "appTitle": "إترو",
+ "footerText": "<strong>إترو</strong> هو تطبيق تجريبي تم تصميمه باستخدام Qwik &amp; 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.

Screenshot of browser DOM inspector showing rendered HTML. A  paragraph with the class attribute "text-center" is shown containing the text "Etttro is a demo app made with Qwik & Qwik Speak," where "Etttro" is enclosed in a strong tag.
The HTML in the translation message is rendered as-is.

🗒️ 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.

Screenshot of the Etttro demo app showing product listings with localized number formatting in English (en-US) and Arabic (ar-EG). The English section displays prices as $149.99 and $229.00, and the Arabic section displays prices as $US ١٩٩٫٠٠ and $US ٢٣٩٫٠٠. The products are a Commodore 64, a Nintendo Virtual Boy, and a Sony Walkman, each with descriptions and prices in the respective languages and formats.

🔗 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 timeZones for our locales in speak-config.ts they will automatically be used by fn.

Screenshot of the Etttro demo app showing product listings with localized date formatting in English (en-US) and Arabic (ar-EG). The English section displays dates as 6/1/24 and 6/5/24, and the Arabic section displays dates as ١/٦/٢٠٢٤ and ٥/٦/٢٠٢٤. The products are a Commodore 64, a Nintendo Virtual Boy, and a Sony Walkman, each with descriptions and prices in the respective languages and formats.

🔗 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.