Alain Lilic

LivePayload CMS 3 und Next.js 16Ražanac, Kroatien

Restoran Infinity Libre: Restaurant-Website in fünf Sprachen

Kroatische Startseite von Restoran Infinity Libre in Ražanac am Desktop: Logo, Überschrift 'Uspomene počinju ovdje.', Schaltfläche zur Tischreservierung, Sprachumschalter HR, EN, DE, IT, FR und Angaben zu Küche, Lage und Öffnungszeiten.
Kroatische Startseite von Restoran Infinity Libre auf dem Smartphone: Sprachumschalter, Logo, Überschrift und die Schaltflächen 'Rezerviraj stol' und 'Istraži meni'.

Problem

Infinity Libre ist ein Restaurant in Ražanac, etwa 25 Minuten von Zadar, bekannt für Peka und Fleisch vom Spieß. Viele Gäste sind Touristen, die Deutsch, Englisch, Italienisch oder Französisch sprechen. Die bisherige Seite war eine Vorlage eines Baukastens. Gebraucht wurde eine eigene Website, auf der die Inhaberin Speisekarte, Öffnungszeiten und Veranstaltungen selbst pflegt und über die Gäste Reservierungsanfragen schicken.

Meine Rolle

Ich habe Ziel und Umfang in Anforderungen und Phasen gegossen, die Architektur und die Projektregeln festgelegt (zum Beispiel Schema nur über Migrationen, eine feste Allergenliste, Payload nur auf der Node-Runtime), jeden Plan und jeden Diff geprüft und Domain, Hosting und Datenbank eingerichtet. Gebaut wurde teils parallel in getrennten Git-Worktrees, 25 davon wurden zusammengeführt.

Vorgehen

Eine Next.js-16-App enthält Website und Payload-CMS-Admin zugleich. Inhalte sind feldweise in fünf Sprachen lokalisiert (Kroatisch als Standard, dazu Englisch, Deutsch, Italienisch und Französisch). Fehlt eine Übersetzung, greift Kroatisch. Der Admin selbst ist auf Kroatisch, Englisch und Deutsch bedienbar. Ein Settings-Global enthält acht Modul-Schalter, etwa für Speisekarte, Reservierungen, Veranstaltungen, Bewertungen und Gästebuch; die betroffenen Seiten, die Navigation und die Cron-Jobs fragen sie ab.

Gelesen wird über gecachte Reader mit 'use cache', cacheLife und cacheTag. afterChange-Hooks in Payload rufen revalidateTag auf, eine Änderung im Admin ist also ohne neues Deployment sichtbar. Reservierung und Gästebuch laufen über Server Actions mit Zod und Honeypot, Gästebucheinträge starten immer als Entwurf. Tägliche Vercel-Crons sind für den Abgleich von Google-Bewertungen, das Löschen alter IP-Hashes und das Ausblenden vergangener Veranstaltungen angelegt; für den Bewertungs-Abgleich ist der Schlüssel für Google Places noch nicht gesetzt. In Produktion ist push abgeschaltet, das Schema soll nur über Migrationen im Build-Befehl kommen. Geplant habe ich das in acht Phasen mit 85 Anforderungen. Umgesetzt wurde Plan für Plan, oft mit dem Test zuerst.

Architektur

  1. Gäste in fünf Sprachen

    /hr, /en, /de, /it, /fr über next-intl, hreflang inklusive x-default.

  2. Server Components

    Gecachte Reader mit 'use cache', cacheLife und cacheTag; Modul-Schalter aus dem Settings-Global.

  3. Payload CMS 3 in derselben App

    Local API statt HTTP. Admin auf Kroatisch, Englisch und Deutsch, Felder in fünf Sprachen lokalisiert.

  4. Neon Postgres

    push in Produktion abgeschaltet, Schema-Änderungen sollen nur über Migrationen im Build-Befehl kommen.

  5. Hooks und Server Actions

    afterChange ruft revalidateTag. Reservierung und Gästebuch mit Zod, Honeypot und Entwurfs-Status.

  6. Vercel Cron, täglich

    Bewertungs-Abgleich über Google Places (idempotentes Upsert, angelegt, Schlüssel noch nicht gesetzt), Datenschutz-Bereinigung, vergangene Veranstaltungen ausblenden.

Entscheidung

Nicht erfasst heißt nicht allergenfrei

Allergene sind im CMS eine feste Liste der 14 EU-Allergene nach Verordnung 1169/2011, Freitext ist ausgeschlossen. Meinen eigenen Fehler fand ich bei einer Prüfung kurz nach dem Livegang: Ich hatte die Speisekarte für den Fall gebaut, dass jedes Gericht Allergendaten hat, eingetragen waren aber noch keine. Im Filter zum Ausblenden von Allergenen ging ein Gericht ohne Daten deshalb als frei durch. Behoben habe ich das mit einer Regel: die vollständige Allergen-Karte erscheint erst, wenn jedes Gericht Daten hat. Bis dahin steht dort, dass das Personal Auskunft gibt. Der Filter erscheint erst mit Daten, und ein Gericht ohne Daten fällt bei aktivem Filter immer heraus. Gäste verlieren damit vorerst eine Funktion, aber eine falsche Allergen-Angabe wäre ein Gesundheitsrisiko. Daten wurden keine eingetragen oder geschätzt.

Code-Auszug

src/lib/menuFilters.ts (gekürzt)Seitlich scrollentypescript
/**
 * An item with an empty `allergens` list has no recorded allergen data. It is
 * never shown while an allergen filter is active, because "not recorded" must
 * not read as "free of that allergen" (EU FIC 1169/2011).
 */
export function applyMenuFilters(menu: LocalizedMenu, f: MenuFilter): LocalizedMenu {
  const exclude = f.excludedAllergens ?? []
  const require = f.requiredDietary ?? []
  if (exclude.length === 0 && require.length === 0) return menu
  const keepItem = (item: MenuItem): boolean => {
    if (exclude.length > 0 && item.allergens.length === 0) return false
    if (exclude.length > 0 && exclude.some((a) => item.allergens.includes(a))) return false
    if (require.length > 0 && require.some((d) => !item.dietary[d])) return false
    return true
  }
  // [...] Kategorien ohne Gerichte fallen weg
}
export function menuDataCoverage(menu: LocalizedMenu) {
  const items = menu.categories.flatMap((c) => c.items)
  return {
    anyAllergenData: items.some((i) => i.allergens.length > 0),
    allAllergenData: items.length > 0 && items.every((i) => i.allergens.length > 0),
    anyDietaryData: items.some((i) => Object.values(i.dietary).some(Boolean)),
  }
}
Filterlogik und Datenabdeckung: die Oberfläche zeigt nur, was die Menüdaten belegen.

Ergebnisse

  • 5Sprachen live: hr, en, de, it, fr (alle Routen HTTP 200, geprüft am 18.10.2026)
  • 175Positionen auf der mehrsprachigen Karte, gepflegt im CMS
  • 8Modul-Schalter im Admin
  • 104Testdateien: 88 Vitest-Integrationsspecs, 16 Playwright-Specs
392
Commits vom 21.04. bis 18.10.2026

Was ich daraus gelernt habe

Im Mai 2026 entstanden in einer Nacht fünf Karussell-Blöcke, am nächsten Morgen habe ich sie wieder entfernt, weil sie beliebig wirkten. Zu dem Zeitpunkt waren Infrastruktur-Schritte wie Mailversand, Captcha und der Schlüssel für die Bewertungen noch offen, Captcha und Bewertungs-Schlüssel sind es bis heute. Heute schließe ich zuerst den ganzen Weg einer Anfrage ab, vom Formular bis zur Benachrichtigung, und baue erst danach Extras. Außerdem zeigte meine lokale Umgebung auf die Live-Datenbank. Im Entwicklungsmodus gleicht Payload das Schema per push ab, so kamen Tabellen in die Produktion, die in keiner Migration stehen. Seit dem 18.10.2026 erfasst eine Baseline-Migration diesen Stand, und der Build bricht ab, wenn nicht jede Migration eingetragen ist. Heute trenne ich Entwicklungs- und Produktionsdatenbank vom ersten Tag an.

Screenshots

Deutsche Fassung der Startseite von Restoran Infinity Libre am Desktop: Überschrift 'Erinnerungen beginnen hier.', Schaltflächen 'Tisch reservieren' und 'Speisekarte ansehen', rechts Küche, Lage und Öffnungszeiten.
infinitylibre.com/de
Englische Fassung der Startseite von Restoran Infinity Libre am Desktop: Überschrift 'Memories begin here.', Schaltflächen 'Reserve a table' und 'Explore the menu', rechts Küche, Lage und Öffnungszeiten.
infinitylibre.com/en