Restoran Infinity Libre: restaurant website in five languages


Problem
Infinity Libre is a restaurant in Ražanac, about 25 minutes from Zadar, known for peka and spit-roasted meat. Many guests are tourists who speak German, English, Italian or French. The previous site was a template from a site builder. The owner needed her own website where she maintains the menu, opening hours and events herself, and where guests can send reservation requests.
My role
I turned goal and scope into requirements and phases, set the architecture and the project rules (for example schema changes only through migrations, a fixed allergen list, Payload only on the Node runtime), reviewed every plan and every diff, and set up domain, hosting and database. Parts were built in parallel in separate Git worktrees, and 25 of them were merged.
Approach
One Next.js 16 app holds both the website and the Payload CMS admin. Content is localized per field in five languages (Croatian by default, plus English, German, Italian and French). When a translation is missing, Croatian is used. The admin itself works in Croatian, English and German. A Settings global holds eight module switches, for example for menu, reservations, events, reviews and guestbook; the affected pages, the navigation and the cron jobs check them.
Reads go through cached readers with 'use cache', cacheLife and cacheTag. Payload afterChange hooks call revalidateTag, so an edit in the admin shows up without a new deployment. Reservations and the guestbook use Server Actions with Zod and a honeypot, and guestbook entries always start as drafts. Daily Vercel cron jobs are set up to sync Google reviews, delete old IP hashes and hide past events; the Google Places key for the review sync is not set yet. Push is switched off in production, and schema changes are meant to come only through migrations in the build command. I planned the work in eight phases with 85 requirements. The plans were then carried out one by one, often test first.
Architecture
Guests in five languages
/hr, /en, /de, /it, /fr via next-intl, hreflang including x-default.
Server Components
Cached readers with 'use cache', cacheLife and cacheTag; module switches from the Settings global.
Payload CMS 3 in the same app
Local API instead of HTTP. Admin in Croatian, English and German, fields localized in five languages.
Neon Postgres
Push switched off in production; schema changes are meant to come only through migrations in the build command.
Hooks and Server Actions
afterChange calls revalidateTag. Reservations and guestbook with Zod, honeypot and draft status.
Vercel Cron, daily
Review sync via Google Places (idempotent upsert, set up, key not set yet), privacy cleanup, hiding past events.
Decision
Not recorded does not mean allergen-free
Allergens in the CMS are a fixed list of the 14 EU allergens under Regulation 1169/2011; free text is not allowed. I found my own mistake in a review shortly after launch: I had built the menu for the case where every dish has allergen data, but none had been entered yet. So the filter for hiding allergens let a dish without data pass as free. I fixed it with a rule: the full allergen card appears only when every dish has data. Until then it says that staff can provide the information. The filter appears only once there is data, and a dish without data always drops out while a filter is active. Guests lose a feature for now, but a wrong allergen statement would be a health risk. No data was entered or guessed.
Code excerpt
/**
* 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)),
}
}Results
- 5Languages live: hr, en, de, it, fr (all routes HTTP 200, checked on 18 Oct 2026)
- 175items on the multilingual menu, maintained in the CMS
- 8Module switches in the admin
- 104test files: 88 Vitest integration specs, 16 Playwright specs
- 392
- commits from 21 Apr to 18 Oct 2026
What I learned
In May 2026 five carousel blocks were built in one night, and I removed them the next morning because they looked generic. At that point, infrastructure steps such as email delivery, captcha and the reviews API key were still open, and captcha and the reviews key still are. Today I finish the whole path of a request first, from the form to the notification, and only then build extras. Also, my local environment pointed at the live database. In development mode Payload syncs the schema via push, so tables reached production that are not in any migration. Since 18 Oct 2026 a baseline migration captures that state, and the build fails unless every migration is recorded. Today I separate the development and production databases from day one.
Screenshots

