Riso illustration of the Schmitdy dolphin at a harbour printing press, one inked plate feeding both a printed sheet and a glowing screen

How to Build a Dynamic Restaurant Menu Page in 2026 (With Tested Code)

TL;DR

  • A dynamic menu page is one where the menu is data first and a page second. One structured source, rendered on the server, with the print file generated from the same data instead of being the data.
  • The build is small. A working version is a typed data file, one route, one JSON-LD builder and a revalidation endpoint. The code below was built and tested end to end.
  • The hard part is choosing a source of truth a manager will keep current, because a menu that is accurate looking and three months stale is worse than a PDF.
  • Server render it. We show the same menu rendered two ways, and the client rendered version returns nothing at all to a plain fetch.
  • Only eleven values are legal in suitableForDiet. Type the field so a wrong one cannot compile.

Building a menu page machines can read is about two days of development. The decisions around it take longer and are where this goes wrong, so this guide covers both.

What follows is a real implementation. It was built with Node v22.22.3, Next.js 16.3.4 and TypeScript 5.9.3, and every command output below is the actual output from running it. The argument for why any of this matters is in our piece on why a PDF menu is invisible to AI search.

What counts as a dynamic menu page?

Not a menu that animates. A menu where the content is structured data that a page is generated from, which gives you four things a PDF cannot:

  1. Text a crawler can read without executing anything.
  2. A separate address and heading for each venue and each menu.
  3. Machine readable prices, sections and dietary marking.
  4. One place to change a dish, with every output updating together.

The fourth is the one that decides whether this survives contact with a real kitchen.

Step 1: choose the source of truth before you write any code

Everything downstream is easy. This is the decision that determines whether the menu is still accurate next spring.

Source of truthWho can edit itGood forThe risk
A typed file in the repositoryDevelopers onlyA single venue, a technical owner, a fast startEvery seasonal change needs a developer, so it goes stale
A headless CMS such as Sanity or ContentfulAnyone, with a real editing interfaceGroups, several editors, scheduled changesCost and setup time before anything ships
A spreadsheet synced on a scheduleAnyone in the buildingTeams who already live in a sheetNo validation, so a typo becomes a published price
Your existing website CMSWhoever edits the site nowStaying on one platformDepends entirely on the platform, see the table near the end

Pick by who makes the change at 4pm on a Friday when a supplier fails to deliver. If that is a manager rather than a developer, you need an editing interface. Decide it now, not after the build.

Start with the fields you genuinely need, because retrofitting them hurts: venue, menu, menu type, active dates, section, item, description, price, currency, dietary flags, allergens, availability, shared or venue specific, and sort order.

That shared or specific distinction matters more than it looks. In a group, some menus are shared and some belong to one site. Model it as a many to many relationship on day one, or you will end up copying pages and one venue's dishes will start appearing on another venue's menu, which is a bug we have seen on more than one platform.

Step 2: type the menu so wrong data cannot compile

Start with the dietary enumeration, because it is the one place people quietly invent values.

// 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}`;
}

Type the item's diets field as RestrictedDiet[] and an invented value stops being a silent data error and becomes a build failure. We tested that by putting DairyFreeDiet into the data and running the type checker:

error TS2322: Type '"DairyFreeDiet"' is not assignable to type
'"DiabeticDiet" | "GlutenFreeDiet" | ... | "VegetarianDiet"'

That is the whole point of doing this in a typed language. The compiler enforces the vocabulary so a human does not have to remember it.

Step 3: render the page on the server

The menu has to be in the HTML the server sends, before any JavaScript runs. In Next.js that means a server component and generateStaticParams so every venue and menu combination is generated at build time.

// 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>
  );
}

Two details in there are worth more than they look.

The heading varies. ${venue.name} ${menu.name} produces a different H1 for every combination. A single static heading reused across every venue and menu is one of the most common defects in a multi venue build, and it undoes much of the benefit of having separate pages at all. Verified across three routes:

$ 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>

Write the heading as one expression. This looks pedantic and is not. Writing <h1>{venue.name} {menu.name}</h1> as two adjacent expressions makes React insert an empty HTML comment between them in the server output, which quietly breaks any tooling that reads the heading with a simple pattern. A single template literal child avoids it. We hit this for real during the build.

Step 4: generate the structured data from the same objects

Never hand write JSON-LD next to a menu. It drifts within a season, and a schema block that disagrees with the visible page is worse than none.

// 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),
        })),
      })),
    },
  };
}

Because it is built from the same objects the page renders, the two cannot disagree. Set your expectations correctly though: Google documents no dedicated Menu rich result, and its Local Business guidance asks only for a menu URL. Publish this because it makes your facts explicit for any consumer, not because a rich result is waiting.

Step 5: make the print menu a derived output

This is the step that answers the only real objection to the whole exercise, which is that the PDF exists because the kitchen needs something to print.

Do not maintain two menus. Generate the printable one from the same data:

$ npx tsx scripts/generate-print-menu.ts borough-market dinner
Wrote out/borough-market-dinner-print.html

A print stylesheet over generated HTML is enough, and it is easier to restyle than a PDF pipeline. The designer keeps the layout, the kitchen keeps its printed menu, and nobody maintains the same dish list twice.

Step 6: let someone publish a change without a deploy

If a price change needs a developer, the menu will be wrong by March. Expose a revalidation endpoint the CMS or an internal tool can call:

// 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 });
}

Tested with a wrong secret, with no secret configured and with the correct secret, returning 401, 401 and:

{"revalidated":true,"path":"/menus/borough-market/dinner"}

Guard it with a secret and treat a missing environment variable as a failure rather than an open door.

Step 7: prove a machine can actually read it

This is the acceptance test, and it is the one step nobody should skip. Check the raw response, never the browser. A browser runs your JavaScript, so it will tell you the menu is fine when it is not.

We built the same menu twice from one data file to show the size of the difference. Server rendered:

$ 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

The same dish, on a page that sets its menu in client side state instead:

$ 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>

Nothing. The entire raw body is a loading placeholder. Identical in a browser, invisible to a plain fetch.

Your acceptance checklist:

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

The fourth line catches the defect that wastes the most money here: a noindex left on from staging. A perfect menu that tells engines not to look at it is a total loss, and invisible unless you check the header.

How to build this with Claude Code or Codex

Both handle this well. The task is small, typed and testable. What changes the result is handing over the constraints rather than the feature request.

A prompt that works:

Build a server rendered menu route at /menus/[venue]/[menu] in this Next.js app. Read the menu from a single typed data module. Requirements: every dish name and price must appear in the raw HTML with no JavaScript executed; the H1 must vary by venue and menu and be written as a single template literal; emit Restaurant > hasMenu > Menu > hasMenuSection > hasMenuItem JSON-LD built from the same objects the page renders; type suitableForDiet against the eleven real schema.org RestrictedDiet values so an invalid one fails the type check. Then verify with curl and show me the output.

The last sentence does most of the work. Ask for the verification and you get an agent that runs curl and reads the response. Leave it out and you get code that looks right.

Two more habits worth adopting. Ask for the failing case as well, meaning a deliberately client rendered version, so you can see the contrast in your own project rather than taking it on trust. And ask it to run the type check after generating the data file, because an invented dietary value is the single most likely error in this build and the compiler catches it instantly.

If you cannot do a custom build

Most restaurants are not going to run a Next.js app, and they do not need to. What matters is whether the platform can output readable text plus structured data that a non technical person can keep current.

PlatformThe routeWatch out for
SquarespaceNative Menu block for the text, page level Code Injection for JSON-LDCode Injection needs Core or above, Squarespace emits its own Local Business schema on every page which can collide with yours, and the Menu block does not feed your injected schema, so the two drift
WebflowA CMS Collection for items and sections, with JSON-LD bound to CMS fields in the Collection TemplateField and character limits on a rich menu, and each additional locale needs its own schema
WordPressA dedicated menu plugin, or hand written JSON-LD in the templateYoast and Rank Math do not generate Menu schema, whatever the general advice says
ShopifyMetaobjects for menu content plus JSON-LD in the themeModelling dishes as products pulls in checkout behavior you do not want
WixThe Restaurants Menus appThe documentation does not state what its structured data option emits, so verify the live page yourself
Toast, Popmenu, Flipdish, UpMenuThe vendor's own menu pages on your domainConnecting your domain is a separate step, and an unconnected site will not be found

Whatever the platform, do not put the menu in an iframe or rely on a booking widget to serve it. Embedded vendor content frequently carries its own noindex, and an assistant that never runs the script never sees inside the frame regardless.

The traps we hit

Two things cost real time during this build, so they are probably waiting for you too.

Adjacent JSX expressions in the heading. Covered above. It produced a heading that looked correct in the browser and failed a simple automated check, which is the worst kind of bug because the visible evidence says you are fine.

Path aliases in standalone scripts. A plain Node script cannot import a TypeScript module that uses an @/ alias, so run those through tsx, which respects your config paths.

What this is worth doing for

The build is the small part. What you get is the menu as data: one place to change a dish, a page per venue, a print file that cannot drift, and dish vocabulary that finally exists where a machine can read it.

We do this for restaurants inside the monthly fee, for every venue and every menu, and that is the offer.

Sources

  1. Schema.org, "RestrictedDiet" and "suitableForDiet", https://schema.org/RestrictedDiet, verified 2026-09-05.
  2. Google Search Central, "Local Business (LocalBusiness) Structured Data", https://developers.google.com/search/docs/appearance/structured-data/local-business, last updated 10 December 2025, verified 2026-09-05.
  3. Google Search Central, "Structured Data Markup that Google Search Supports", https://developers.google.com/search/docs/appearance/structured-data/search-gallery, last updated 15 June 2026, verified 2026-09-05.
  4. Squarespace Help Center, "Using code injection", https://support.squarespace.com/hc/en-us/articles/205815908-Using-code-injection, verified 2026-09-05.
  5. Squarespace Help Center, "Menu blocks", https://support.squarespace.com/hc/en-us/articles/206544087-Menu-blocks, verified 2026-09-05.

Frequently Asked Questions

Marco Lobo
Marco Lobo

Founder, Schmitdy

Marco builds AI search growth systems that turn prompts, sources, content, and agents into revenue.

We will build your menu pages, at no extra chargeEvery venue, every menu, out of PDF and into pages a phone and a crawler can both read. Included in Managed AI Search from $1,650 a month, not quoted separately.
Get my menus built

Related Articles

Works with your website stack

Keep the platform. Improve what it ships.

See platform capabilities
SquarespaceVercelWebflowSanityShopifyHubSpot