SSR Date Formatting in Remix using the User's Locale

How I achieved consistent date formatting in Remix on both client side and server side rendered React using a LocaleProvider and Intl.DateTimeFormat.

Do you see the publication date above? It probably says Mar 25, 2022. Or does it say 25 Mar 2022? Or maybe something else? Well, it depends. It depends on your locale.

According to Wikipedia:

A locale is a set of parameters that defines the user's language, region and any special variant preferences that the user wants to see in their user interface.

In order to give users a more "familiar" experience, I wanted to format the date for them. And that's what this article is all about.

Background

I recently wrote my website using Remix. Remix is a React framework that uses SSR (Server Side Rendering) to render the HTML on the server for faster TFP (Time to First Paint). My website needed to display my blog posts, including the published date.

The problem

Because Remix uses SSR, the date is actually rendered twice: once on the server and once on the client (during hydration). When the server renders the date, it renders in the locale of the server (i.e. en-US), but the client renders in the user's preferred locale. That means that if the user is anything but US English they will see a flash as the client rerenders. I needed a way for both the server and the client to render the same HTML.

The solution

I came up with a solution that would involve adding a locale provider around the server and one around the client that is based on the same locale. But how would I get the user's locale passed to the server on the initial request?

We can simply Use the Platform™. Browsers, as it turns out, send an Accept-Language HTTP header on each request that contains the user's preferred locales.

Specifying your preferred locales

In Chrome, you can change your preferred locales at chrome://settings/languages and expanding the "Language" section. I have my browser set to "English (United Kingdom)" then "English (United States)", then "English".

Chrome's language settings

When I visit a webpage, my Accept-Language HTTP request header looks like this.

Accept-Language: en-GB,en-US;q=0.9,en;q=0.8

But what are those goofy "q" values? They are called "relative quality factor" and are a way to order the preference the user has for a particular locale. They range from zero to one. The higher the number, the more preference the user has for that locale. If a locale doesn't have a "q", it is assumed to be "1.0" The numbers are assigned by the browser based on order.

How does this help us render dates in React that are in the user's locale?

Locale Provider

There are some open source locale providers out there, but I chose to roll my own as I didn't need complete language support, just date formatting.

Here is the code I used to create the locale provider. It contains the provider and a hook to access the locales.

LocaleProvider.tsx
import { createContext, ReactNode } from 'react';

type LocaleContext = {
  locales: string[];
};

type LocaleContextProviderProps = {
  locales: string[];
  children: ReactNode;
};

const Context = createContext<LocaleContext | null>(null);

export const LocaleContextProvider = ({
  locales,
  children,
}: LocaleContextProviderProps) => {
  const value = { locales };
  return <Context.Provider value={value}>{children}</Context.Provider>;
};

const throwIfNoProvider = () => {
  throw new Error('Please wrap your application in a LocaleContextProvider.');
};

export const useLocales = () => {
  const { locales } = useContext(Context) ?? throwIfNoProvider();
  return locales;
};

Wrapping the App on the server

Now that we have a provider, we need to wrap our application with it. We do this differently depending on whether we are in a browser or on the server.

On the server, inside of entry.server.tsx, we wrap <RemixServer /> and pass in the locales to the provider as an array of locale strings sorted in order of user preference (remember those goofy "q" values from above?)

We use the package intl-parse-accept-language to parse the Accept-Language header into an array of locale strings.

entry.server.tsx
import { parseAcceptLanguage } from 'intl-parse-accept-language';

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const acceptLanguage = request.headers.get('accept-language');
  const locales = parseAcceptLanguage(acceptLanguage, {
    validate: Intl.DateTimeFormat.supportedLocalesOf,
  });

  const markup = renderToString(
    <LocaleContextProvider locales={locales}>
      <RemixServer context={remixContext} url={request.url} />
    </LocaleContextProvider>
  );
  responseHeaders.set('Content-Type', 'text/html');

  return new Response('<!DOCTYPE html>' + markup, {
    status: responseStatusCode,
    headers: responseHeaders,
  });
}

Rendering dates in React

We have our provider and we have our locales. Now we need to render dates in React. To do so, I've written a component that renders an HTML <time /> element and formats the display date using JavaScript's Intl.DateTimeFormat class.

IntlDate.tsx
import { useLocales } from '~/providers/LocaleProvider';

type IntlDateProps = {
  date: Date;
  timeZone?: string;
};

export const IntlDate = ({ date, timeZone }: IntlDateProps) => {
  const locales = useLocales();
  const isoString = date.toISOString();
  const formattedDate = new Intl.DateTimeFormat(locales, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    timeZone,
  }).format(date);

  return <time dateTime={isoString}>{formattedDate}</time>;
};

On my blog, I'm rendering the date of the post using this component. The publishedOn of the post's frontmatter is stored and displayed in UTC. In my case, there's no need to worry about time zones (hence the timeZone="UTC").

<IntlDate date={post.publishedOn} timeZone="UTC" />

Client side rendering

So far we've been rendering dates in React on the server. Now we need to render them on the client, using the same locales. On the client, we don't have access to the request headers, but we do have navigator.languages. This is the same data point that the browser uses to build the Accept-Language header. Everything else is the same.

I should point out that "languages" is a misnomer. It's actually the user's preferred "locales".

Here's how you add the <LocaleContextProvider /> to your client-side application.

entry.client.tsx
const locales = window.navigator.languages;

hydrate(
  <LocaleContextProvider locales={locales}>
    <RemixBrowser />
  </LocaleContextProvider>,
  document
);

That's it!

You now have everything you need to render user locale specific dates in Remix, both server and client side.