---
title: How to architect multi-location QSR menus on Vercel
description: Multi-location QSR menus vary by price, hours, and availability. Learn how to model them on Vercel with ISR, Global Config, and crawlable location pages.
url: "https://vercel.com/kb/guide/multi-location-qsr-menus"
published: 2026-10-01
last_updated: 2026-10-01
authors: Vercel
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

A customer opens a quick-service restaurant (QSR) app at 12:10, sees the chicken sandwich at their store, and drives over. The kitchen pulled it from the line an hour ago. Staff had 86'd it, kitchen shorthand for marking an item unavailable, and nothing between the point-of-sale (POS) terminal and the app carried that flag.

Menus, per-location pricing, store hours, and live item availability have to stay correct at every address while running from one Next.js codebase. You solve most of it in the data model, and the rest in what you let render at request time.

## What makes multi-location QSR menus hard to model

Treating the menu as one global dataset copied to every store works until the first franchisee changes a price. Use a corporate base menu with a location-scoped exception list that records only what differs from the base.

Track four types of location-specific differences:

- **Item availability:** Whether the kitchen has 86'd the item.
  
- **Price:** A location-level override on top of the corporate price.
  
- **Store hours:** Regular hours, holiday hours, and day-part boundaries.
  
- **Channel visibility:** Whether an item shows on pickup, delivery, or both.
  

Part of the staleness is fixed upstream, before your code sees the change. Toast's Menus API returns [published data only](https://doc.toasttab.com/doc/devguide/apiMenusApiReturnsPublishedDataOnly.html), so a change a staff member makes in Toast Web stays invisible until someone publishes it, and Toast takes time to generate the new JSON. You cannot shrink that floor, so the job is to avoid adding to it.

To build this, you’ll need:

- **A Next.js App Router project on Vercel:** [Global Config](https://vercel.com/docs/global-config) is available on every plan, with more write capacity on Pro and Enterprise.
  
- **A POS or ordering platform with a menu webhook:** Toast, Olo, Square, and Deliverect all publish one.
  
- **A location source with IDs, coordinates, and hours:** The build reads this to decide which pages exist.
  

With those in place, the first decision is what renders each store's page.

## How to pre-render every location page and revalidate on demand

Every location gets its own route at `/menu/[locationId]`, and [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) pre-renders those routes at build time from the IDs in your locations API. New stores shouldn't need a redeploy, so leave `dynamicParams` at its default of `true`. A location ID missing from the build list renders on first request and is cached as an [Incremental Static Regeneration](https://vercel.com/docs/incremental-static-regeneration) (ISR) entry.

Turning on `cacheComponents` alongside `partialPrefetching` changes what that first visitor waits for. Next.js 16.3 serves [an App Shell](https://nextjs.org/docs/app/guides/incremental-static-regeneration-cache-components) instantly and upgrades it in the background, so the next visitor to that store gets the concrete page. A `<Link>` entering the viewport counts as that first visit, so a store locator warms its own pages. Cache Components also needs `generateStaticParams` to return at least one param, so return a real location ID. Keep the `params` read inside a `<Suspense>` boundary, since awaiting it above binds the App Shell to one store.

Hours and descriptions change rarely, so a time-based `revalidate` interval measured in hours covers them. Prices and published menu changes arrive as POS webhooks, on no schedule at all.

A Route Handler receives the webhook and revalidates that store's tag:

```tsx
import { revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  const secret = request.headers.get('x-webhook-secret');

  if (secret !== process.env.MENU_WEBHOOK_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { locationId } = await request.json();
  revalidateTag(`location-${locationId}`, 'max');

  return Response.json({ revalidated: true });
}
```

The second argument controls whether visitors wait for regeneration. With `'max'`, Next.js serves the cached page while it regenerates in the background. The deprecated single-argument form behaves like `{ expire: 0 }`, blocking the next visitor until the render finishes.

The tag scope determines how much of the chain a price change affects. Only cached data tagged `location-123` goes stale. Every other store continues serving cached content, and Vercel propagates the purge to all regions within 300ms. A request triggers the regeneration, not the `revalidateTag` call, so 10,000 location pages refresh as visitors reach them. Bare content-type tags give all of that up by purging everything of a kind. That is the [write amplification](https://vercel.com/kb/guide/caching-antipatterns) pattern Vercel's caching audits find most often.

### Routing a visitor to their nearest location page

[Routing Middleware](https://vercel.com/docs/routing-middleware) runs before the cache, and that is where store selection belongs. Every request arrives with `x-vercel-ip-latitude`, `x-vercel-ip-longitude`, and `x-vercel-ip-postal-code` among the [geolocation headers](https://vercel.com/kb/guide/geo-ip-headers-geolocation-vercel-functions), and the `geolocation()` helper in [`@vercel/functions`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package) returns them as a typed object. Match those coordinates to the nearest location, then rewrite to that store's pre-rendered page, with no request-time render in the path.

IP geolocation is accurate to a neighborhood, so have the visitor confirm the store with the browser Geolocation API before an order commits. The current convention is a [`proxy.ts`](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) file with a `proxy` export, running on Node.js only. Next.js 16 deprecated `middleware.ts`, and `npx @next/codemod middleware-to-proxy .` renames the file and the function.

## How to update per-location availability without rebuilding the menu

Availability changes by the minute, which is the usual argument for server-rendering a menu page. A cached page carries it too, with a small client-side overlay retrieving current availability after the HTML arrives.

Global Config is the store behind that overlay, replicated to every region before a request arrives. Reads complete within 15ms at the 99th percentile, often under 1ms. The current package is `@vercel/global-config`, and projects on `@vercel/edge-config` keep working unchanged.

A Route Handler reads the location's exception list, and the overlay calls that endpoint once the cached menu shell loads.

Split the two update paths by how often they fire:

- **One item going unavailable:** Write the 86'd item to Global Config through the REST API, since the SDK reads only. The overlay picks it up on its next request.
  
- **A published menu or price change:** Revalidate that location's tag through the webhook and the page regenerates.
  

The stored shape stays sparse, one key per location holding only the hidden item IDs, as in `{ 'loc-123': ['item-A', 'item-B'] }`. On order submission, re-check stock against the POS, since that is the system of record.

Check what you store against these [Global Config limits](https://vercel.com/docs/global-config/global-config-limits):

| Constraint         | Value                               | Implication for a chain                                                          |
| ------------------ | ----------------------------------- | -------------------------------------------------------------------------------- |
| Maximum store size | 1 MB on every plan                  | Exception lists only                                                             |
| Write cost         | $1.00 per 100 writes on demand      | Batch availability flips into one write rather than one per item                 |
| Write propagation  | Up to 10 seconds globally           | Suitable for marking items unavailable; unsuitable for transactional inventory.” |
| Stores per project | 1 on Hobby, 3 on Pro and Enterprise | Shard by region or brand, not by location                                        |

Price moves less often than availability and carries more structure, starting with who sets it.

## How to model franchise pricing across multi-location menus

A single `price` field per item fails the moment a franchisee sets their own price. The model needs a location-level price the franchisee controls, with the corporate price as the default.

POS APIs already encode this shape. Square's [`CatalogItemVariation`](https://developer.squareup.com/reference/square/objects/CatalogItemVariation) carries a base `price_money` plus a `location_overrides` array keyed by `location_id`, and each override holds its own `price_money`, a `pricing_type`, and a read-only `sold_out` flag with a `sold_out_valid_until` timestamp. Toast exposes the same idea as a [Location Specific Price](https://doc.toasttab.com/doc/platformguide/platformEachVersionOfAMenuHasItsOwnMenuSpecificPrices.html) strategy on the menu item.

Price also moves by channel and by day part, so key the override on whichever combination your POS supports. The location page resolves it by calling the POS with a location-scoped token, ISR caches the result, and the webhook's tag revalidation refreshes it.

## How to keep thousands of location pages fast and crawlable

The price and hours resolved for each location have to reach crawlers as [JSON-LD](https://nextjs.org/docs/app/guides/json-ld), inside a `<script type="application/ld+json">` tag in `page.js` or `layout.js`. Server Components render it into the initial HTML, so crawlers read it without running JavaScript. `JSON.stringify` doesn't sanitize its input, so replace every `<` with `\u003c` before the payload reaches the page.

Give each location its own `LocalBusiness` entity carrying `openingHoursSpecification`, a `menu` URL, `priceRange`, and `geo` coordinates. A chain crosses Google's per-sitemap limit of 50,000 URLs or 50 MB early, so split it with [`generateSitemaps`](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps). It returns an array of `{ id }` objects that Next.js serves at `/menu/sitemap/1.xml` and onward.

[Crawl budget](https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget) applies to sites over 10,000 pages whose content changes daily, or over a million pages changing weekly. A chain qualifies on the first count once prices and hours move every day. ISR gives Googlebot a fast 200 while your origin stays out of the request. A crawler can read a stale price for one revalidation interval, acceptable when a webhook drives each price change.

## What breaks a multi-location QSR menu setup at chain scale

You hit the write budget first, usually during a lunch rush. The other three surface later.

### Global Config writes during a rush

Writes are metered at $1.00 per 100 on demand, and the store isn't built for frequently updated data. Hundreds of items going unavailable across one lunch rush become hundreds of writes, each propagating for up to 10 seconds before every region sees it. Batching those updates over a short window and flushing them as one write per location keeps both the cost and the lag down.

### ISR write utilization

ISR Observability reports [write utilization](https://vercel.com/changelog/write-utilization-now-available-in-isr-observability), the ratio of cached requests to ISR writes, for teams on Observability Plus. A location page regenerating hourly for ten daily visits belongs on a longer interval, or on demand.

### Publishing gates in the point-of-sale system

Toast's [Menus Webhook](https://doc.toasttab.com/doc/devguide/apiMenusWebhook.html) can miss messages, so poll the `/metadata` endpoint every 30 minutes as a backup. Put the publish step in the store's opening runbook, since the webhook never fires for a change nobody published.

### Geolocation behind your own proxy

Vercel overwrites `x-forwarded-for` behind a proxy to prevent IP spoofing, so a chain with its own edge in front loses the visitor's real IP and every `x-vercel-ip-*` header. [Verified Proxy](https://vercel.com/docs/security/reverse-proxy) restores it, and Lite covers Cloudflare, Akamai, Fastly, CloudFront, and four more providers automatically on every plan. A self-hosted proxy needs Verified Proxy Advanced on Enterprise.

## Next steps

Once the exception list and tag scheme are settled, the architecture covers every store.

[Start a new Vercel project](https://vercel.com/new) and connect your locations repository. [Vercel for retail](https://vercel.com/for/retail) covers the storefront and personalization work that sits alongside this.

## Read more

- [Incremental Static Regeneration](https://vercel.com/docs/incremental-static-regeneration)
  
- [Global Config](https://vercel.com/docs/global-config)
  
- [Global Config limits](https://vercel.com/docs/global-config/global-config-limits)
  
- [Routing Middleware](https://vercel.com/docs/routing-middleware)
  
- [Geolocation IP headers](https://vercel.com/kb/guide/geo-ip-headers-geolocation-vercel-functions)
  
- [Caching antipatterns](https://vercel.com/kb/guide/caching-antipatterns)
  

## Frequently asked questions

### What happens to cached location pages after a deploy?

Each deployment gets its own cache, because the cache key includes the build ID. Locations that `generateStaticParams` pre-rendered come back warm, and any location generated on demand is cold again. With Cache Components that visitor gets an App Shell instantly, and without it they wait on a render. Vercel's audits call this a deploy-wiped cache.

### What does a location page serve if the POS is down when its webhook fires?

The stale one. Vercel counts a network error, a function error, or a status code outside 200, 301, 302, 307, 308, 404, and 410 as a failed revalidation. It keeps serving the cached page and sets a 30-second TTL, so it retries shortly after and customers see the last good menu.

### Does the availability overlay cost a Global Config read per 86'd item?

No. Vercel counts one read per request to the store, whether you retrieve a single item or every item in it. A single `get` on that location's key is one read, however many items a kitchen has 86'd. `getAll` collapses several keys into one read if an overlay needs more.

### Why don't geolocation headers work in local development?

The `x-vercel-ip-*` headers are set by Vercel's CDN on deployed requests, so they never appear when you run the app locally and `geolocation()` returns undefined fields. Test store routing on a preview deployment, where the headers behave exactly as they do in production.

## More Next.js guides

- [Building Ecommerce Sites with Next.js and Shopify](/kb/guide/building-ecommerce-sites-with-next-js-and-shopify): Learn how to integrate Next.js and Shopify together for the fastest storefronts using the Storefront GraphQL API.
- [How to set up local HTTPS for Next.js and other dev servers](/kb/guide/access-nextjs-localhost-https-certificate-self-signed): Learn when you need local HTTPS, how to enable it with next dev --experimental-https, and how to clear certificate warnings on localhost.
- [Rendering strategies for retail: ISR vs SSR vs static](/kb/guide/ssr-vs-ssg-vs-isr-vs-csr-for-ecommerce): Choose between SSR, SSG, ISR, and CSR for each ecommerce surface. Compare rendering modes on freshness, personalization, SEO, and cost.