# Per-Brand Bulk Redirects on Vercel with vercel.ts


Here is a launch-week problem that looks trivial until you try it: one shared Next.js codebase, multiple brands deployed as separate Vercel projects, and each brand carrying hundreds of its own legacy URLs that need 301s. Vercel's [bulk redirects](https://vercel.com/docs/routing/redirects/bulk-redirects) are exactly the right tool - they run at the edge, before your app does - but the config key that points at the CSV lives in `vercel.json`, and `vercel.json` is a static file shared by every brand.

This is the same multi-brand setup from my [ISR revalidation series](/posts/xm-cloud-vercel-isr-part-1-revalidation-on-publish): same source directory, built once per brand, with a single env var selecting theme, content root - and now redirects.

The short version, if you only need the answer:

- `vercel.json`'s `bulkRedirectsPath` is a static string and cannot vary per project.
- Replace `vercel.json` with `vercel.ts` and branch `bulkRedirectsPath` on an env var.
- Put that env var in the **deploy shell's** environment. Setting it only on the Vercel project silently ships zero redirects.

Everything below is the detail, plus three production traps we learned the hard way.

### The Setup

| | Vercel project | `SITE_NAME` | Domain |
|---|---|---|---|
| **Brand A** | `web-brand-a` | `brand-a` | `brand-a.example.com` |
| **Brand B** | `web-brand-b` | `brand-b` | `brand-b.example.com` |

The problem in three lines:

- Each brand carries different legacy URLs needing 301s.
- `vercel.json` is shared by both builds; `bulkRedirectsPath` is a plain string with no interpolation.
- One static config file cannot hand a different CSV to each brand.

Two approaches we considered and dropped before landing on `vercel.ts`:

| Approach | Why we dropped it |
|---|---|
| `redirects()` in `next.config.js` | Pushes hundreds of legacy URLs into the routing/function layer instead of cheap edge redirects. |
| A prebuild script that copies `brand-a.csv` to `active.csv` | Works, but adds a script, a generated file, and a build-order dependency for something the platform can do directly. |

### The Solution: `vercel.ts`

`vercel.ts` (via [`@vercel/config`](https://vercel.com/docs/project-configuration/vercel-ts) - install it with `npm i @vercel/config`) replaces `vercel.json`, and because it is code, it can branch:

```ts
import type { VercelConfig } from '@vercel/config/v1';

const SITE_BULK_REDIRECTS: Record<string, string> = {
  'brand-a': 'redirects/brand-a.csv',
  'brand-b': 'redirects/brand-b.csv',
};

const site = process.env.SITE_NAME ?? '';
const bulkRedirectsPath = SITE_BULK_REDIRECTS[site];

if (bulkRedirectsPath) {
  console.log(`[vercel.ts] SITE_NAME="${site}" -> bulkRedirectsPath="${bulkRedirectsPath}"`);
} else {
  console.warn(`[vercel.ts] no bulk redirects mapped for SITE_NAME="${site}"`);
}

export const config: VercelConfig = {
  // Omit the key when unmapped - do not pass "".
  ...(bulkRedirectsPath ? { bulkRedirectsPath } : {}),
};
```

The same file ships to every project; `SITE_NAME` selects the CSV. Two details worth the comments they carry:

- Omit `bulkRedirectsPath` entirely when there is no mapping - an empty string is a value, not "no redirects".
- The `console.log` is not decoration. It is how you verify the whole thing later, because almost nothing else tells you whether redirects registered.

### The Gotcha: Where `vercel.ts` Actually Runs

This is the part that cost us the debugging session. For CLI deploys (`vercel deploy`, which most CI tasks wrap), `vercel.ts` is evaluated **on the machine running the deploy**, before anything is uploaded. The Vercel project's own environment variables are **not** in scope for that evaluation.

We proved it by toggling one variable at a time:

| `SITE_NAME` set where | Do the bulk redirects work? |
|---|---|
| Vercel **project** env var only | **No** - every legacy path 404s, the build log looks fine, exit code 0 |
| The **deploy shell's** environment | **Yes** - the full test matrix passes |

Project-level env vars are injected into Vercel's remote build and runtime - they never reach the CLI's local evaluation of `vercel.ts`. The deploy-time config needs a deploy-time env var. Nothing errors when you get this wrong; you just silently ship zero redirects.

```bash
# Deploying by hand: the env var must be in THIS shell.
SITE_NAME=brand-a vercel deploy --prod --yes --token "$VERCEL_TOKEN"
```

### CI Wiring (Azure DevOps)

In the pipeline this is one `env:` block on the deploy step, fed per brand from a pipeline variable:

```yaml
- task: vercel-deployment-task@1
  inputs:
    vercelProjectId: $(PROJECT_ID)
    vercelOrgId: $(ORG_ID)
    vercelToken: $(VERCEL_TOKEN)
    vercelCWD: "apps/web"
    production: true
  env:
    # vercel.ts is evaluated by the CLI on THIS agent; it needs the brand in
    # the process environment to pick the per-brand redirects file.
    SITE_NAME: $(SITE_NAME)
```

Each brand's deploy stage links its own variable group, so `SITE_NAME` resolves per brand. Pipelines that do not define `SITE_NAME` are unaffected: the placeholder passes through as the literal text `$(SITE_NAME)`, which maps to no CSV, so the key is omitted - nothing changes until a brand opts in.

### Verifying It Worked

The deploy log - in the **agent** step, not the remote build log - should print the line from `vercel.ts`:

```text
[vercel.ts] SITE_NAME="brand-a" -> bulkRedirectsPath="redirects/brand-a.csv"
```

Then spot-check a few legacy URLs with `curl -sI` and confirm the status codes and targets. One thing to know here: bulk redirect rows default to `307`, so if you want permanent redirects, set `statusCode` to `301` (or `permanent` to true) on each row. If you want to audit the registered list itself, the bulk redirects REST API (`/v1/bulk-redirects`) returns what the platform actually holds for the project, which beats guessing.

### Three More Traps We Hit in Production

These came later, each with real downtime or head-scratching attached:

**A dashboard "Redeploy" ships zero bulk redirects.** Registration of the CSV happens during the original CLI deploy, when `vercel.ts` is evaluated locally. A dashboard Redeploy of that same deployment re-runs the build but never re-registers the redirects - and the build logs look identical. We learned this when a well-meaning redeploy on launch weekend silently killed every legacy URL on two production sites. If you need to restore a deployment, use **Instant Rollback** to the original pipeline deployment instead of Redeploy. (You can detect the difference after the fact: the deployment's metadata shows `action: redeploy`.)

**A managed redirects version overrides your CSV.** Bulk redirects can also be managed via the dashboard or CLI as a "managed" version, and a non-empty managed version silently takes precedence over whatever the deployment's `vercel.ts` registered. A stray managed list someone created while experimenting wiped out a lower environment's redirects for us. The fix: delete the managed version, then trigger a fresh pipeline deploy so the repo CSV re-registers. Rule of thumb - pick one source of truth per project and never mix them.

**Query strings are dropped by default.** Bulk redirects support a `preserveQueryParams` column, and its default is `false` - the opposite of `next.config.js` `redirects()`, which preserves the query string automatically. If your legacy URLs carry campaign parameters, set it explicitly per row or your analytics team will notice before you do.

### Wrapping Up

The pattern itself is small: replace `vercel.json` with `vercel.ts`, branch on the env var you already use for brand selection, and put that env var in the deploy shell, not just the project settings. Everything else in this post is the part that cost us real incidents - where the config actually runs, what a Redeploy quietly forgets, and which of two redirect systems wins when both exist.

Hopefully this saves you the silent-404 hunt it cost us!
