Vercel Logo

You have two apps: apps/web for marketing and apps/blog for content. They run on different ports, so users see two domains. This lesson puts both behind one URL.

Outcome

Configure Next.js rewrites so navigating to localhost:3000/blog shows content from apps/blog while keeping the unified domain experience.

Fast Track

For experienced developers:

  1. Add rewrites() to apps/web/next.config.ts that send /blog, /blog/:path*, and /blog-static/:path+ to http://localhost:3001
  2. Set basePath: '/blog' and assetPrefix: '/blog-static' in apps/blog/next.config.ts
  3. Start both apps: pnpm dev in workspace root
  4. Visit http://localhost:3000/blog and verify blog content appears with its styles

Why Multi-Zone?

The Problem: Different teams own different parts of your site. Marketing owns the homepage while the content team owns the blog. They need independent deploy schedules under one site.

Traditional Approach: Monolith where all code lives in one app. Every deploy risks breaking everything. Teams block each other.

Multi-Zone Pattern: Each app deploys independently. Next.js rewrites route traffic between them. User sees one domain, your teams move independently.

User visits example.com/blog
  ↓
apps/web receives request
  ↓
Rewrite rule matches /blog/*
  ↓
Proxies to apps/blog
  ↓
User sees blog content at example.com/blog

How Rewrites Work

Rewrites are transparent proxies. They:

  • Match a URL pattern (source)
  • Forward the request to a different URL (destination)
  • Keep the original URL in the browser
  • Work for both local development and production

Key Difference from Redirects:

  • Redirect: Browser URL changes, new HTTP request
  • Rewrite: Browser URL stays same, Next.js forwards internally

Configure Rewrites

Add rewrites to the primary app (apps/web) that will route to the blog app.

apps/web/next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/blog',
        destination: 'http://localhost:3001/blog',
      },
      {
        source: '/blog/:path*',
        destination: 'http://localhost:3001/blog/:path*',
      },
      {
        source: '/blog-static/:path+',
        destination: 'http://localhost:3001/blog-static/:path+',
      },
    ];
  },
};
 
export default nextConfig;

What this does:

  • source: '/blog' matches the exact route /blog
  • source: '/blog/:path*' matches /blog/anything/nested
  • :path* is a catch-all parameter (captures everything after /blog/)
  • source: '/blog-static/:path+' forwards the blog's JavaScript and CSS (we set that prefix in the blog app next)
  • destination points to the blog app running on port 3001

Why Two Rules for Pages?

The first rule spells out the index route (/blog), and the second handles nested routes (/blog/post-slug). Strictly speaking, the second rule covers /blog too, because * matches zero or more segments (rewrites docs). We keep both so the index route is easy to spot when you scan the config.

Move the Blog Under /blog

Those rewrites forward /blog to localhost:3001/blog. Try that URL directly and the blog app gives you a 404. Since Dynamic Routing, the blog has served its index at / and its posts at /[slug]. So /blog lands on the post route, which goes looking for a post with the slug blog and doesn't find one.

We could rename every folder in apps/blog, but basePath does it in one line. It prefixes every route in the app:

apps/blog/next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  basePath: '/blog',
};
 
export default nextConfig;

Restart apps/blog and the index moves to localhost:3001/blog, with posts at localhost:3001/blog/[slug]. The blog's <Link href={`/${post.slug}`}> links keep working without edits, because next/link adds the base path for us.

Give the Blog Its Own Assets

Run the rewrites now and localhost:3000/blog shows up looking like it was printed on a fax machine. Open the Network tab and you'll see why. The blog's HTML asks for its JavaScript and CSS at /_next/static/..., and that path belongs to apps/web, which has never heard of the blog's files.

The multi-zones guide fixes this with an assetPrefix on each zone that isn't the default app. Add one to the blog, next to its basePath:

apps/blog/next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  basePath: '/blog',
  assetPrefix: '/blog-static',
};
 
export default nextConfig;

Now the blog serves its files from /blog-static/_next/.... Nothing in apps/web lives at that path, so the third rewrite forwards those requests to the blog app. apps/web is the default zone, so it keeps serving its own assets from /_next/ with no prefix.

Try It

1. Start Both Apps

From your workspace root:

pnpm dev

This starts:

  • apps/web on http://localhost:3000
  • apps/blog on http://localhost:3001

2. Test Direct Access

Visit http://localhost:3001/blog directly. You should see the blog app's content. This confirms the blog app works independently.

Expected Output:

Blog posts list or blog homepage from apps/blog

3. Test Rewrite

Visit http://localhost:3000/blog (note the port 3000). You should see the same blog content but routed through the web app.

Expected Output:

Same blog content, but URL stays localhost:3000/blog
Network tab shows request to localhost:3000/blog
Blog app serves the content

What's Happening:

  1. Browser requests localhost:3000/blog
  2. apps/web receives request
  3. Rewrite rule matches /blog
  4. Next.js proxies request to localhost:3001/blog
  5. apps/blog renders and returns content
  6. User sees content at localhost:3000/blog

4. Check the Blog's Assets

With localhost:3000/blog open, filter the Network tab by CSS and reload. The blog's stylesheet loads through the web app's port with the blog prefix:

localhost:3000/blog-static/_next/static/chunks/...css    200

If you see /_next/static/... with no prefix, restart apps/blog so it picks up the new assetPrefix.

Production Deployment

In local development, rewrites use localhost:3001. In production on Vercel, you configure the actual domains.

Update Config for Production

apps/web/next.config.ts
const blogUrl = process.env.BLOG_URL || 'http://localhost:3001';
 
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/blog',
        destination: `${blogUrl}/blog`,
      },
      {
        source: '/blog/:path*',
        destination: `${blogUrl}/blog/:path*`,
      },
      {
        source: '/blog-static/:path+',
        destination: `${blogUrl}/blog-static/:path+`,
      },
    ];
  },
};
 
export default nextConfig;

Vercel Deployment

Create one Vercel project for apps/blog and another for apps/web. In each project's Build and Deployment settings, set the Root Directory to the corresponding app. Git pushes then create preview deployments and updates to the production branch create production deployments.

After the blog project has a stable production domain, add BLOG_URL to the web project's Production, Preview, and Development environments in Project Settings. Redeploy the web project so its rewrite uses the new value. Do not pass environment values on an ad hoc deployment command: project-scoped values are repeatable across Git and CLI deployments.

The legacy vercel.json combination of version: 2, builds, and catch-all routes is unnecessary for framework monorepos and can disable Vercel's framework-aware defaults. Keep routing in next.config.ts and project selection in each Vercel project's Root Directory setting.

Commit

git add apps/web/next.config.ts apps/blog/next.config.ts
git commit -m "feat(routing): add rewrites for multi-zone blog integration
 
Serve apps/blog under a /blog basePath and configure Next.js rewrites to
proxy /blog/* and the blog's /blog-static assets from apps/web to
apps/blog, enabling independent deployment with unified user experience."

Done-When

  • Visiting http://localhost:3001/blog shows the blog index, and its post links go to /blog/[slug]
  • Visiting http://localhost:3000/blog shows blog app content
  • Browser URL stays localhost:3000/blog (doesn't redirect to 3001)
  • The Network tab on localhost:3000/blog shows the blog's CSS loading from /blog-static/_next/... with status 200
  • Both apps can be developed and deployed independently
  • Rewrite configuration uses environment variable for production URL
  • Each Vercel project has the correct Root Directory and environment scopes
  • A preview deployment verifies /blog and one nested /blog/* route before production promotion

Troubleshooting

404 on /blog route

Problem: Visiting localhost:3000/blog returns 404.

Cause: Rewrite rule not loaded, blog app not running, or the blog app has no basePath.

Fix:

  1. Restart apps/web dev server to reload config
  2. Verify blog app is running on port 3001: lsof -i :3001
  3. Visit localhost:3001/blog directly. A 404 there means apps/blog/next.config.ts is missing basePath: '/blog' (or the blog dev server needs a restart to pick it up)
  4. Check rewrite syntax matches exactly (common: a typo in source or destination)
Blog app shows on wrong port

Problem: localhost:3000/blog shows content but from port 3000, not 3001.

Cause: Both apps have a /blog route. The web app's route shadows the rewrite.

Fix: Remove /blog routes from apps/web/app directory. Only apps/blog should have blog routes.

Styles broken on rewritten route

Problem: Content appears but CSS is missing.

Cause: The blog's JavaScript and CSS requests are landing on apps/web. That happens when apps/blog has no assetPrefix, or when apps/web has no rewrite for that prefix. The blog asks for /_next/static/..., and the web app doesn't have those files.

Fix: Give the blog its prefix in apps/blog/next.config.ts:

const nextConfig: NextConfig = {
  assetPrefix: '/blog-static',
};

Then confirm apps/web/next.config.ts has the /blog-static/:path+ rewrite and restart both dev servers. Only the blog gets a prefix. apps/web is the default zone and keeps serving from /_next/.

CORS errors in browser console

Problem: Console shows CORS errors when accessing /blog.

Cause: API requests from blog app trying to hit different origin.

Fix: Add CORS headers in apps/blog/next.config.ts:

async headers() {
  return [
    {
      source: '/api/:path*',
      headers: [
        { key: 'Access-Control-Allow-Origin', value: '*' },
        { key: 'Access-Control-Allow-Methods', value: 'GET,POST,OPTIONS' },
      ],
    },
  ];
}

Solution

Complete next.config.ts with production support
apps/web/next.config.ts
import type { NextConfig } from 'next';
 
// Use environment variable in production, localhost in development
const blogUrl = process.env.BLOG_URL || 'http://localhost:3001';
 
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/blog',
        destination: `${blogUrl}/blog`,
      },
      {
        source: '/blog/:path*',
        destination: `${blogUrl}/blog/:path*`,
      },
      {
        source: '/blog-static/:path+',
        destination: `${blogUrl}/blog-static/:path+`,
      },
    ];
  },
};
 
export default nextConfig;
apps/blog/next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  // Serves every blog route under /blog, matching the rewrites in apps/web
  basePath: '/blog',
  // Keeps the blog's /_next assets from colliding with apps/web
  assetPrefix: '/blog-static',
};
 
export default nextConfig;

Environment Variable Setup:

.env.local (for local override)
# Optional: point to deployed blog in local dev
BLOG_URL=https://your-blog.vercel.app

Vercel Dashboard Setup:

  1. Go to Project Settings → Environment Variables
  2. Add BLOG_URL with value: https://your-blog.vercel.app
  3. Redeploy apps/web

Testing Checklist:

  • Local dev: both apps run, rewrites work
  • Direct access: localhost:3001/blog works
  • Proxied access: localhost:3000/blog works, styles included
  • Production: deploy both apps, configure BLOG_URL, verify

Learn More

What's Next

Both apps now share one URL while keeping independent deployments. Next: final certification readiness review.

Was this helpful?

supported.