ondrej.
· 6 min čtení

Vícejazyčné weby v Next.js 16: next-intl, hreflang, lokalizace ticketing flow

Jak jsem postavil DJ BOBO Arena Tour weby pro CZ a PL — single codebase, dvě deploymenty, lokalizovaná města a ticketing vendoři. Konfigurace next-intl, hreflang, locale routing, ticketing flow.

nextjsi18nlocalizationticketing

Když Sixart Ticket potřeboval marketingové weby pro DJ BOBO Arena Tour 2026 v Česku a Polsku, požadavek zněl jednoduše: jeden codebase, dva deploye, plně lokalizovaný obsah včetně ticketing vendora. Tady je, jak jsem to postavil v Next.js 16 + next-intl za pár dní.

Live: sixartticket.cz a sixartticket.pl. 6 měst, 2 země, jeden codebase.

Návrh: kdy single codebase, kdy ne

První rozhodnutí je drsnější, než vypadá. Máš tři varianty:

  1. Jeden codebase, jeden deploy/cs a /pl segmenty (i18n routing)
  2. Jeden codebase, dva deploye — buildneš s ENV LOCALE=cs na .cz, LOCALE=pl na .pl
  3. Dva codebases — tradiční fork

Pro tenhle projekt jsem zvolil (2). Důvody:

  • Domény jsou marketingové, každá v jiné zemi a má jinou ticketingovou platformu. Logika a vendor differs natolik, že /cs vs /pl segmenty by tahaly mrtvý kód do druhé domény.
  • SEO chce každá doména jako standalone. Český Google indexuje .cz jinak než .pl.
  • Vercel deploy preview per locale — můžu testovat cs build a pl build odděleně.

Single codebase = sdílí komponenty, design system, page layout. Dva buildy = každý má jen svoje data.

next-intl konfigurace

Tenhle portfolio běží na variantě (1) — locale routing přes next-intl. Tady je essentials:

// src/lib/i18n.ts
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
 
export const locales = ['cs', 'en'] as const;
export const defaultLocale = 'cs';
export type Locale = (typeof locales)[number];
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(locales, requested) ? requested : defaultLocale;
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { defaultLocale, locales } from './lib/i18n';
 
export default createMiddleware({
  locales,
  defaultLocale,
  localePrefix: 'as-needed', // cs bez prefixu, en jako /en
});
 
export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)'],
};

localePrefix: 'as-needed' znamená:

  • / → CS (default, žádný prefix)
  • /en → EN
  • /work → CS work
  • /en/work → EN work

Pro SEO výhoda: hlavní jazyk si nese clean URL bez locale segmentu. Žádné /cs/work, jen /work. Trade-off: musíš si dávat pozor při generování linků (next-intl Link to řeší).

hreflang správně

Tohle je místo, kde 90 % multi-language webů selže. Bez hreflang Google neví, kterou verzi servovat na .cz vs .pl vyhledávači a ranky cestují kam chtějí.

Setup v každé page metadata:

import type { Metadata } from 'next';
import type { Locale } from '@/lib/i18n';
 
export async function generateMetadata({
  params,
}: { params: Promise<{ locale: Locale }> }): Promise<Metadata> {
  const { locale } = await params;
  const path = '/services';
  const csUrl = `https://ondrejknedla.cz${path}`;
  const enUrl = `https://ondrejknedla.cz/en${path}`;
  return {
    alternates: {
      canonical: locale === 'cs' ? csUrl : enUrl,
      languages: { cs: csUrl, en: enUrl, 'x-default': csUrl },
    },
    openGraph: {
      locale: locale === 'cs' ? 'cs_CZ' : 'en_US',
      alternateLocale: locale === 'cs' ? 'en_US' : 'cs_CZ',
    },
  };
}

x-default říká crawlerům, "když nevíš, kterou verzi, použij CS". To je důležité pro non-CZ a non-EN trhy (DE/SK Google a další).

Ticketing flow lokalizace

Tady to bylo zajímavé. CZ verze používá Sixart Ticket API. PL verze polského vendora s úplně jiným flow. Komponenta <TicketCTA> musí být polymorfní:

// components/ticket-cta.tsx
import type { Locale } from '@/lib/i18n';
 
type TicketingProvider = 'sixart' | 'polish-vendor';
 
const PROVIDER_BY_LOCALE: Record<Locale, TicketingProvider> = {
  cs: 'sixart',
  pl: 'polish-vendor',
};
 
export function TicketCTA({
  locale,
  eventId,
  city,
}: { locale: Locale; eventId: string; city: string }) {
  const provider = PROVIDER_BY_LOCALE[locale];
  if (provider === 'sixart') {
    return <SixartCTA eventId={eventId} city={city} />;
  }
  return <PolishVendorCTA eventId={eventId} city={city} />;
}

Klíčové učení: nikdy si nelep vendor logiku do jedné komponenty s if/else. Polymorfní složka, kde každý provider je samostatný komponent. Když přidám DE verzi, jen rozšířím union a přidám <DeVendorCTA>.

Lokalizovaná města (ne jen překlad)

CS verze pokrývá Ostravu (17.10.) a Bratislavu (24.10.). PL verze Gdańsk, Łódź a Krakov. Každé město má jiné venue, jiný layout sálu (5+ kategorií sedadel včetně VIP "Golden Seat"), jiné ticketing IDs.

Ne stejná data prefixována locale. Úplně jiný dataset:

// data/events.cs.ts
export const events: Event[] = [
  {
    city: 'Ostrava',
    date: '2026-10-17',
    venue: 'Ostravar Aréna',
    seatTiers: ['VIP Golden Seat', 'Standing Floor', 'Sezení A', ...],
    ticketingId: 'sx-ostrava-2026',
  },
  // ...
];
 
// data/events.pl.ts — úplně jiné objekty, jiné tier names, jiný vendor

Komponenta page jen importuje správný dataset:

import { events } from `data/events.${locale}`;

Build-time discriminace přes ENV LOCALE určuje, který dataset se zabuildí. Druhá doména vůbec nemá kód té první.

Reálné metriky

MetricCS sitePL site
Cities2 (Ostrava, Bratislava)3 (Gdańsk, Łódź, Krakov)
VendorSixart TicketPolish vendor
Build size~180 KB First Load JS~180 KB
Lighthouse SEO100100
Time to shipshared4 dny pro oba

Časté chyby (a jak se vyhnout)

1. Locale switcher, který drží query stringy. Když uživatel je na /work?filter=ai a přepne na EN, chceš /en/work?filter=ai. next-intl usePathname to řeší, ale musíš to explicitně preservovat.

2. Date formatting napříč locale. CS je 17. 10. 2026, PL je 17.10.2026, EN Oct 17, 2026. Nikdy si nepiš vlastní format funkci. Použij Intl.DateTimeFormat(locale).

3. SEO meta jen v jednom jazyce. Open Graph musí mít locale: 'cs_CZ' nebo 'pl_PL', ne jen 'cs'. Plus alternateLocale array pro ostatní lokace.

Co dál

Pokud řešíš multi-locale Next.js projekt (e-commerce, ticketing, marketing site), napiš mi. Většina problémů je předvídatelná, správný setup ti ušetří týdny.