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
- Create the
acme-pricingapp with a page at/pricing - Add a
flagto its routing inmicrofrontends.json - Define the flag in marketing and pass it to
runMicrofrontendsMiddleware - 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:
{
"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:
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:
@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:
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:
{
"$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-marketingCreate apps/marketing/flags.ts. For now the flag reads a cookie, so we can flip it from the browser:
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:
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 pushFlag off (marketing serves it):
- Visit your preview URL at
/pricing - You should see the plain "Pricing" page from Lesson 1.4
Flag on (acme-pricing serves it):
- Open the DevTools console on the preview URL
- Set the cookie:
document.cookie = "new-pricing=true; path=/" - Refresh
/pricing - 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:
/* @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 testPASS __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/pricingexists withname: "acme-pricing"and a page atapp/pricing/page.tsx/pricingappears once inmicrofrontends.json, underacme-pricing, with"flag": "new-pricing"apps/marketing/proxy.tspassesnew-pricinginflagValues, and its matcher includes/pricingand the client-config path- On the preview URL,
/pricingshows the marketing page without the cookie and the new page with it - Both tests in
__tests__/proxy.test.tspass
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.
Was this helpful?