Skip to content

Deploy to Vercel

You can run the whole stack in one Vercel project instead of on your own cloud. The storefront and the subgraphs run as functions, and the gateway runs as a container. A push to a branch deploys a preview of the whole stack, and a merge to main deploys production.

This page covers what your project needs for that, and what works differently from a deployment to your own cloud, as in Deploying. You need a Vercel Pro team: container services and service bindings are not on the Hobby plan.

Part Vercel service Reachable from
Storefront Next.js The internet, for every path except /graphql
Gateway A container, from the evolve-graphql-gateway image The internet, on /graphql and /graphql/auth
Each subgraph A Node function The gateway only
flowchart LR
  browser[Browser] -- "/graphql" --> gateway[Gateway]
  browser -- "everything else" --> site[Storefront]
  site -- binding --> gateway
  gateway -- binding --> purchase[purchase]
  gateway -- binding --> discover[discover]

The subgraphs have no public route. The gateway reaches them through service bindings: Vercel puts the address of the subgraph in this deployment into an environment variable. A preview’s gateway therefore calls the preview’s subgraphs, without a URL that you set.

The browser calls /graphql on the host that served the page. One deployment answers on several hostnames: its own URL, the branch alias and, in production, your domain. A fixed gateway URL would be cross-origin from all but one of them, and the browser would then drop the session cookies.

vercel.json in the root of your repository declares the services, their bindings and the routes. Nothing about the topology is configured in the dashboard. For a project with the purchase and discover subgraphs:

{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["fra1"],
"git": { "deploymentEnabled": true },
"services": {
"site": {
"root": "frontend/site",
"framework": "nextjs",
"bindings": [{ "type": "service", "service": "gateway", "format": "url", "env": "SERVER_API_GATEWAY_URL" }]
},
"gateway": {
"root": "backend/graphql-gateway",
"runtime": "container",
"entrypoint": "Dockerfile.vercel",
"bindings": [
{ "type": "service", "service": "purchase", "format": "url", "env": "SUBGRAPH_PURCHASE_URL" },
{ "type": "service", "service": "discover", "format": "url", "env": "SUBGRAPH_DISCOVER_URL" }
]
},
"purchase": { "root": "backend/purchase", "entrypoint": "src/vercel.ts" },
"discover": { "root": "backend/discover", "entrypoint": "src/vercel.ts" }
},
"rewrites": [
{ "source": "/graphql", "destination": { "service": "gateway" } },
{ "source": "/graphql/auth", "destination": { "service": "gateway" } },
{ "source": "/(.*)", "destination": { "service": "site" } }
]
}
  • regions sets where the functions run. Pick the region closest to your commerce engine.
  • The order of services matters. Vercel builds them one after another, in this order, on one checkout. The storefront goes first, because its build generates what the gateway needs; see Generate the schema.
  • Bind only what a service calls. Each binding adds to the time a cold function takes to start.

Vercel calls a function’s default export once per request. Give each subgraph an entrypoint that builds the service, but does not start a server:

import type { IncomingMessage, ServerResponse } from "node:http";
import { createApp } from "#src/server.ts";
const yoga = (await createApp()).getYogaInstance();
if (!yoga) {
throw new Error("The service has no GraphQL schema to serve");
}
// Vercel prefers a handler's `fetch` over the handler itself, and the framework loses the request headers on that path.
export default (req: IncomingMessage, res: ServerResponse) => yoga(req, res);

createApp is the function your service already starts its server with. Export a plain (req, res) function, not the yoga instance: the instance also has a fetch method, and Vercel would call that with a web Request, whose headers the framework’s request context does not copy.

The gateway runs the same evolve-graphql-gateway image as on your own cloud. Its container build only sees the directory of its Dockerfile, so that directory holds everything the image needs:

FROM ghcr.io/evolve-platform/evolve-graphql-gateway:0.3.0
COPY --chown=app:app router.config.vercel.yaml /app/router.config.yaml
COPY --chown=app:app supergraph.generated.graphql /app/
# The supergraph is composed during the build, not read from Hive.
ENV SUPERGRAPH_FILE_PATH=/app/supergraph.generated.graphql

Start router.config.vercel.yaml from the image’s own configuration, and change two blocks:

docker run --rm --entrypoint cat ghcr.io/evolve-platform/evolve-graphql-gateway:0.3.0 /app/router.config.yaml > backend/graphql-gateway/router.config.vercel.yaml
override_subgraph_urls:
subgraphs:
purchase:
url:
expression: |
origin = string!(env("SUBGRAPH_PURCHASE_URL"))
if ends_with(origin, "/") { origin = slice!(origin, 0, -1) }
origin + "/graphql"
discover:
url:
expression: |
origin = string!(env("SUBGRAPH_DISCOVER_URL"))
if ends_with(origin, "/") { origin = slice!(origin, 0, -1) }
origin + "/graphql"
plugins:
federated_token:
config:
cookie_domain: "${COOKIE_DOMAIN:-}"
  • The subgraph addresses. A binding holds the origin of the subgraph, with a trailing slash. Appending /graphql to it as it is gives //graphql, which Vercel answers with a redirect that the router does not follow. The router then reports SUBGRAPH_RESPONSE_DESERIALIZATION_FAILURE, with no redirect in the message. With LOG_LEVEL=debug, the router logs the URL it calls.
  • The cookie domain. Without a domain, the session cookies are host-only, which is what works on every hostname of a deployment. The image substitutes every ${...} in the file, and a variable without a default stops the router from starting, so give COOKIE_DOMAIN an empty default.

Keep the rest of the federated token block as the image has it. The block above shows only where cookie_domain sits.

The subgraph schemas, the supergraph and the storefront’s types are generated files, so a checkout does not have them. Vercel has no step before the services build, so the storefront’s build generates them for every service. Turbo already knows the order, from the codegen dependencies in each package’s turbo.json:

"scripts": {
"prebuild": "turbo run codegen --filter=@evolve-platform/site",
"build": "node scripts/write-build-info.ts && next build --turbo"
}

The gateway’s codegen script calls task, which Vercel’s build image does not have. Add @go-task/cli to the gateway’s dev dependencies, or have the script call the Hive CLI itself.

To check the order, run turbo run codegen --filter=@evolve-platform/site --dry=text.

Two settings that serve a self-hosted storefront stand in the way on Vercel:

output: process.env.VERCEL ? undefined : "standalone",
cacheHandler: process.env.VERCEL
? undefined
: fileURLToPath(new URL("./cache-handler.mjs", import.meta.url)),

The cache handler shares the cache between the containers of a self-hosted storefront. Vercel has a cache of its own, and it does not bundle the handler, so a deployment that keeps it fails with “Cannot find module”.

Then let the browser find the gateway on its own origin. Make CLIENT_API_GATEWAY_URL optional in the site’s configuration, keep the local value in your .env, and fill in the origin where the fetcher is made:

// Without a configured gateway, it answers on the origin that served the page.
const base = apiHostname ?? (typeof window === "undefined" ? "" : window.location.origin);
const fetcher = useSessionClientFetcher(`${base}/graphql`, {

Server-side rendering cannot call a relative URL. The binding sets SERVER_API_GATEWAY_URL while the site runs, but not while it builds, so fall back to the deployment’s own URL:

get serverApiHostname(): string | undefined {
return (
this.SERVER_API_GATEWAY_URL ||
this.CLIENT_API_GATEWAY_URL ||
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined)
);
}

The content security policy needs no change: 'self' already allows the storefront’s own origin.

  1. Create the project and connect the repository.

    vercel link --scope <your-team> --project <your-project>
    vercel git connect

    Leave the build, output and install commands empty: vercel.json sets the framework to services. The Vercel GitHub app needs access to the organization that owns the repository. Without it, vercel git connect fails with a message about typos.

  2. Set the environment variables for Production and for Preview, with vercel env add <name> <environment> or in the dashboard. Mark secrets as sensitive. Next to what your services read on any cloud, set these:

    Variable Value
    FEDERATED_TOKEN_ENCRYPT_KEY_1, FEDERATED_TOKEN_SIGN_KEY_1 32 bytes each. The gateway does not start without them.
    JWT_ISSUER, JWT_AUDIENCE One fixed URL, the same for the gateway and the site, in every environment
    HOST_NAME The public hostname
    NPM_RC An .npmrc that can read the Evolve registry. Vercel writes it before the install.

    Leave these unset:

    Variable Why
    CLIENT_API_GATEWAY_URL, COOKIE_DOMAIN The browser calls its own origin, and the cookies are host-only.
    SERVER_API_GATEWAY_URL, SUBGRAPH_*_URL The bindings set them.
    REDIS_URL See Redis.
    INTERNAL_EVENTS_TARGET The services then publish to a no-op publisher.
  3. Push a branch. The deployment that follows is a preview. The first one takes a few minutes, most of it the install and the build of the storefront.

To find a build error faster than a deployment shows it, run vercel build locally: it resolves the configuration the same way.

Deploy from git only. vercel deploy from your machine uploads the working copy, which is over Vercel’s file limit with the build output in it, and without .git the codegen in the build fails.

Your own cloud Vercel
Release Blue-green, with smoke checks before the switch One atomic deployment per push
Roll back Move the traffic to the other label vercel rollback, or promote an earlier deployment in the dashboard
Secrets The cloud’s secret store, written by Terraform The project’s environment variables
Supergraph Published to Hive, after hive schema:check Composed during the build. Nothing checks it against the schema in use.
Previews One test environment A full stack for every branch

Leave REDIS_URL unset for the subgraphs. Their cache and rate limiter then keep their data in memory, per instance.

The framework’s Redis client keeps one TCP connection open and checks it every ten seconds. Vercel freezes a function between requests, so the check does not run and the connection dies unnoticed. The next request then waits for a timeout. A Redis provider for serverless does not help over TCP, only over HTTP, and the framework has no HTTP client for Redis.

In memory, the cache is smaller, and the rate limiter counts per instance and not for the whole deployment.

The framework has no adapter for a Vercel queue. With INTERNAL_EVENTS_TARGET unset, a service publishes its events nowhere, so nothing reacts to them: a change in the CMS, for example, does not clear the storefront’s cache.

The storefront’s cached fetches stay in Vercel’s data cache across deployments: a new deployment serves what the previous one cached. When a deployment changes the shape of cached data, invalidate the tags it uses:

vercel cache invalidate --tag layout

Production and Preview have separate caches, but all previews share one. Prefer invalidating a tag over vercel cache purge, which clears everything.