TL;DR
- Eine dynamische Speisekartenseite ist eine, bei der die Speisekarte zuerst Daten und erst dann eine Seite ist. Eine strukturierte Quelle, serverseitig gerendert, wobei die Druckdatei aus denselben Daten generiert wird, statt selbst die Daten zu sein.
- Der Bau ist klein. Eine funktionierende Version besteht aus einer typisierten Datendatei, einer Route, einem JSON-LD-Builder und einem Revalidierungs-Endpunkt. Der Code unten wurde end-to-end gebaut und getestet.
- Der schwierige Teil ist die Wahl einer Source of Truth, die ein Manager tatsächlich aktuell hält, denn eine Speisekarte, die korrekt aussieht und drei Monate veraltet ist, ist schlimmer als ein PDF.
- Rendern Sie serverseitig. Wir zeigen dieselbe Speisekarte auf zwei Arten gerendert, und die clientseitig gerenderte Version liefert bei einem einfachen Fetch überhaupt nichts zurück.
- Nur elf Werte sind in
suitableForDietzulässig. Typisieren Sie das Feld so, dass ein falscher Wert nicht kompiliert.
Eine Speisekartenseite zu bauen, die Maschinen lesen können, sind rund zwei Tage Entwicklung. Die Entscheidungen drumherum dauern länger und sind der Teil, der schiefgeht, deshalb deckt diese Anleitung beides ab.
Was folgt, ist eine echte Implementierung, kein Pseudocode. Sie wurde mit Node v22.22.3, Next.js 16.3.4 und TypeScript 5.9.3 gebaut, und jede unten zitierte Befehlsausgabe ist die tatsächliche Ausgabe aus dem echten Lauf. Wenn Sie das Argument dafür suchen, warum das überhaupt wichtig ist, finden Sie es in unserem Beitrag darüber, warum eine PDF-Speisekarte für die KI-Suche unsichtbar ist.
Was zählt als dynamische Speisekartenseite?
Nicht eine Speisekarte, die animiert. Eine Speisekarte, bei der der Inhalt strukturierte Daten sind, aus denen eine Seite generiert wird, was Ihnen vier Dinge gibt, die ein PDF nicht kann:
- Text, den ein Crawler lesen kann, ohne irgendetwas auszuführen.
- Eine eigene Adresse und Überschrift für jeden Standort und jede Speisekarte.
- Maschinenlesbare Preise, Abschnitte und Ernährungskennzeichnung.
- Eine einzige Stelle, um ein Gericht zu ändern, wobei jede Ausgabe gemeinsam aktualisiert wird.
Der vierte Punkt ist der, der entscheidet, ob das den Kontakt mit einer echten Küche übersteht.
Schritt 1: Wählen Sie die Source of Truth, bevor Sie Code schreiben
Alles Nachgelagerte ist einfach. Das ist die Entscheidung, die bestimmt, ob die Speisekarte im nächsten Frühjahr noch korrekt ist.
| Source of Truth | Wer sie bearbeiten kann | Gut geeignet für | Das Risiko |
|---|---|---|---|
| Eine typisierte Datei im Repository | Nur Entwickler | Einen einzelnen Standort, einen technischen Verantwortlichen, einen schnellen Start | Jede saisonale Änderung braucht einen Entwickler, deshalb veraltet sie |
| Ein headless CMS wie Sanity oder Contentful | Jeder, mit einer echten Bearbeitungsoberfläche | Gruppen, mehrere Redakteure, geplante Änderungen | Kosten und Einrichtungszeit, bevor überhaupt etwas live geht |
| Eine Tabelle, die nach Zeitplan synchronisiert wird | Jeder im Haus | Teams, die ohnehin in einer Tabelle leben | Keine Validierung, ein Tippfehler wird so zum veröffentlichten Preis |
| Ihr bestehendes Website-CMS | Wer die Website heute bearbeitet | Bei einer Plattform zu bleiben | Hängt komplett von der Plattform ab, siehe die Tabelle gegen Ende |
Entscheiden Sie danach, wer die Änderung tatsächlich vornimmt, freitags um 16 Uhr, wenn ein Lieferant nicht liefert. Wenn die Antwort ein Manager ist und kein Entwickler, brauchen Sie eine Bearbeitungsoberfläche, und Sie sollten das jetzt entscheiden, nicht erst nach dem Bau.
Beginnen Sie das Datenmodell mit den Feldern, die wirklich gebraucht werden, denn sie nachträglich einzubauen ist schmerzhaft: Standort, Speisekarte, Menütyp, Gültigkeitszeitraum, Abschnitt, Artikel, Beschreibung, Preis, Währung, Ernährungskennzeichen, Allergene, Verfügbarkeit, ob eine Speisekarte über Standorte hinweg geteilt wird oder nur für einen gilt, und Sortierreihenfolge.
Diese Unterscheidung zwischen geteilt und standortspezifisch ist wichtiger, als sie aussieht. In einer Gruppe sind manche Speisekarten geteilt und manche gehören zu einem einzigen Standort. Modellieren Sie das von Tag eins an als Many-to-many-Beziehung, sonst landen Sie beim Kopieren von Seiten, und die Gerichte eines Standorts tauchen auf der Speisekarte eines anderen Standorts auf, ein Fehler, den wir auf mehr als einer Plattform gesehen haben.
Schritt 2: Typisieren Sie die Speisekarte, damit falsche Daten nicht kompilieren
Beginnen Sie mit der Ernährungs-Enumeration, denn das ist die eine Stelle, an der Leute stillschweigend Werte erfinden.
// lib/diets.ts
// The full schema.org RestrictedDiet enumeration. Exactly these eleven.
// There is no DairyFreeDiet and no NutFreeDiet, so allergen information is
// carried as free text on the item instead.
export const RESTRICTED_DIETS = [
"DiabeticDiet",
"GlutenFreeDiet",
"HalalDiet",
"HinduDiet",
"KosherDiet",
"LowCalorieDiet",
"LowFatDiet",
"LowLactoseDiet",
"LowSaltDiet",
"VeganDiet",
"VegetarianDiet",
] as const;
export type RestrictedDiet = (typeof RESTRICTED_DIETS)[number];
export function dietToIri(diet: RestrictedDiet): string {
return `https://schema.org/${diet}`;
}
Typisieren Sie das diets-Feld eines Artikels als RestrictedDiet[], und ein erfundener Wert hört auf, ein stiller Datenfehler zu sein, und wird zu einem Build-Fehler. Wir haben das getestet, indem wir DairyFreeDiet in die Daten eingefügt und den Type Checker laufen lassen haben:
error TS2322: Type '"DairyFreeDiet"' is not assignable to type
'"DiabeticDiet" | "GlutenFreeDiet" | ... | "VegetarianDiet"'
Genau das ist der Sinn, das in einer typisierten Sprache zu tun. Der Compiler erzwingt das Vokabular, sodass ein Mensch es sich nicht merken muss.
Schritt 3: Rendern Sie die Seite auf dem Server
Die Speisekarte muss im HTML stehen, das der Server sendet, bevor irgendein JavaScript läuft. In Next.js bedeutet das eine Server-Komponente und generateStaticParams, damit jede Kombination aus Standort und Speisekarte zur Build-Zeit generiert wird.
// app/menus/[venue]/[menu]/page.tsx
import { notFound } from "next/navigation";
import { getAllVenueMenuParams, getMenuForVenue, getVenue } from "@/data/menu-data";
import { buildMenuJsonLd } from "@/lib/schema";
export function generateStaticParams() {
return getAllVenueMenuParams();
}
export default async function MenuPage({
params,
}: PageProps<"/menus/[venue]/[menu]">) {
const { venue: venueSlug, menu: menuSlug } = await params;
const venue = getVenue(venueSlug);
const menu = getMenuForVenue(venueSlug, menuSlug);
if (!venue || !menu) {
notFound();
}
const jsonLd = buildMenuJsonLd(venue, menu);
return (
<main>
<h1>{`${venue.name} ${menu.name}`}</h1>
{menu.sections.map((section) => (
<section key={section.name}>
<h2>{section.name}</h2>
<ul>
{section.items.map((item) => (
<li key={item.slug}>
<strong>{item.name}</strong>
<span>{item.currency} {item.price.toFixed(2)}</span>
<p>{item.description}</p>
{item.allergens.length > 0 && (
<p>Allergens: {item.allergens.join(", ")}</p>
)}
</li>
))}
</ul>
</section>
))}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</main>
);
}
Zwei Details darin sind wichtiger, als sie aussehen.
Die Überschrift variiert. ${venue.name} ${menu.name} erzeugt für jede Kombination eine andere H1. Eine einzige statische Überschrift, die für jeden Standort und jede Speisekarte wiederverwendet wird, ist einer der häufigsten Fehler bei einem Multi-Standort-Bau, und er macht einen Großteil des Nutzens separater Seiten überhaupt zunichte. Geprüft über drei Routen:
$ curl -s http://localhost:3417/menus/borough-market/dinner | grep -o '<h1>[^<]*</h1>'
<h1>Borough Market Dinner Menu</h1>
$ curl -s http://localhost:3417/menus/shoreditch/dinner | grep -o '<h1>[^<]*</h1>'
<h1>Shoreditch Dinner Menu</h1>
$ curl -s http://localhost:3417/menus/borough-market/sunday-roast | grep -o '<h1>[^<]*</h1>'
<h1>Borough Market Sunday Roast</h1>
Schreiben Sie die Überschrift als einen einzigen Ausdruck. Das wirkt pedantisch, ist es aber nicht. Wenn Sie <h1>{venue.name} {menu.name}</h1> als zwei benachbarte Ausdrücke schreiben, fügt React zwischen ihnen einen leeren HTML-Kommentar in die Server-Ausgabe ein, was still jedes Tooling bricht, das die Überschrift mit einem einfachen Muster liest. Ein einzelnes Template-Literal als Kind vermeidet das. Uns ist das während des Baus real passiert.
Schritt 4: Generieren Sie die strukturierten Daten aus denselben Objekten
Schreiben Sie JSON-LD niemals von Hand neben eine Speisekarte. Es driftet innerhalb einer Saison auseinander, und ein Schema-Block, der der sichtbaren Seite widerspricht, ist schlimmer als gar keiner.
// lib/schema.ts
export function buildMenuJsonLd(venue: Venue, menu: Menu) {
return {
"@context": "https://schema.org",
"@type": "Restaurant",
name: venue.name,
address: {
"@type": "PostalAddress",
streetAddress: venue.streetAddress,
addressLocality: venue.city,
postalCode: venue.postalCode,
addressCountry: "GB",
},
hasMenu: {
"@type": "Menu",
name: menu.name,
hasMenuSection: menu.sections.map((section) => ({
"@type": "MenuSection",
name: section.name,
hasMenuItem: section.items.map((item) => ({
"@type": "MenuItem",
name: item.name,
description: item.description,
offers: {
"@type": "Offer",
price: item.price,
priceCurrency: item.currency,
},
suitableForDiet: item.diets.map(dietToIri),
})),
})),
},
};
}
Weil es aus denselben Objekten gebaut wird, die die Seite rendert, können die beiden nicht widersprüchlich sein. Setzen Sie Ihre Erwartungen trotzdem richtig: Google dokumentiert kein eigenes Rich-Result für Menu, und seine Local-Business-Anleitung verlangt nur eine Menü-URL. Veröffentlichen Sie das, weil es Ihre Fakten für jeden Konsumenten explizit macht, nicht weil ein Rich-Result darauf wartet.
Schritt 5: Machen Sie die gedruckte Speisekarte zu einer abgeleiteten Ausgabe
Das ist der Schritt, der den einzigen echten Einwand gegen die ganze Übung beantwortet, nämlich dass das PDF existiert, weil die Küche etwas zum Ausdrucken braucht.
Pflegen Sie nicht zwei Speisekarten. Generieren Sie die druckbare aus denselben Daten:
$ npx tsx scripts/generate-print-menu.ts borough-market dinner
Wrote out/borough-market-dinner-print.html
Ein Print-Stylesheet über generiertem HTML reicht, und es lässt sich leichter neu gestalten als eine PDF-Pipeline. Der Designer behält das Layout, die Küche behält ihre gedruckte Speisekarte, und niemand pflegt dieselbe Gerichtliste zweimal.
Schritt 6: Ermöglichen Sie, eine Änderung ohne Deploy zu veröffentlichen
Wenn eine Preisänderung einen Entwickler braucht, ist die Speisekarte bis März falsch. Stellen Sie einen Revalidierungs-Endpunkt bereit, den das CMS oder ein internes Tool aufrufen kann:
// app/api/revalidate/route.ts
export async function POST(request: Request) {
const secret = request.headers.get("x-revalidate-secret");
if (!process.env.REVALIDATE_SECRET || secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ revalidated: false }, { status: 401 });
}
const { path } = await request.json();
revalidatePath(path);
return Response.json({ revalidated: true, path });
}
Getestet mit einem falschen Secret, ohne konfiguriertes Secret und mit dem richtigen Secret, mit den Rückgaben 401, 401 und:
{"revalidated":true,"path":"/menus/borough-market/dinner"}
Sichern Sie ihn mit einem Secret ab und behandeln Sie eine fehlende Umgebungsvariable als Fehlschlag statt als offene Tür.
Schritt 7: Beweisen Sie, dass eine Maschine sie wirklich lesen kann
Das ist der Abnahmetest, und es ist der eine Schritt, den niemand auslassen sollte. Prüfen Sie die Rohantwort, niemals den Browser. Ein Browser führt Ihr JavaScript aus, er wird Ihnen also sagen, die Speisekarte sei in Ordnung, wenn sie es nicht ist.
Wir haben dieselbe Speisekarte zweimal aus einer Datendatei gebaut, um die Größe des Unterschieds zu zeigen. Serverseitig gerendert:
$ curl -s http://localhost:3417/menus/borough-market/dinner | grep -o "Bavette Steak"
Bavette Steak
$ curl -s http://localhost:3417/menus/borough-market/dinner | grep -o "26.00" | head -1
26.00
Dasselbe Gericht, auf einer Seite, die ihre Speisekarte stattdessen im clientseitigen State setzt:
$ curl -s http://localhost:3417/csr-demo | grep -o "Bavette Steak"
$ curl -s http://localhost:3417/csr-demo | grep -o '<main><h1>[^<]*</h1></main>'
<main><h1>Loading menu…</h1></main>
Nichts. Der gesamte Rohtext ist ein Lade-Platzhalter. Im Browser identisch, bei einem einfachen Fetch unsichtbar.
Ihre Abnahme-Checkliste:
curl -s <your-menu-url> | grep -o "<a dish you are known for>"
curl -s <your-menu-url> | grep -o '<h1>[^<]*</h1>'
curl -s <your-menu-url> | grep -c "application/ld+json"
curl -sI <your-menu-url> | grep -i "x-robots-tag"
curl -s <your-domain>/robots.txt
Die vierte Zeile fängt den Fehler ab, der bei dieser Art von Bau am meisten Geld verbrennt: ein noindex, das noch von Staging übrig ist. Eine perfekte Speisekarte, die Engines sagt, sie sollen nicht hinschauen, ist ein Totalverlust, und er ist unsichtbar, wenn Sie nicht den Header prüfen.
So bauen Sie das mit Claude Code oder Codex
Beide kommen damit gut zurecht, weil die Aufgabe klein, typisiert und testbar ist. Was das Ergebnis verändert, ist, dem Modell das Constraint-Set zu geben statt die Feature-Anfrage.
Ein Prompt, der funktioniert:
Bauen Sie in dieser Next.js-App eine serverseitig gerenderte Menü-Route unter
/menus/[venue]/[menu]. Lesen Sie die Speisekarte aus einem einzigen typisierten Datenmodul. Anforderungen: Jeder Gerichtname und jeder Preis muss im rohen HTML erscheinen, ohne dass JavaScript ausgeführt wird; die H1 muss je nach Standort und Speisekarte variieren und als einzelnes Template-Literal geschrieben sein; erzeugen SieRestaurant > hasMenu > Menu > hasMenuSection > hasMenuItem-JSON-LD, gebaut aus denselben Objekten, die die Seite rendert; typisieren SiesuitableForDietgegen die elf echten schema.org-RestrictedDiet-Werte, sodass ein ungültiger Wert den Type Check scheitern lässt. Verifizieren Sie danach mit curl und zeigen Sie mir die Ausgabe.
Der letzte Satz macht den Großteil der Arbeit. Fragen Sie nach der Verifizierung, bekommen Sie einen Agenten, der curl ausführt und die Antwort liest. Lassen Sie ihn weg, bekommen Sie Code, der richtig aussieht.
Zwei weitere Gewohnheiten lohnen sich. Fragen Sie auch nach dem Fehlerfall, also einer absichtlich clientseitig gerenderten Version, damit Sie den Kontrast in Ihrem eigenen Projekt sehen statt es einfach zu glauben. Und lassen Sie nach der Generierung der Datendatei den Type Check laufen, denn ein erfundener Ernährungswert ist der mit Abstand wahrscheinlichste Fehler bei diesem Bau, und der Compiler fängt ihn sofort ab.
Wenn Sie keinen Individualbau umsetzen können
Die meisten Restaurants werden keine Next.js-App betreiben, und sie müssen es auch nicht. Entscheidend ist, ob die Plattform lesbaren Text plus strukturierte Daten ausgeben kann, die eine technisch nicht versierte Person aktuell halten kann.
| Plattform | Der Weg | Worauf Sie achten sollten |
|---|---|---|
| Squarespace | Natives Menu-Block für den Text, seitenweite Code Injection für JSON-LD | Code Injection braucht mindestens Core, Squarespace gibt auf jeder Seite eigenes Local-Business-Schema aus, das mit Ihrem kollidieren kann, und der Menu-Block speist Ihr injiziertes Schema nicht, sodass die beiden auseinanderdriften |
| Webflow | Eine CMS Collection für Artikel und Abschnitte, mit JSON-LD gebunden an CMS-Felder im Collection Template | Feld- und Zeichenlimits bei einer umfangreichen Speisekarte, und jede zusätzliche Sprachversion braucht ihr eigenes Schema |
| WordPress | Ein dediziertes Menü-Plugin oder handgeschriebenes JSON-LD im Template | Yoast und Rank Math generieren kein Menu-Schema, was die allgemeinen Ratschläge auch behaupten |
| Shopify | Metaobjects für Speisekarteninhalte plus JSON-LD im Theme | Gerichte als Produkte zu modellieren zieht Checkout-Verhalten mit sich, das Sie nicht wollen |
| Wix | Die App Restaurants Menus | Die Dokumentation sagt nicht, was ihre Option für strukturierte Daten ausgibt, prüfen Sie also selbst die Live-Seite |
| Toast, Popmenu, Flipdish, UpMenu | Die eigenen Speisekartenseiten des Anbieters auf Ihrer Domain | Die Anbindung Ihrer Domain ist ein separater Schritt, und eine nicht angebundene Seite wird nicht gefunden |
Unabhängig von der Plattform: Packen Sie die Speisekarte nicht in einen iFrame und verlassen Sie sich nicht darauf, dass ein Buchungs-Widget sie ausliefert. Eingebetteter Anbieterinhalt trägt häufig ein eigenes noindex, und ein Assistent, der das Skript nie ausführt, sieht ohnehin nie in den Frame hinein.
Die Fallen, in die wir getappt sind
Zwei Dinge haben bei diesem Bau echte Zeit gekostet, sie warten wahrscheinlich auch auf Sie.
Benachbarte JSX-Ausdrücke in der Überschrift. Oben behandelt. Das führte zu einer Überschrift, die im Browser korrekt aussah und an einer einfachen automatisierten Prüfung scheiterte, die schlimmste Art von Fehler, weil der sichtbare Befund sagt, alles sei in Ordnung.
Pfad-Aliasse in eigenständigen Skripten. Ein einfaches Node-Skript kann kein TypeScript-Modul importieren, das einen @/-Alias verwendet. Führen Sie solche Skripte über tsx aus, das die Pfade in Ihrer TypeScript-Konfiguration respektiert.
Wofür sich das lohnt
Der Bau ist der kleine Teil. Der Wert entsteht daraus, dass die Speisekarte zu Daten wird: eine Stelle, um ein Gericht zu ändern, eine Seite pro Standort, eine Druckdatei, die nicht mehr aus dem Takt geraten kann, und Gericht-Vokabular, das endlich irgendwo existiert, wo eine Maschine es lesen kann.
Wir übernehmen diese Arbeit für Restaurants im Rahmen der monatlichen Gebühr statt als separates Projektangebot, für jeden Standort und jede Speisekarte. Wenn Sie das lieber nicht selbst bauen möchten, das ist unser Angebot.
Quellen
- Schema.org, "RestrictedDiet" und "suitableForDiet", https://schema.org/RestrictedDiet, geprüft 2026-09-05.
- Google Search Central, "Local Business (LocalBusiness) Structured Data", https://developers.google.com/search/docs/appearance/structured-data/local-business, zuletzt aktualisiert 10 December 2025, geprüft 2026-09-05.
- Google Search Central, "Structured Data Markup that Google Search Supports", https://developers.google.com/search/docs/appearance/structured-data/search-gallery, zuletzt aktualisiert 15 June 2026, geprüft 2026-09-05.
- Squarespace Help Center, "Using code injection", https://support.squarespace.com/hc/en-us/articles/205815908-Using-code-injection, geprüft 2026-09-05.
- Squarespace Help Center, "Menu blocks", https://support.squarespace.com/hc/en-us/articles/206544087-Menu-blocks, geprüft 2026-09-05.





