TL;DR
We were hired to build a new Next.js storefront and admin dashboard. On launch day, flipping the DNS to Vercel broke a critical webview application relying on legacy /app/* routes. We had to revert immediately. The legacy PHP app was built by another vendor, and we were mandated to modernize the frontend without touching the legacy codebase.
Here's how we achieved it: we moved the Next.js app to Vercel, kept the legacy PHP app on a subdomain at its original VPS, and introduced a host-aware admin subdomain—all with one DNS change, zero downtime, and full backward compatibility for thousands of deep links.
The Challenge: A Launch Day Rollback
The client came to us with a clear goal: modernize their public-facing storefront and build a sleek, secure admin dashboard. We delivered a blazing-fast Next.js 16 (App Router) application, ready to replace their aging, self-hosted setup.
On launch day, we flipped the switch: we updated the root A record to point to Vercel.
Within minutes, our Slack channel blew up. The migration had inadvertently broken a critical webview application embedded in their distributors' mobile apps. Thousands of bookmarked URLs, printed QR codes, and active distributor sessions pointing to /app/* were suddenly hitting 404s or redirect loops.
We immediately reverted the DNS to the old VPS IP to stop the bleeding. The site was stable again, but the migration was stalled.
We were left with a brutal set of constraints:
- The Black Box: The legacy PHP application was built and deployed by a completely different vendor. We had no access to their source code, no desire to untangle their PHP, and a strict mandate: do not touch the legacy application.
- No Magic Proxies: DNS was managed on a standard provider. No Cloudflare Worker or proxy tricks were allowed.
- The Subdomain Dilemma: The new
/admindashboard needed to live atadmin.yoursite.comfor clean ops, butyoursite.com/adminhad to redirect to the PHP dashboard for backward compatibility. - Zero Downtime: We only had one shot at the DNS flip.
Naive A @ → Vercel would break /app/* again. Naive next.config.ts blanket redirects would create infinite redirect loops or kill the new admin subdomain. We needed a surgical solution.
Architecture (Before → After)
Before (Failed Launch):
yoursite.com → VPS (Next.js, broke /app/*)
app.yoursite.com → LEGACY_VPS_IP (PHP, untouched)
After (Surgical Fix):
yoursite.com → VERCEL_IP (Vercel, Next.js)
www.yoursite.com → CNAME → yoursite.com → Vercel
admin.yoursite.com → CNAME → your-project.vercel-dns.com → Vercel (same deployment, host-aware)
app.yoursite.com → LEGACY_VPS_IP (PHP, untouched, webview restored)
Note: A single Vercel deployment serves both yoursite.com and admin.yoursite.com. Routing is handled dynamically via the Host header.
Step 1: Make /app Redirects Deterministic
To protect the webview and legacy links, we had to guarantee that any traffic hitting the new Vercel domain for /app would seamlessly bounce to the PHP server.
In next.config.ts:
async redirects() {
return [
{ source: "/app", destination: "https://app.yoursite.com/app/login.html", permanent: false },
{ source: "/app/:path*", destination: "https://app.yoursite.com/app/login.html", permanent: false },
];
}
Why login.html and not just /app/?
The PHP directory index returned a loader/parked page. Pointing directly to /app/login.html ensures the real <title>Login</title> renders immediately with cookie-based session validation. This fixed the webview breakage:
curl -I https://yoursite.com/app
# 307 → https://app.yoursite.com/app/login.html (Vercel)
curl -sL https://yoursite.com/app | head
# <!DOCTYPE html><title>Login</title>
Verified with curl -I on both yoursite.com and admin.yoursite.com — same 307. The webview was safe.
Step 2: Host-Aware /admin Handling (The Tricky Part)
A blanket next.config.ts redirect for /admin → PHP would also capture admin.yoursite.com/admin, killing our new Next.js dashboard. Instead, we implemented host-aware logic in our Next.js middleware (src/middleware.ts):
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const host = request.headers.get('host')?.split(':')[0] ?? '';
const isAdminHost = host === 'admin.yoursite.com';
// 1. If it's the main domain, send /admin to the legacy PHP app
if (!isAdminHost && pathname.startsWith('/admin')) {
return NextResponse.redirect('https://app.yoursite.com/app/admin_dashboard.php');
}
// 2. If it's the admin host, rewrite root/paths to /admin, but guard assets
let effectivePath = pathname;
let needsRewrite = false;
if (isAdminHost) {
const isAsset = /\.(png|jpg|svg|ico|woff2?|json)$/i.test(pathname);
if (!pathname.startsWith('/admin') && !isAsset && !pathname.startsWith('/_next')) {
effectivePath = pathname === '/' ? '/admin' : `/admin${pathname}`;
needsRewrite = true;
}
}
// 3. Auth check on effectivePath before rewrite
if (effectivePath.startsWith('/admin') && !effectivePath.startsWith('/admin/login')) {
const session = request.cookies.get('admin_session')?.value;
if (!session) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
const secretKey = process.env.JWT_SECRET;
if (!secretKey || secretKey.length < 32) {
throw new Error('JWT_SECRET missing or too short');
}
const key = new TextEncoder().encode(secretKey);
await jwtVerify(session, key, { algorithms: ['HS256'] });
if (needsRewrite) {
return NextResponse.rewrite(new URL(effectivePath, request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*', '/((?!_next|api).*)'],
};
The Matcher Pitfall We Discovered in Review:
Our initial middleware matcher excluded dot-files to "skip assets". This created a catastrophic security bypass: /admin/backup.sql would skip the middleware entirely, bypassing auth.
// BEFORE: Excluded any dot-file → /admin/backup.sql bypassed auth ❌
matcher: ['/((?!_next|api|.*\\.).*)']
// AFTER: Always runs for /admin, skips _next/api otherwise ✅
matcher: ['/admin/:path*', '/((?!_next|api).*)']
We also added the explicit isAsset guard so admin.yoursite.com/manifest.json and favicon.ico aren't incorrectly rewritten to /admin/manifest.json.
Step 3: Hardening JWT (Fail-Closed Security)
During the code review for the middleware, we spotted a silent vulnerability:
const secretKey = process.env.JWT_SECRET;
if (!secretKey || secretKey.length < 32) throw new Error('JWT_SECRET missing or too short');
const key = new TextEncoder().encode(secretKey);
Without this guard, encode(undefined) results in the literal string "undefined" (12 bytes) being used as the key. This makes HS256 sessions trivially forgeable. We enforced this fail-closed check at import time in both src/middleware.ts and src/lib/auth/jwt.ts.
Step 4: DNS — One Record, Zero Downtime
We prepared for the second launch attempt.
Pre-change snapshot:
dig yoursite.com → old VPS IP
dig admin.yoursite.com → your-project.vercel-dns.com
dig app.yoursite.com → LEGACY_VPS_IP
The Change:
- Vercel → Add Domain:
yoursite.com,www.yoursite.com, andadmin.yoursite.comto the same project. (Shows "Invalid Configuration" pre-DNS, queues SSL). - DNS Provider →
A @→VERCEL_IP(TTL 300) - DNS Provider →
CNAME www→cname.vercel-dns.com - Leave
app.yoursite.comA→LEGACY_VPS_IPcompletely untouched.
Post-change verification:
dig yoursite.com → VERCEL_IP ✅
dig www.yoursite.com → yoursite.com → VERCEL_IP ✅
dig admin.yoursite.com → vercel-dns → VERCEL_IP ✅
dig app.yoursite.com → LEGACY_VPS_IP ✅ (Legacy webview intact)
The 300-second TTL was our safety net, allowing a 5-minute rollback to the old IP if anything went wrong.
Verification Checklist (Prod)
curl -I https://yoursite.com/app → 307 → https://app.yoursite.com/app/login.html
curl -I https://admin.yoursite.com/app → same 307
curl -I https://yoursite.com/admin → 307 → https://app.yoursite.com/app/admin_dashboard.php (main domain only)
curl -I https://admin.yoursite.com/ → 200 (rewritten to /admin, auth required → /admin/login if no cookie)
curl -I https://yoursite.com/app/ → 308 → /app → 307 (normalize hop, acceptable)
Manifest/asset check after proxy fix: admin.yoursite.com/manifest.json → 200 (not 404). The webview and new dashboard were both fully operational.
Lessons Learned
- Redirects are code, not DNS.
/appcompatibility was preserved bynext.config.ts+middleware.ts, not by keeping the root DNS on the old host. - One deployment, host-aware proxy > two deployments. Routing by
Hostheader beats maintaining two separate Vercel projects (2x cost) when only a path prefix differs.admin.yoursite.comis a rewrite, not a redeploy. - Middleware matchers are a security boundary. Excluding
.*\.to "skip assets" skipped auth entirely. Explicit'/admin/:path*'is vastly safer. - Fail-closed JWT.
undefinedas a key is silent and catastrophic. Validate environment variables at import time. - Snapshot your IPs. Keep your Vercel and legacy VPS IPs noted in your runbook. Rollback is
dig-verifiable.
Stack
Next.js 16.2.4 (App Router, middleware.ts), Vercel, Standard DNS Provider, Legacy PHP, jose HS256, single deployment with host-based routing.