Formatting currencies and dates
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.
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
?
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.
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.
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 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 {}
}
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 {}
}
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.
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.
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.
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!