Vercel Logo

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:

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

That doesn't work. Paths must map to exactly one microfrontend, 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

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:

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.4.3",
    "react": "^19.3.1",
    "react-dom": "^19.3.1",
    "@acme/ui": "workspace:*",
    "@vercel/microfrontends": "^2.4.2"
  },
  "devDependencies": {
    "@types/node": "^26.6.5",
    "@types/react": "^19.3.0",
    "@types/react-dom": "^19.3.0",
    "eslint": "^9",
    "eslint-config-next": "16.4.3",
    "typescript": "^6.0.3",
    "@tailwindcss/postcss": "^4.3.3",
    "tailwindcss": "^4.3.3"
  }
}

Create apps/pricing/next.config.ts:

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:

apps/pricing/app/globals.css
@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:

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:

apps/marketing/microfrontends.json
{
  "$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. Install it in the marketing app:

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:

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:

apps/marketing/proxy.ts
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:

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 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:

apps/marketing/__tests__/proxy.test.ts
/* @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:

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 and other providers plug in through the Flags SDK's adapters.

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

Commit

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

PhaseFlag ValueTraffic
Internal testingSpecific users~1%
Beta10% rollout10%
Gradual50% rollout50%
Full100% rollout100%
CleanupRemove flag from the path group, delete marketing's pricing page100% 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.

Was this helpful?

supported.