Today Platform Web — Dev Docs
Architecture

Vercel deploy model

How Vercel turns code into a running URL — build time vs deploy time vs runtime, what `vercel.json` controls, and the four archetypes of "Vercel project linked to a Git repo".

The repo deploys five apps to five Vercel projects, all under one mechanism but in two different shapes:

  • Actions-owned (today-web, today-admin, today-design-system, today-dev-docs): GitHub Actions calls vercel build + vercel deploy --prebuilt, Vercel's own Git auto-deploy is disabled.
  • Vercel-Git-owned (webview-bridge-docs): no Actions workflow; Vercel for GitHub builds + deploys directly on push.

To pick the right shape for a new app — or to debug why an old one behaves oddly — you need a clear mental model of what Vercel does and when. This page covers the underlying model; Workflow → Deployment covers our concrete per-app configuration.

Three lifecycle phases

Every deploy passes through three phases. Different config sources, different env vars, different things you can change.

+-------------------+  +------------------+  +----------------------+
|     Build time    |  |    Deploy time   |  |       Runtime        |
| (source -> output)|  | (output -> CDN)  |  | (request -> response)|
+-------------------+  +------------------+  +----------------------+
PhaseRuns whereReadsProduces
Build timeVercel build container, OR your CI (vercel build)vercel.json buildCommand / installCommand / framework / outputDirectory; Project env vars scoped to Build (incl. NEXT_PUBLIC_* inlined into client bundles).vercel/output/ (Build Output API format)
Deploy timeVercel control plane (CDN / Edge network)vercel.json rewrites / redirects / headers / cleanUrls / trailingSlash; functions block (per-Serverless/Edge function region, memory, maxDuration); crons; project domain bindings, alias, Production/Preview targetA Deployment URL (xxx-yyy.vercel.app) + alias
RuntimeEdge / Node Function / static asset on user requestProject env vars scoped to Runtime; process.env inside Functions; System env vars (VERCEL_ENV, VERCEL_URL, VERCEL_GIT_*)HTTP response

A few cases that bite repeatedly:

  • NEXT_PUBLIC_* is a Build-time concern, not Runtime. Changing the value in the Vercel project settings doesn't change anything until you redeploy.
  • vercel.json routing fields (rewrites, headers, etc.) are compiled into .vercel/output/config.json at build time and consumed by Vercel's router at deploy/runtime. Changing them requires vercel build + vercel deploy. They are not runtime-mutable.
  • ignoreCommand runs before build time — it's a "should we even build?" gate. Vercel runs git diff plus your command on the just-pushed commit; if the command exits 0, Vercel skips the build entirely. This is the cheapest form of build-skipping when nothing in your scope changed.

System env vars (VERCEL_ENV, VERCEL_URL, etc.) are only auto-injected when Vercel itself runs the build. If you vercel build from GitHub Actions, you need vercel pull --environment=preview|production first — the CLI writes them to .vercel/.env.{env}.local for vercel build to consume. See Environment variables for the full Next.js inlining story (process.env.NEXT_PUBLIC_* substitution, .env.{mode}.local priority, the vercel pull flow).

Project, deployment, alias, target

These four primitives compose every Vercel concept.

TermMeaning
ProjectThe unit Vercel deploys. One Vercel project per app in our repo (five total). Each has its own settings, env vars, and (optionally) a Git link.
DeploymentAn immutable build of one commit. Identified by a unique dpl_* ID and a hash URL <hash>.vercel.app. Every build creates one.
AliasA pretty URL pointing at a deployment. today.ai is an alias on the latest production deployment of today-web. Aliases are mutable; deployments are not. Promote / rollback = "move the alias to a different deployment".
TargetThe environment label on a deployment: production or preview. Determines which env-var scope was active during vercel pull and which alias pool applies.

Each Git push creates one or more deployments. Switching production is a sub-second alias change; the previous deployment is still reachable at its hash URL.

The Vercel project ↔ Git repo spectrum

A Vercel project's relationship with its Git repo isn't binary "linked / not linked". It's a four-archetype spectrum, and the choice affects what's automated and what isn't.

ArchetypeStateBehavior
A. UnlinkedNo Git repo at allCLI-only deploys. No branch alias, no VERCEL_GIT_* vars, no PR comments, no Production Branch UI.
B. Linked + auto-deploy disabledLinked, git.deploymentEnabled: false in vercel.jsonVercel sees the repo (PR comments, Production Branch, VERCEL_GIT_* populated when CLI runs in Actions), but does not build on push. CI is responsible.
C. Linked + auto-deploy enabledLinked, no overridePush triggers build + deploy on Vercel. Default behavior; zero workflow needed.
D. Deploy HookLinked, but deploys via HTTP webhookA unique URL that triggers a deploy without a new commit. Useful for "rebuild on CMS change".

Our five projects sit at B or C:

ProjectArchetypeWhy
today-webBActions builds + deploys via prebuilt; tight control over CI gating
today-adminBSame
today-design-systemBSame
today-dev-docsB (intended)Actions workflow exists; see Deployment for current state
webview-bridge-docsCPre-dates the Actions convention; pure Vercel Git

The keep-linked-but-disable-auto-deploy hybrid (B) is the load-bearing pattern because it preserves everything link gives you for free — branch alias, Git metadata, PR commit status, deployment list with author / SHA — while keeping CI as the single build authority.

The two off-switches and what they actually do

When you want to suppress Vercel's auto-deploy, you have two knobs. They are not equivalent.

                            push
                              |
                              v
            +---------------------------------+
            | git.deploymentEnabled in        |
            | vercel.json -- false?           |
            +---------------------------------+
                  |                       |
              yes |                       | no
                  v                       v
              no deploy           +-----------------+
                                  | previewDeploy-  |
                                  | mentsDisabled   |
                                  | (project-level) |
                                  +-----------------+
                                          |
                              +-----------+-----------+
                              |                       |
                          yes |                       | no
                              v                       v
                     preview: no deploy      Vercel auto-deploys
                     production: still
                       deploys (production
                       branch only)
SettingWhereDisablesNotes
git.deploymentEnabled: falsevercel.json (per commit)All auto-deploys (preview + production)This is the documented "turn off automatic Git deployments" switch. Wins over project-level config.
previewDeploymentsDisabled: trueProject settings (Vercel UI → Settings → Git)Preview deploys onlyProduction-branch pushes still create deploys unless git.deploymentEnabled: false also set.

A third field — gitProviderOptions.createDeployments: "enabled" — is visible in the API but is not an auto-deploy switch. It controls whether Vercel writes GitHub Deployment API records and commit-status entries for Vercel-side builds. When git.deploymentEnabled: false suppresses the build entirely, there's nothing for createDeployments to report; it's a no-op in that case.

In our setup the four Actions-owned projects have both git.deploymentEnabled: false (in vercel.json) and previewDeploymentsDisabled: true (project-level). The vercel.json setting is the one actually doing the work; the project-level toggle is redundant for the preview path. Don't take this as a recommendation to set both — set git.deploymentEnabled: false and you're done.

The reason all four Actions-owned projects keep the Git link instead of going unlinked (archetype A):

  • Branch aliases: each preview deployment automatically gets <project>-git-<branch>-<team>.vercel.app — stable per-branch URL that always points at the latest deploy for that branch. Unlinked projects must vercel alias set manually.
  • VERCEL_GIT_* env vars: VERCEL_GIT_COMMIT_SHA, VERCEL_GIT_COMMIT_REF, VERCEL_GIT_REPO_SLUG, etc. Vercel CLI in GitHub Actions reads GITHUB_* env vars and translates them, but the project needs a Git link to know which repo they're for.
  • PR commit status: the Vercel for GitHub app posts a Vercel – <project> status check on each commit. Still posted on prebuilt deploys.
  • Production Branch semantics: vercel deploy --prod from CLI knows which branch is production based on the link.
  • Deployment list UX: each deployment in the Vercel dashboard shows commit message, author, branch — populated from the Git link.

Practically: link the project, set git.deploymentEnabled: false in vercel.json, and treat Vercel as a managed CDN that consumes prebuilt artifacts from Actions. You keep all the integration nice-to-haves without duplicating builds.

When CI builds and Vercel builds duplicate

A subtle case the team has occasionally seen: ci.yml's Build job and a deploy-*.yml's vercel build step are both valid Build-time invocations of the same code. They produce different artifacts (CI's gets discarded; deploy's becomes .vercel/output for prebuilt upload) and they're scoped differently (CI uses pnpm --filter across affected apps; deploy targets a single project) — but they compile the same source.

For a PR that touches apps/web, you currently get two next build invocations of apps/web running in parallel:

ci.yml `Build` job          06:52:13Z -> 06:55:44Z  (~3.5 min, all affected apps)
deploy-web.yml `Build` step 06:52:38Z -> 06:54:14Z  (~1.5 min, web only)

The duplication is intentional, not a bug — they serve different purposes:

  • CI's Build job is the required check for branch protection. It also covers targets that have no deploy workflow (packages, scripts, etc.). It's the build correctness gate.
  • Deploy's vercel build produces the shippable artifact with Vercel's env injection and output format. It's the deploy mechanism.

If compute matters, the lever to pull is ordering inside the deploy workflow, not deleting either build:

current order:  vercel pull -> vercel build -> wait for CI -> vercel deploy
better order:   vercel pull -> wait for CI -> vercel build -> vercel deploy

With the current order, if CI fails after vercel build succeeded, the deploy artifact is built and then thrown away. Reordering short-circuits that wasted compute when CI is red. This is a follow-up worth taking once build minutes start to matter; the current order isn't broken, just suboptimal.

See also

On this page