DJ BOBO Arena Tour 2026 - Multi-Language Event Sites
Marketing sites for DJ BOBO arena tour in CZ/SK (sixartticket.cz) and Poland (sixartticket.pl). Localized content, ticketing integrations, countdown timers, multiple seating tiers including VIP Golden Seat. One codebase, two deployments.
6 cities, 2 countries
Brief
DJ BOBO is celebrating 30 years on stage in 2026 with an arena tour across Central Europe - Ostrava, Bratislava, Gdańsk, Łódź, Kraków, plus one additional date. The promoter needed two parallel landing pages for two markets: a Czech-Slovak one through SixArt Ticket and a Polish one through a local vendor. Audience: eurodance fans 30+ who buy on mobile late Sunday evening but also convert from desktop on a Thursday at the office.
The commercial model is standard touring revenue: ticketing is on the vendor side, the website is primarily a conversion funnel (impressions → email capture → click on ticketing CTA → vendor checkout). I measured CTR from the hero to vendor and abandonment on seat selection. Everything else is marketing dramaturgy.
Architecture
I went with single repo, two builds. A monorepo would have been overkill - these aren't separate products, just two regional variants of the same site. The setup is simpler than a typical Turborepo:
sixartticket/
├── src/
│ ├── app/[locale]/...
│ ├── components/ # shared (hero, lineup, countdown)
│ └── content/
│ ├── tour.cs.ts # CZ/SK cities + venues + ticketing config
│ └── tour.pl.ts # PL cities + venues + local vendor
├── deployments/
│ ├── cz.vercel.json # deploy to sixartticket.cz
│ └── pl.vercel.json # deploy to sixartticket.pl
The build is orchestrated via NEXT_PUBLIC_TOUR_REGION=cz|pl, which controls which content module is imported and which locale routes are generated. Identical bundle, two configurations. We share 94 % of the code; local specifics live in content modules and three components (local vendor button, regulatory footer, distribution partners).
Localization strategy
Localization here doesn't mean "translate UI strings". It means each market has a different set of cities, venues, dates, prices, and a different vendor. I went with a typed config per locale:
// src/content/tour.cs.ts
import type { TourConfig } from '@/types/tour';
export const tour: TourConfig = {
region: 'cz',
defaultLocale: 'cs',
supportedLocales: ['cs', 'sk'],
cities: [
{
slug: 'ostrava',
name: { cs: 'Ostrava', sk: 'Ostrava' },
venue: 'Ostravar Aréna',
date: '2026-10-17T20:00:00+02:00',
vendor: { kind: 'sixart', eventId: 'BOBO-OSTRAVA-2026' },
seatTiers: ['standing', 'tribune', 'vip', 'golden'],
},
{
slug: 'bratislava',
name: { cs: 'Bratislava', sk: 'Bratislava' },
venue: 'Ondrej Nepela Arena',
date: '2026-10-24T20:00:00+02:00',
vendor: { kind: 'sixart', eventId: 'BOBO-BA-2026' },
seatTiers: ['standing', 'tribune', 'vip', 'golden'],
},
],
newsletter: { provider: 'resend', audienceId: 'aud_bobo_cz' },
};The Polish tour.pl.ts has a different city structure, a different vendor, a different timezone offset, and its own distribution partners. URLs are generated from config - /cs/koncert/ostrava, /sk/koncert/bratislava, /pl/koncert/gdansk. App Router's generateStaticParams walks tour.cities and produces all permutations.
Ticketing integration
SixArt's API is REST authenticated with an HMAC-signed header. The availability lookup looks like this:
// src/lib/sixart.ts
export async function getEventAvailability(eventId: string): Promise<Availability> {
const ts = Date.now().toString();
const signature = hmacSha256(`${ts}:${eventId}`, process.env.SIXART_SECRET!);
const res = await fetch(`https://api.sixart.cz/v2/events/${eventId}/availability`, {
headers: {
'X-SixArt-Timestamp': ts,
'X-SixArt-Signature': signature,
},
next: { revalidate: 60 }, // 60s - fresh enough for "X tickets left"
});
if (!res.ok) throw new SixArtError(res.status, await res.text());
const data = (await res.json()) as {
eventId: string;
tiers: Array<{ id: string; name: string; available: number; priceCzk: number }>;
soldOut: boolean;
};
return data;
}The Polish vendor instead uses OAuth2 client credentials with hour-long access tokens, so I wrote a thin TicketProvider interface that both variants implement. The CTA component just renders <TicketButton city={city} /> and the right provider is selected polymorphically.
Performance
The target was LCP under 1.8 s on mobile (Google PageSpeed > 90). Hero background is an <Image priority /> with AVIF + WebP fallback, optimization happens server-side, and sizes dropped from the original 2.4 MB to 180 kB AVIF. The countdown timer uses requestAnimationFrame, not setInterval - setInterval(..., 1000) steals frame budget when the tab is in the background and Safari throttles it aggressively. RAF + the Page Visibility API let me pause counting when the tab is hidden:
useEffect(() => {
let raf = 0;
let lastTick = performance.now();
const tick = (now: number) => {
if (now - lastTick >= 1000 && !document.hidden) {
lastTick = now;
setRemaining(target.getTime() - Date.now());
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target]);Measured numbers: LCP 1.4 s on 4G mobile, FID < 50 ms, CLS 0.02. The dynamic ticketing CTA fetch is 240 ms p95 on a cache hit (revalidate: 60), 820 ms p95 on a cold miss from Edge.
Decisions that paid off
| Decision | Reason | Outcome |
|---|---|---|
| Single repo, two deploys | Vendor lock-in is overrated | 94 % code reuse, 2 prod domains |
Typed TourConfig per locale | Avoid i18n hell | New venue = 1 commit, not 5 |
revalidate: 60 not no-store | Vendor API is expensive | -85 % SixArt requests |
| AVIF + WebP fallback | Polish 4G ≠ Czech 4G | LCP -1.0 s on weak networks |
What surprised me
- The Polish vendor returned a different shape than the docs claimed. The
availablefield doesn't exist on their side; it'sinventory.remaining. The adapter has to map this. Had I shipped the first attempt, the sites would have lied about availability. - Timezone bugs. CZ/SK and Poland are both
+02:00in October (DST). But the Bratislava 2025-10-26 02:00 → 03:00 DST transition happened to align with my test date and the RAF countdown got "stuck" by an hour. Switching to a fixed UTC offset fixed it. - Email capture converted 4× higher than I expected. ~7 % of visitors dropped their email for pre-sale notifications. The Resend audience went straight into the promoter's Mailchimp.
Lessons
Ticketing vendor APIs in the events world are the wild west. If you have two markets and two vendors, build the provider abstraction immediately, not after you've duplicated 200 lines of fetch logic. Two deployments from one repo work brilliantly for parallel marketing campaigns, but you need discipline around process.env.NEXT_PUBLIC_TOUR_REGION - any component that ignores it will silently break one of the two builds.
For the client this meant two live domains in 11 days from kickoff, a shared codebase, and a ticketing integration they can reuse for the 2027 tour.