Vercel Logo
This Course Is in Beta

You're getting early access to this course as it's being refined. Have feedback? Please share it in the widget at the bottom of each lesson.

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.