---
title: "Feature Flag Routing"
description: "Move the pricing page to a new microfrontend behind a feature flag, so the default app keeps serving it until the flag says otherwise."
canonical_url: "https://vercel.com/academy/microfrontends-on-vercel/feature-flag-routing"
md_url: "https://vercel.com/academy/microfrontends-on-vercel/feature-flag-routing.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-11-04T22:26:55.379Z"
content_type: "lesson"
course: "microfrontends-on-vercel"
course_title: "Microfrontends on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Feature Flag Routing

# Feature Flag Routing

The growth team rebuilt the Acme pricing page in its own app. It has a gradient. It has a slider. Nobody has agreed yet whether the slider is a good idea.

So nobody wants to flip all of `/pricing` over to it on a Tuesday afternoon. We want the new page for the growth team today, for 10% of visitors next week, and for everyone once the slider survives contact with real customers.

## Outcome

Route `/pricing` to a new `acme-pricing` microfrontend when a feature flag is on, and let the marketing app keep serving it when the flag is off.

## How It Works

Here's the config your fingers want to write. Both apps claim `/pricing`, and the flag picks a winner:

```json
"acme-marketing": { "routing": [{ "paths": ["/pricing"] }] },
"acme-pricing": { "routing": [{ "flag": "new-pricing", "paths": ["/pricing"] }] }
```

That doesn't work. Paths must [map to exactly one microfrontend](https://vercel.com/docs/microfrontends/routing#supported-path-expressions), and the default app doesn't take a `routing` array at all.

Flagged routing works differently. You list the path once, under the new app, with a `flag`. Vercel sends that path to the **default application** first, and the default app's `proxy.ts` asks the flag:

```
Request: /pricing

1. Microfrontends sees /pricing is flagged
2. Routes to the default app (marketing)
3. Marketing's proxy.ts evaluates new-pricing
4. Flag on:  hands the request to acme-pricing
5. Flag off: marketing keeps it and serves its own pricing page
```

That's why the pricing page from Lesson 1.4 stays in `apps/marketing`. It's the "flag off" experience, and it keeps working the whole time the new app rolls out.

## Fast Track

1. Create the `acme-pricing` app with a page at `/pricing`
2. Add a `flag` to its routing in `microfrontends.json`
3. Define the flag in marketing and pass it to `runMicrofrontendsMiddleware`
4. Deploy and toggle the flag with a cookie

## Hands-on Exercise 4.1

Move `/pricing` to a new microfrontend behind the `new-pricing` flag.

### Part 1: Create the Pricing App

```bash
cd apps
pnpm create next-app@latest pricing --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*"
```

Update `apps/pricing/package.json`. Like the other apps, the `name` matches the Vercel project name we'll create:

```json title="apps/pricing/package.json"
{
  "name": "acme-pricing",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3003",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "next": "^16",
    "react": "^19",
    "react-dom": "^19",
    "@acme/ui": "workspace:*",
    "@vercel/microfrontends": "^2"
  },
  "devDependencies": {
    "@types/node": "^26",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^10",
    "eslint-config-next": "^16",
    "typescript": "^6",
    "@tailwindcss/postcss": "^4",
    "tailwindcss": "^4"
  }
}
```

Create `apps/pricing/next.config.ts`:

```typescript title="apps/pricing/next.config.ts"
import type { NextConfig } from "next";
import { withMicrofrontends } from "@vercel/microfrontends/next/config";

const nextConfig: NextConfig = {};

export default withMicrofrontends(nextConfig);
```

The page uses the shared Header, so give `apps/pricing/app/globals.css` the same `@source` line as the other apps from Lesson 2.4:

```css title="apps/pricing/app/globals.css" {2}
@import "tailwindcss";
@source "../../../packages/ui/src";
```

Create `apps/pricing/app/pricing/page.tsx`. The folder repeats the URL, same as the docs and dashboard apps, because child apps don't use `basePath`:

```tsx title="apps/pricing/app/pricing/page.tsx"
import { Header } from "@acme/ui";

export default function NewPricingPage() {
  return (
    <div>
      <Header />
      <main className="p-8">
        <div className="rounded-lg bg-linear-to-r from-blue-500 to-purple-600 p-8 text-white">
          <h1 className="text-4xl font-bold">Pricing, but with a slider</h1>
          <p className="mt-4 text-lg">
            Drag to pick a plan. The growth team is very proud of this.
          </p>
        </div>
      </main>
    </div>
  );
}
```

### Part 2: Add the Flagged Path

Update `apps/marketing/microfrontends.json`. The new app lists `/pricing` once, with the flag name:

```json title="apps/marketing/microfrontends.json" {26-36}
{
  "$schema": "https://openapi.vercel.sh/microfrontends.json",
  "applications": {
    "acme-marketing": {
      "development": {
        "fallback": "acme-marketing.vercel.app",
        "local": 3000
      }
    },
    "acme-docs": {
      "routing": [
        { "paths": ["/docs", "/docs/:path*"] }
      ],
      "development": {
        "local": 3001
      }
    },
    "acme-dashboard": {
      "routing": [
        { "paths": ["/app", "/app/:path*", "/settings", "/settings/:path*"] }
      ],
      "development": {
        "local": 3002
      }
    },
    "acme-pricing": {
      "routing": [
        {
          "flag": "new-pricing",
          "paths": ["/pricing"]
        }
      ],
      "development": {
        "local": 3003
      }
    }
  },
  "options": {
    "localProxyPort": 3024
  }
}
```

Keep your real marketing domain in `fallback` if it isn't `acme-marketing.vercel.app`. Nothing changes for docs or dashboard.

### Part 3: Define the Flag

We'll use the [Flags SDK](https://flags-sdk.dev). Install it in the marketing app:

```bash
pnpm add flags --filter acme-marketing
```

Create `apps/marketing/flags.ts`. For now the flag reads a cookie, so we can flip it from the browser:

```typescript title="apps/marketing/flags.ts"
import { flag } from "flags/next";

export const newPricing = flag<boolean>({
  key: "new-pricing",
  description: "Route /pricing to the redesigned acme-pricing app",
  defaultValue: false,
  decide({ cookies }) {
    return cookies.get("new-pricing")?.value === "true";
  },
});
```

The string that has to match the `flag` field in `microfrontends.json` is the key in `flagValues`, which we write next. Giving the flag the same `key` keeps everyone's head straight.

### Part 4: Ask the Flag in `proxy.ts`

Lesson 3.2 gave marketing a `proxy.ts` with an empty `flagValues`. Fill it in, and add `/pricing` to the matcher:

```typescript title="apps/marketing/proxy.ts" {3,9,21}
import type { NextRequest } from "next/server";
import { runMicrofrontendsMiddleware } from "@vercel/microfrontends/next/middleware";
import { newPricing } from "./flags";

export async function proxy(request: NextRequest) {
  const response = await runMicrofrontendsMiddleware({
    request,
    flagValues: {
      "new-pricing": () => newPricing(request),
    },
  });

  if (response) {
    return response;
  }
}

export const config = {
  matcher: [
    "/.well-known/vercel/microfrontends/client-config",
    "/pricing",
  ],
};
```

When the flag is on, `runMicrofrontendsMiddleware` returns a response that hands the request to `acme-pricing`. When it's off, it returns nothing, the proxy falls through, and marketing renders its own `app/pricing/page.tsx`.

We pass `request` to `newPricing` because a flag called from `proxy.ts` needs the request to read cookies. The matcher needs both entries: `/pricing` so the proxy runs for the flagged path, and the client-config path so the browser can ask which app serves `/pricing` for this visitor.

## Try It

We'll test on a preview deployment, where the flag decision runs on Vercel the same way it will in production. Create an `acme-pricing` project with `apps/pricing` as its root directory, add it to the `acme-platform` group the same way you did in Lesson 2.5, then push:

```bash
git add -A
git commit -m "feat(pricing): route /pricing to acme-pricing behind a flag"
git push
```

**Flag off (marketing serves it):**

1. Visit your preview URL at `/pricing`
2. You should see the plain "Pricing" page from Lesson 1.4

**Flag on (acme-pricing serves it):**

1. Open the DevTools console on the preview URL
2. Set the cookie: `document.cookie = "new-pricing=true; path=/"`
3. Refresh `/pricing`
4. You should see "Pricing, but with a slider" on the gradient

With [debug headers](/academy/microfrontends-on-vercel/observability) on, the flag-on response includes:

```
x-vercel-mfe-app: acme-pricing
x-vercel-mfe-zone-from-middleware: acme-pricing
```

Delete the cookie and refresh. You're back on the old page, and nothing was redeployed.

## Test the Flagged Path

Lesson 3.2 listed `validateMiddlewareOnFlaggedPaths`, which checks that the proxy routes every flagged path to the right app. It only passes when every flag returns `true`, and ours reads a cookie the test doesn't set. Here's what happens if we call it as-is:

```
Error: middleware did not action for https://acme-marketing.vercel.app/pricing. Expected to route to acme-pricing
```

This is expected. The flag returned `false`, so the proxy did nothing. We'll mock `flags/next` so every flag says yes, and import the Next.js config so `withMicrofrontends` sets the environment the proxy reads at runtime.

Update `apps/marketing/__tests__/proxy.test.ts`:

```typescript title="apps/marketing/__tests__/proxy.test.ts" {4,6-12,23-27}
/* @jest-environment node */
import {
  validateMiddlewareConfig,
  validateMiddlewareOnFlaggedPaths,
} from "@vercel/microfrontends/next/testing";
import { config as proxyConfig, proxy } from "../proxy";
// withMicrofrontends sets the env vars the proxy reads at runtime
import "../next.config";

jest.mock("flags/next", () => ({
  flag: jest.fn().mockReturnValue(jest.fn().mockResolvedValue(true)),
}));

describe("proxy configuration", () => {
  const configPath = "./microfrontends.json";

  test("marketing proxy config is compatible with microfrontends", () => {
    expect(() =>
      validateMiddlewareConfig(proxyConfig, configPath)
    ).not.toThrow();
  });

  test("flagged paths route to the right app", async () => {
    await expect(
      validateMiddlewareOnFlaggedPaths(configPath, proxy)
    ).resolves.not.toThrow();
  });
});
```

Run it:

```bash
cd apps/marketing
pnpm test
```

```
PASS  __tests__/proxy.test.ts
  proxy configuration
    ✓ marketing proxy config is compatible with microfrontends (6 ms)
    ✓ flagged paths route to the right app (14 ms)
```

## Production Feature Flags

A cookie is fine for the growth team. For a real percentage rollout, swap the `decide` function for a flag provider. The `flag()` call, the proxy, and `microfrontends.json` stay the same. [Vercel Flags](https://vercel.com/docs/flags/vercel-flags) and other providers plug in through the Flags SDK's [adapters](https://flags-sdk.dev/docs/providers).

If you use the Flags SDK across the group, share the same `FLAGS_SECRET` environment variable with every microfrontend.

## Commit

```bash
git add -A
git commit -m "test(marketing): validate flagged routing for /pricing"
```

## Done-When

- [ ] `apps/pricing` exists with `name: "acme-pricing"` and a page at `app/pricing/page.tsx`
- [ ] `/pricing` appears once in `microfrontends.json`, under `acme-pricing`, with `"flag": "new-pricing"`
- [ ] `apps/marketing/proxy.ts` passes `new-pricing` in `flagValues`, and its matcher includes `/pricing` and the client-config path
- [ ] On the preview URL, `/pricing` shows the marketing page without the cookie and the new page with it
- [ ] Both tests in `__tests__/proxy.test.ts` pass

## Troubleshooting

**`/pricing` always shows the old page, even with the cookie**: The proxy didn't hand the request off. Check that the matcher includes `/pricing`, and that the key in `flagValues` is exactly `new-pricing`, the same string as the `flag` field.

**The `acme-pricing` build fails with `Could not find a microfrontends.json file`**: The project name and the key don't match. Name the Vercel project `acme-pricing`, or rename the key to the project name and add `"packageName": "acme-pricing"`.

**`validateMiddlewareConfig` says the matcher should match `/pricing`**: Flagged paths always need to be in the default app's matcher. Add it back.

## Rollout Strategy

| Phase            | Flag Value                                                         | Traffic              |
| ---------------- | ------------------------------------------------------------------ | -------------------- |
| Internal testing | Specific users                                                     | \~1%                 |
| Beta             | 10% rollout                                                        | 10%                  |
| Gradual          | 50% rollout                                                        | 50%                  |
| Full             | 100% rollout                                                       | 100%                 |
| Cleanup          | Remove `flag` from the path group, delete marketing's pricing page | 100% to acme-pricing |

## What's Next

The old pricing page lived in an app we control. What if the old version lives in a legacy system that isn't on Vercel at all? Next: the strangler fig pattern for incremental migration.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
