Files
web/apps/scandic-web/i18n/i18n.md
Anton Gunnarsson 233c685e52 Merged in feat/sw-2333-package-and-sas-i18n (pull request #2538)
feat(SW-2333): I18n for multiple apps and packages

* Set upp i18n in partner-sas

* Adapt lokalise workflow to monorepo

* Fix layout props


Approved-by: Linus Flood
2025-07-10 07:00:03 +00:00

8.4 KiB

Internationalization

The lang route parameter

All page paths starts with a language parameter, e.g. /sv/utforska-scandic/wi-fi.

Get the language in a client component

We have a hook called useLang that directly returns the lang parameter from the path.

Get the language in a server component

In order to not prop drill that all the way from a page we use React's cache in a way that resembles React`s context, but on the server side.

For this to work we must set the language with setLang on the topmost layout

This was inspired by server-only-context

Translations a.k.a. UI labels a.k.a. Lokalise

Quickstart: How to use react-intl in the codebase

For more information read about the workflow below.

  • Do not destructure formatMessage from either getIntl() or useIntl().

    Do not do this:

    const { formatMessage } = useIntl()
    const message = formatMessage(...)
    

    instead do this:

    const intl = useIntl()
    const message = intl.formatMessage(...)
    

    Do not do this:

    const { formatMessage } = await getIntl()
    const message = formatMessage(...)
    

    instead do this:

    const intl = await getIntl()
    const message = intl.formatMessage(...)
    
  • Do not pass variables as defaultMessage.

    The defaultMessage needs to be a literal string so that the tooling can properly find and extract defined messages. Do not use template strings with variable interpolation either.

    Do not do this:

    const data = await getSomeData()
    ...
    const message = intl.formatMessage({
      defaultMessage: data.type,
    })
    ...
    const message = intl.formatMessage({
      defaultMessage: `Certification: ${data.type}`,
    })
    

    instead do this:

    This is a hard one to give a general solution/rule for, but in essence it should use either use a "switch approach" or defineMessage/defineMessages (docs) in some way.

    The most common reason for this scenario is that the data contains one or several words/sentences we want translated.

    The preferred solution is to use a "switch approach" (or something equivalent), checking some property of the entity to decide what message to use.

    • It is explicit about what cases are supported: this helps finding bugs by lowering complexity by making it easier to find where a string is used.
    • The declaration is coupled with the usage: keeping our messages up to date and clutter free over time.
    • The formatjs eslint plugin helps enforce proper usage.
    • TypeScript will force us to keep the list up to date with the typings of data.type. Preferably ALL data points of this manner should be an enum or an array of strings as const (or equivalent) for the best TS support here.
    ...
    const data = await getSomeData()
    ...
    let message = intl.formatMessage({defaultMessage: 'N/A'})) // or some other default/"no match" message
    switch (data.type) {
      case "Restaurant":
        message = intl.formatMessage({defaultMessage: "Restaurant"})
        break;
      case "Bar":
        message = intl.formatMessage({defaultMessage: "Bar"})
        break;
      case "Pool":
        message = intl.formatMessage({defaultMessage: "Pool"})
        break;
      case "Some certification":
        message = intl.formatMessage({defaultMessage: "Certification: {name}"}, { name: "Some certification"})
        break;
      default:
        // TS will throw if it reaches here if typings for `data.type` are properly defined and the above cases are exhaustive.
        // This will help us keep messages up to date.
        const type: never = data.type
        console.warn(`Unsupported type given: ${type}`)
    }
    

    If the above is not possible the escape hatch is using something like the following. Avoid using this because:

    • This decouples the message declaration from the message consumption: causing stale messages to linger around and clutter up the codebase.
    • It makes it a lot harder to find where a string is being used in the codebase.
    • The formatjs eslint plugin is unable to enforce placeholders: this decreases confidence in our messages and potentially hiding bugs.
    import { defineMessages } from "react-intl"
    ...
    const data = await getSomeData()
    ...
    const restaurantMessage = defineMessage({
      defaultMessage: "Restaurant",
    })
    const barMessage = defineMessage({
      defaultMessage: "Bar",
    })
    const poolMessage = defineMessage({
      defaultMessage: "Pool",
    })
    // OR
    const messages = defineMessages({
      restaurant: {
        defaultMessage: "Restaurant",
      },
      bar: {
        defaultMessage: "Bar",
      },
      pool: {
        defaultMessage: "Pool",
      }
    })
    ...
    return (
      <p>{intl.formatMessage(messages.restaurant)}</p> // or .bar or .pool, etc.
    )
    ...
    
    // Since calls to defineMessage/defineMessages get their messages extracted,
    // technically you can do the following instead of accessing the key like above.
    // But it is not encouraged as this decoupling leads to had to track usage and decay over time:
    const message = intl.formatMessage({
      // eslint-disable-next-line formatjs/enforce-default-message
      defaultMessage: data.type, // data.type === "Restaurant" | "Bar" | "Pool"
    })
    
  • Do not use defaultMessage key as an alias.

    The defaultMessage should be the actual message you want to display (and send to Lokalise). It must be written in American/US English.

    Do not do this:

    const message = intl.formatMessage({
      defaultMessage: "some.alias",
    })
    

    instead do this:

    const message = intl.formatMessage({
      defaultMessage: "The real message is here in US English",
    })
    
  • Do not use conditionals when defining messages.

    Do not do this:

    const message = intl.formatMessage({
      defaultMessage: someVariable ? "Variable is truthy" : "Variables i falsey",
    })
    

    instead do this:

    const truthyMessage = intl.formatMessage({
      defaultMessage: "Variable is truthy",
    })
    const falseyMessage = intl.formatMessage({
      defaultMessage: "Variable is falsey",
    })
    const message = someVariable ? truthyMessage : falseyMessage
    
  • Avoid using ICU special words as placeholder names

    Some words have meaning in ICU when dealing with placeholders and such. To avoid bugs and confusion do not use them as placehodler names.

    Avoid (list is not exhaustive):

    • {number}
    • {integer}
    • {plural}
    • {date}
    • {time}
    • {select}
    • {choice}
    • {other}

    Also do not use the current variable name in scope as the placeholder name (unless the variable is named following the below rules). It will confuse translators.

    Do not do this:

    const number = getValueSomeWay()
    ...
    const message = intl.formatMessage(
      {
        defaultMessage: "The number is {number}",
      },
      {
        number,
      }
    )
    

    nor this

    const goodNameForVarButNotForPlaceholder = getValueSomeWay()
    ...
    const message = intl.formatMessage({
      defaultMessage: "The number is {goodNameForVarButNotForPlaceholder}",
    }, {
      goodNameForVarButNotForPlaceholder
    })
    

    instead do this:

    Prefer a placeholder name that gives context to the message when reading it without the context of the code

    const goodNameForVarButNotForPlaceholder = getValueSomeWay()
    ...
    const message = intl.formatMessage(
      {
        defaultMessage: "The number is {membershipNumber}",
      },
      {
        membershipNumber: goodNameForVarButNotForPlaceholder,
      }
    )
    

    or if context is hard to give, use generic words like value, count, amount, etc.

    const number = getValueSomeWay()
    ...
    const message = intl.formatMessage(
      {
        defaultMessage: "The number is {value}",
      },
      {
        value: number,
      }
    )
    
  • Do not give id to messages.

    The eslint plugin will automatically fix this (removes the id on save/fix).

    Do not do this:

    const message = intl.formatMessage({
      id: "some-id",
      defaultMessage: "This is a message",
    })
    

    instead do this:

    const message = intl.formatMessage({
      defaultMessage: "This is a message",
    })