Multi-language sites in Next.js 16: next-intl, hreflang, localizing ticketing flows
How I built the DJ BOBO Arena Tour sites for CZ and PL — single codebase, two deploys, localized cities and ticketing vendors. next-intl config, hreflang, locale routing, ticketing flow.
When Sixart Ticket needed marketing sites for DJ BOBO Arena Tour 2026 in the Czech Republic and Poland, the brief sounded simple: one codebase, two deploys, fully localized content including the ticketing vendor. Here is how I built it in Next.js 16 + next-intl in a few days.
Live: sixartticket.cz and sixartticket.pl. 6 cities, 2 countries, one codebase.
Architecture: when single codebase, when not
The first decision is harder than it looks. You have three options:
- One codebase, one deploy —
/csand/plsegments (i18n routing) - One codebase, two deploys — build with
LOCALE=csto.cz,LOCALE=plto.pl - Two codebases — traditional fork
For this project I went with (2). Reasons:
- The domains are marketing, each in a different country and on a different ticketing platform. The logic and vendor differ enough that
/csvs/plsegments would drag dead code into the other domain. - SEO wants each domain as a standalone. Czech Google indexes
.czdifferently than.pl. - Vercel preview deploys per locale — I can test the
csbuild andplbuild in isolation.
Single codebase = shared components, design system, page layout. Two builds = each only carries its own data.
next-intl config
This portfolio runs option (1) — locale routing via next-intl. The 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 without prefix, en as /en
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};localePrefix: 'as-needed' means:
/→ CS (default, no prefix)/en→ EN/work→ CS work/en/work→ EN work
The SEO upside: the main language gets clean URLs without a locale segment. No /cs/work, just /work. The trade-off: you have to be careful when generating links (next-intl's Link solves this).
hreflang done right
This is where 90% of multilanguage sites mess up. Without hreflang, Google does not know which version to serve to .cz vs .pl searchers and rankings travel wherever they please.
Setup in each page's 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 tells crawlers, "if you don't know which version, use CS". That matters for non-CZ and non-EN markets (DE/SK Google and friends).
Localizing the ticketing flow
This is where it got interesting. The CZ site uses the Sixart Ticket API. The PL site uses a Polish vendor with a totally different flow. The <TicketCTA> component has to be polymorphic:
// 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} />;
}Key takeaway: never glue vendor logic into a single component with if/else everywhere. Polymorphic component, each provider as a separate component. When I add a DE version, I just extend the union and drop in <DeVendorCTA>.
Localized cities (not just translated strings)
The CS site covers Ostrava (Oct 17) and Bratislava (Oct 24). The PL site covers Gdańsk, Łódź, and Kraków. Each city has a different venue, a different hall layout (5+ seat tiers including a VIP "Golden Seat"), different ticketing IDs.
Not the same data with locale-prefixed strings. A completely different 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 — totally different objects, tier names, vendorThe page component just imports the right dataset:
import { events } from `data/events.${locale}`;Build-time discrimination via LOCALE env decides which dataset gets bundled. The other domain never carries the first one's code at all.
Real metrics
| Metric | CS site | PL site |
|---|---|---|
| Cities | 2 (Ostrava, Bratislava) | 3 (Gdańsk, Łódź, Kraków) |
| Vendor | Sixart Ticket | Polish vendor |
| Build size | ~180 KB First Load JS | ~180 KB |
| Lighthouse SEO | 100 | 100 |
| Time to ship | shared | 4 days for both |
Common mistakes (and how to avoid them)
1. A locale switcher that drops query strings. When the user is on /work?filter=ai and switches to EN, you want /en/work?filter=ai. usePathname from next-intl handles this, but you have to explicitly preserve the query.
2. Date formatting across locales. CS is 17. 10. 2026, PL is 17.10.2026, EN is Oct 17, 2026. Never write your own format function. Use Intl.DateTimeFormat(locale).
3. SEO meta in only one language. Open Graph needs locale: 'cs_CZ' or 'pl_PL', not just 'cs'. Plus an alternateLocale array for the other locations.
Where to next
- DJ BOBO Tour case study → — more on the product around these sites
- Claude Code workflow → — how Claude Code wrote most of this localization logic
- B2B lead pipeline → — another weekend project
If you are working on a multi-locale Next.js project (e-commerce, ticketing, marketing site), drop me a line. Most of the gotchas are predictable, the right setup saves you weeks.