Vercel Logo

Connecting Apps with Rewrites

You have two apps—apps/web for marketing and apps/blog for content. They run on different ports. Users see two domains. This lesson fixes that.

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 pointing /blog/* to http://localhost:3001/*
  2. Start both apps: pnpm dev in workspace root
  3. Visit http://localhost:3000/blog and verify blog content appears

Why Multi-Zone?

The Problem: Different teams own different parts of your site. Marketing owns the homepage, content team owns the blog. They need independent deploy schedules but users expect one seamless 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*',
      },
    ];
  },
};
 
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/)
  • destination points to the blog app running on port 3001

Why Two Rules?

The first rule handles the index route (/blog), the second handles all nested routes (/blog/post-slug). Without the first rule, visiting /blog wouldn't match.

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

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*`,
      },
    ];
  },
};
 
export default nextConfig;

Vercel Deployment

Each app deploys separately:

# Deploy blog app
cd apps/blog
vercel --prod
 
# Note the deployment URL: https://blog-abc123.vercel.app
 
# Deploy web app with BLOG_URL
cd apps/web
vercel --prod --env BLOG_URL=https://blog-abc123.vercel.app

Vercel Configuration (vercel.json):

For more control, add vercel.json to workspace root:

vercel.json
{
  "version": 2,
  "builds": [
    { "src": "apps/web/package.json", "use": "@vercel/next" },
    { "src": "apps/blog/package.json", "use": "@vercel/next" }
  ],
  "routes": [
    { "src": "/blog(.*)", "dest": "apps/blog" },
    { "src": "/(.*)", "dest": "apps/web" }
  ]
}

Zero-Config Alternative:

Vercel auto-detects monorepos. Deploy each app from its directory, then use environment variables to connect them. No vercel.json required for simple setups.

Commit

git add apps/web/next.config.ts
git commit -m "feat(routing): add rewrites for multi-zone blog integration
 
Configure Next.js rewrites to proxy /blog/* requests from apps/web to
apps/blog, enabling independent deployment with unified user experience."

Done-When

  • Visiting http://localhost:3000/blog shows blog app content
  • Browser URL stays localhost:3000/blog (doesn't redirect to 3001)
  • Both apps can be developed and deployed independently
  • Rewrite configuration uses environment variable for production URL

Troubleshooting

404 on /blog route

Problem: Visiting localhost:3000/blog returns 404.

Cause: Rewrite rule not loaded or blog app not running.

Fix:

  1. Restart apps/web dev server to reload config
  2. Verify blog app is running on port 3001: lsof -i :3001
  3. Check rewrite syntax matches exactly (common: missing async)
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: Relative asset paths don't resolve correctly through the proxy.

Fix: Ensure both apps use absolute asset paths or configure assetPrefix in next.config.ts:

const nextConfig: NextConfig = {
  assetPrefix: process.env.ASSET_PREFIX || '',
  // ... rewrites
};
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*`,
      },
    ];
  },
};
 
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
  • Production: deploy both apps, configure BLOG_URL, verify

Learn More

What's Next

You've connected two apps into one seamless experience. Next: final certification readiness review.