Back to writing

Translating Breadcrumbs in Remix

December 5, 2023

Two clean ways to make Remix breadcrumb handles work with i18n without fighting where translation context lives.

The Remix breadcrumbs pattern is elegant right up until you add translations.

The docs show a nice approach where each route exports its own breadcrumb through handle, and the root collects the full trail with useMatches(). That works well for plain text.

The problem is that translations usually live in component land. Your useTranslation() hook is available where you render the breadcrumbs, but not necessarily where you define them.

That mismatch is the real problem.

Once you see it that way, there are two clean fixes.

The tension

handle is a route-level escape hatch. It is great for attaching metadata to a route, but it is not a React component. That means it often does not have direct access to the hooks or context you want.

So the choice becomes:

  1. pass the missing context into the handler
  2. keep the handler dumb and return raw data instead

Both can work. The better option depends on what you want handle to represent.

Option 1: pass translation context into the handler

If you want the route handle to keep returning UI, pass the translation utilities in when you render the breadcrumb list.

const matches = useMatches();
const translation = useTranslation();
 
{matches
  .filter((match) => match.handle?.breadcrumb)
  .map((match, index) => (
    <li key={index}>
      {match.handle.breadcrumb({ ...match, ...translation })}
    </li>
  ))}

Then the route can consume that context directly:

export const handle = {
  breadcrumb: ({ t }) => <Link to="/parent">{t("parent.label")}</Link>,
};

This is the more direct option. It preserves the original shape of the docs example and lets each route fully describe its own rendered breadcrumb.

The catch is that handle now depends on ambient context that is not obvious from the route file alone. That is not always bad, but it does make the pattern a little more magical.

Option 2: return data, not UI

The other option is to let handle describe only the breadcrumb data and keep the rendering in one place.

export const handle = {
  breadcrumb: () => ({ key: "parent.label", to: "/parent" }),
};

Then in the root, where translation hooks already exist, translate and render the breadcrumb normally:

const { t } = useTranslation();
 
{matches
  .filter((match) => match.handle?.breadcrumb)
  .map((match, index) => {
    const { key, to } = match.handle.breadcrumb(match);
 
    return (
      <li key={index}>
        <Link to={to}>{t(key)}</Link>
      </li>
    );
  })}

This keeps handle simpler and makes the translation dependency explicit at the rendering layer.

It is also the version I usually prefer, because it keeps route metadata closer to data and leaves UI concerns in components.

Which one to pick

Use option 1 if you want each route to fully own how its breadcrumb is rendered.

Use option 2 if you want the route to describe the breadcrumb and the root to decide how it gets displayed.

That is the broader lesson here. When handle starts feeling awkward, the fix is usually one of two things:

  • give it more context
  • or make it return simpler data

Breadcrumbs just happen to make that tradeoff very visible.