Captain Codeman Captain Codeman

Internationalization Formatting with Intl + SSR + SvelteKit

Formatting currencies and dates

Contents

Introduction

The great thing about dollars is that they are everywhere. I don’t mean lying around in the street (despite the recent inflation) but globally many countries use “dollars” for their currency. You dollar users have no idea the trouble we had back in the day making printers print our British Pound symbols but the real not-so-great thing is that they are all different types of dollars, with different values, and if you’re running an eCommerce site that supports multiple currencies you can’t get away with just sticking a $ symbol in front of prices - people need to know if you mean US Dollars, Canadian Dollars, Australian Dollars and so on.

Now you could of course just stick a notice that all prices are in “whatever-bucks”, but you may be in a situation where you list prices in one currency and allow customers to pay in their local currency. Or it may not be currencies at all, but general numbers or dates that you want to localize so users interpret them correctly.

Fortunately, since the Intl objects came along this is now a lot easier to do … on the client anyways. But now that we also have easier ways to add SSR to our apps, it’s easy to run into subtle issues.

Flash of Wrongly Localized Content

So what is the issue? Say we want to format a currency, we can create a simple Svelte component to do that for us:

<script lang="ts">
  import { browser } from '$app/environment'

  export let currency: string
  export let amount: number

  $: locale = browser ? navigator.language : 'en-US'

  $: formatter = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    maximumFractionDigits: 2,
    minimumFractionDigits: 2,
  })

  $: formatted = formatter.format(amount)
</script>

<span class={$$props.class} style="tabular-nums lining-nums">{formatted}</span>

This could be used anytime a currency value needs to be rendered and even though the numeric amount is the same, the formatted output will show the monetary values are different. For example, the same numeric amount for two different $ currencies:

<script lang="ts">
  import Currency from '$lib/Currency.svelte'
</script>

<Currency currency="CAD" amount={123.45} /><br />
<Currency currency="USD" amount={123.45} />

Renders:

CA$123.45
$123.45

So problem solved, right? Well, no. It only renders that when the browser locale is set to en-US. Check the code for the component again - it uses the browsers language setting but to run on the server, it has to use a hard coded default because the navigator object isn’t available, it’s a browser thing (hence the browser flag check). That means the server-side rendered content will always use the US format.

What happens if you use a browser with some other format set as the preferred language, such as en-CA?

browser-preferred-languages

Now the SSR content stays the same as it was before but when the page is hydrated on the client it will switch to using the now-available navigator.language which means the content will flip (noticeably) to become:

$123.45
US$123.45

This is jarring and ugly and affects anything using the Intl objects to format numbers, currencies or dates.

Localization of SSR Output

What we need is to have the server-rendered content use the exact same language / locale setting that the client will use when hydrated.

The only way to do that is to tell the server what the client is set to use.

HTTP Content Negotiation

Fortunately, the Hypertext Transfer Protocol (HTTP) already provides a “content negotiation” system where the browser informs the server of the content it prefers by passing an Accept-Language header in the request.

Using a package such as intl-parse-accept-language we can easily parse this header on the server.

SvelteKit hooks.server Handler

SvelteKit provides a hooks.server handle method which is the perfect place to intercept a request, parse this header and set a property on the locals object which provides per-request local scope on the server.

import { parseAcceptLanguage } from 'intl-parse-accept-language'
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
  const { locals, request } = event

  const locales = parseAcceptLanguage(request.headers.get('accept-language') || '')
  locals.locale = locales.length ? locales[0] : 'en-US'

  return resolve(event)
}

Tip: remember to set the property type for the locals object using src/app.d.ts:

declare namespace App {
  // interface Error {}
  interface Locals {
    locale: string
  }
  // interface PageData {}
  // interface Platform {}
}

Root Layout Load Function

So we now have the locale string on the server. But how do we access it in our component? The trick is to use another SvelteKit feature which is the data loading mechanism.

Any page or layout can define a load() function that can be set to run on the server. We want the locale property to be available globally to our app so it makes sense to add it to the root src/routes/+layout.server.ts load function:

import type { LayoutServerLoad } from "./$types"

export const load: LayoutServerLoad = async ({ locals }) => {
  const { locale } = locals

  return { locale }
}

All this does is pass the locale property from the request scope locals object to the page where it becomes available as a layout data property. It is also added to a page store that can be referenced by any Svelte layout, page or component which is what we’ll use.

First though, lets also add the property to the PageData interface in the src/app.d.ts file so TypeScript knows about it, just like we did with the Locals interface:

declare namespace App {
  // interface Error {}
  interface Locals {
    locale: string
  }
  interface PageData {
    locale: string
  }
  // interface Platform {}
}

$page.data Store Property

Now we can update our component and replace the browser check with the $page.data.locale property:

<script lang="ts">
  import { page } from '$app/stores'

  export let currency: string
  export let amount: number

  $: locale = $page.data.locale

  $: formatter = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    maximumFractionDigits: 2,
    minimumFractionDigits: 2,
  })

  $: formatted = formatter.format(amount)
</script>

<span class={$$props.class} style="tabular-nums lining-nums">{formatted}</span>

Now the client and the server are in sync, both will use the same locale setting resulting in exactly the same formatted content being rendered on server and client, with no flash of incorrect output before hydration allowing you to use the Intl formatting objects with confidence.

Caching

If you use SSR and CSR after hydration, you’ll now have a working setup. But if you apply any caching to speed up your site there are a couple of things to be aware of.

Server Side with Redis

If you use something like Redis to cache your SvelteKit page output you’ll need to make sure that the locale used to render the page is part of the cache key. This ensures that a separate version is cached and served for each different locale used, otherwise you’d be back to the same issue in an even less predictable format.

HTTP Caching

If you set HTTP cache headers to allow your content to be cached on proxies, CDNs and on the client, you’ll want to add Accept-Language to the HTTP Vary header which effectively acts the same way as adding the locale to the Redis cache key - only clients with the same Accept-Language headers will be served the cached content to again prevent reverting to the indeterminate behavior.

Hope you found this useful!