Andrea Grossi
← Back to projects

Work · 2026

Cibamus: recipes, four languages and structured data done properly

A Hugo recipe site in Italian, English, French and Spanish. Every page emits schema.org/Recipe JSON-LD, every language gets its own keyword-rich URL, and the analytics stack only loads in production.

Cibamus publishes Italian home cooking in four languages — Italian, English, French and Spanish — with over 130 recipes online. It’s a static Hugo site, and almost every decision in it comes from one question: what does a recipe need to be found, not just to be readable?

One recipe, four URLs, one identity

The naive way to do a multilingual recipe site is to translate the page and keep the URL. That’s a mistake you only notice months later, when the French page never ranks for anything a French person would type.

Each recipe lives in a Hugo bundle named after a short, stable base name, with one file per language inside it:

content/ricette/risotto-ai-funghi/
  index.it.md
  index.en.md
  index.fr.md
  index.es.md
  risotto-ai-funghi.webp

The folder, the shared image and the translationKey all use that neutral base name, which is what ties the four versions together for hreflang. The slug does the opposite: it’s keyword-rich and localised per language, so a dish becomes ricetta-…, …-recipe, recette-…, receta-…, and a basic preparation becomes come-fare-…, how-to-make-… and so on. Categories and cuisines are translated too. Neutral fields — dates, times, nutrition, badges, ingredients, tools — stay identical across all four, because they describe the dish, not the language.

That split is the whole design: identity is shared, addressing is local.

JSON-LD as output, not as an afterthought

Every recipe page emits schema.org/Recipe in JSON-LD, generated from the front-matter rather than written by hand. Times, yield, ingredients, nutrition and the image all come from fields that already have to be correct for the page itself to render.

This is the part of the project closest to my day job. Structured data is where a content site either gets rich results or quietly doesn’t, and the failure mode is always the same: markup maintained separately from the content it describes, drifting apart one edit at a time. Deriving it from the same source removes the possibility.

Recommended tools follow the same rule. They’re defined once in data/tools.yaml, with labels in all four languages, and referenced from recipes by name. A tool’s description exists in one place, not copied into forty recipes.

A recipe page A recipe page. Times, yield and ingredients render here and feed the JSON-LD from the same fields.

Analytics that stay out of the way

Google Tag Manager loads only in production, gated on Hugo’s IsProduction; GA4 runs as a tag inside GTM rather than as a second script. The Content Security Policy is strict by default and widens only in production, and only to the GTM and Analytics domains it actually needs.

The reason for the gate isn’t privacy theatre, it’s data quality: a local dev server that fires the same tags as production pollutes exactly the numbers you built the tracking for.

Build and publishing

The site builds locally and deploys to Hostinger over rsync and SSH, with a multilingual 404 served through .htaccess. In parallel, a GitHub Actions workflow builds and publishes to GitHub Pages on every push, which exists as a continuous check that the build still works rather than as the live site.

That workflow is pinned: actions referenced by commit SHA instead of a moving tag, and the Hugo binary verified by checksum before it runs. A build pipeline that pulls a mutable tag is a supply chain you don’t control.

Where AI fits, and where it doesn’t

New recipes can start as AI-generated JSON, imported through a defensive script that validates the file before it writes anything into the content tree.

The translations are not machine output. They’re an editorial step, done separately, for the same reason the slugs are localised rather than translated: a recipe that reads like a translation doesn’t get cooked twice.