DEV Community

Jakub
Jakub

Posted on

Serving 78 cards x 5 languages from one codebase: our i18n routing

Tarotas is a tarot card app we build at Inithouse. You shuffle, draw a card, and read a calm interpretation. No account required, no fortune-telling claims. Just a quiet space for reflection.

The tricky part: we serve it in five languages (Czech, Slovak, English, German, Polish) across 78 tarot cards, all from a single React codebase on a single domain. That's 390 card detail pages, plus suit indexes, language homepages, and category pages. Our sitemap has 124 unique URLs, each cross-linked with hreflang alternates to the other four language variants.

Here's how we set it up and what we learned along the way.

The URL scheme

We went with a language prefix in the path. English lives at the root (it's also the x-default fallback), everything else gets a two-letter prefix:

tarotas.com/            → English (x-default)
tarotas.com/cs          → Czech
tarotas.com/sk          → Slovak
tarotas.com/de          → German
tarotas.com/pl          → Polish
Enter fullscreen mode Exit fullscreen mode

Card detail pages follow the same pattern:

tarotas.com/cards/the-fool
tarotas.com/cs/cards/the-fool
tarotas.com/de/cards/the-fool
Enter fullscreen mode Exit fullscreen mode

We keep card slugs in English across all languages. A Czech user visiting /cs/cards/the-fool sees the Czech interpretation, but the URL stays consistent. This was a deliberate trade-off: localized slugs (/cs/karty/blazen) would look nicer but would double the routing complexity and make cross-language linking harder to maintain. For a 78-card deck multiplied by five languages, consistent slugs keep the codebase manageable.

Routing in a React SPA

The app is built with React and Vite, deployed as a single-page application. Our router extracts the language prefix from the URL on every navigation:

// Simplified version of our language detection
const SUPPORTED_LANGS = ['cs', 'sk', 'de', 'pl'] as const;
type Lang = typeof SUPPORTED_LANGS[number] | 'en';

function getLangFromPath(pathname: string): Lang {
  const firstSegment = pathname.split('/')[1];
  if (SUPPORTED_LANGS.includes(firstSegment as any)) {
    return firstSegment as Lang;
  }
  return 'en'; // root = English
}
Enter fullscreen mode Exit fullscreen mode

The detected language feeds into a context provider that the rest of the app consumes. Card interpretations, UI labels, meta descriptions, page titles: everything pulls from the current language context.

One thing we got wrong initially: we tried detecting browser language via navigator.language and auto-redirecting. Users hated it. Someone in Germany might want the English version. Someone in Czechia might be learning German and want /de. We dropped auto-redirect entirely and just let the URL be the source of truth. A language picker in the header handles the rest.

Hreflang: telling Google which page belongs where

Every page emits a full set of <link rel="alternate" hreflang="..."> tags in the <head>. For a card detail page, that's six links (five languages plus x-default):

<link rel="alternate" hreflang="cs" href="https://tarotas.com/cs/cards/the-fool" />
<link rel="alternate" hreflang="sk" href="https://tarotas.com/sk/cards/the-fool" />
<link rel="alternate" hreflang="en" href="https://tarotas.com/cards/the-fool" />
<link rel="alternate" hreflang="de" href="https://tarotas.com/de/cards/the-fool" />
<link rel="alternate" hreflang="pl" href="https://tarotas.com/pl/cards/the-fool" />
<link rel="alternate" hreflang="x-default" href="https://tarotas.com/cards/the-fool" />
Enter fullscreen mode Exit fullscreen mode

We mirror this in the XML sitemap. Every <url> entry carries <xhtml:link> alternates for all five variants. With 124 URLs in the sitemap, that's 744 alternate links that need to stay in sync.

The critical rule with hreflang: it must be bidirectional. If the Czech page claims the English page is its alternate, the English page must confirm the same relationship back. We generate both the head tags and the sitemap from the same data source (a list of all routes crossed with all supported languages), so they can't drift apart.

Generating the sitemap from route definitions

Rather than maintaining the sitemap by hand, we generate it from the route config:

const languages = ['cs', 'sk', 'en', 'de', 'pl'];
const routes = ['/', '/cards', '/cards/wands', '/cards/cups',
  '/cards/swords', '/cards/pentacles',
  ...allCards.map(c => `/cards/${c.slug}`)
];

function buildSitemap(): string {
  const urls = routes.flatMap(route =>
    languages.map(lang => {
      const loc = lang === 'en' ? route : `/${lang}${route}`;
      const alternates = languages.map(l => ({
        hreflang: l === 'en' ? 'en' : l,
        href: `https://tarotas.com${l === 'en' ? '' : `/${l}`}${route}`
      }));
      alternates.push({
        hreflang: 'x-default',
        href: `https://tarotas.com${route}`
      });
      return { loc: `https://tarotas.com${loc}`, alternates };
    })
  );
  // ... serialize to XML
}
Enter fullscreen mode Exit fullscreen mode

This approach has saved us from plenty of silent SEO bugs. When we added the /cards/{suit} category pages, the sitemap updated automatically. When we briefly considered adding Hungarian as a sixth language, we could see the full impact just by adding 'hu' to the array: 24 new URLs, 150+ new alternate links.

What we measured

Google Search Console picked up all five language variants within about two weeks of the sitemap submission. A few observations from six months of running this setup:

Czech and Slovak pages got indexed fastest, probably because most of our early traffic came from those regions. The German and Polish variants took a bit longer but eventually caught up.

We saw zero "duplicate content" warnings in GSC. The hreflang implementation seems to work as intended: Google treats each language variant as a distinct page rather than a copy.

One surprise: the Polish version consistently gets the highest engagement per session, even though it has the smallest share of traffic. We haven't figured out why yet, but it's interesting enough that we're paying attention.

Mistakes we'd skip next time

Canonical tag confusion. Early on, we accidentally set every page's canonical to the English version. That told Google "all these language variants are copies of the English page." It took us a few weeks to notice the Czech and German pages dropping out of the index. Each language variant needs its own self-referencing canonical.

Missing trailing-slash consistency. Our router treated /cs and /cs/ as the same page, but the sitemap listed them without trailing slashes. Meanwhile, some internal links pointed to /cs/. Google saw them as different URLs and split the crawl budget. We standardized on no trailing slashes everywhere.

Client-side rendering vs. crawlability. Since Tarotas is a React SPA, Googlebot needs to execute JavaScript to see the content. We confirmed through the URL Inspection tool that rendering works, but it does add latency to indexing. If we were starting fresh, we'd probably use SSR or at minimum pre-render the card detail pages.

The takeaway

Running a multilingual site from a single codebase is doable if you commit to a few principles early: language-in-path routing, generated (not hand-maintained) sitemaps, bidirectional hreflang, and self-referencing canonicals. For Tarotas, this setup lets us serve 78 cards in five languages without maintaining five separate codebases or even five separate build pipelines. It's one repo, one deploy, one sitemap generator.

We ship all our products at Inithouse as single-codebase SPAs, and the pattern has held up across projects with very different content shapes. The i18n tax is real, but it's a solvable problem once you get the routing and SEO plumbing right from day one.

Top comments (1)

Collapse
 
pinagaremogodi profile image
Pinagare Mogodi

🙌