Article

Migrating a Legacy App from PHP to Next.js in 3 Weeks

Β·8 min read min readΒ·πŸ‘ 145
Dharmendra Singh Yadav

Dharmendra Singh Yadav

Founder, Dharmsy Innovations

Migrating a Legacy App from PHP to Next.js in 3 Weeks

Modern teams want faster delivery, better SEO, and a stack that scales. This case study shows how I migrated a production PHP app to Next.js (App Router) + Node.js in just three weeksβ€”without breaking URLs, SEO, or user sessions.

Project Goals

  1. Zero/near-zero downtime during cutover
  2. URL parity (keep existing slugs and query params) to preserve SEO
  3. Same or better performance (LCP/TTFB)
  4. Modern DX (TypeScript, testing, CI/CD)
  5. Gradual migration using a strangler-fig pattern (old + new side-by-side)

Architecture at a Glance

User ─▢ Nginx/ALB
β”œβ”€β–Ά Next.js 15 (App Router, RSC, Edge where useful)
β”‚ β”œβ”€β–Ά API routes (mutations via Server Actions)
β”‚ └─▢ Redis cache (hot reads)
└─▢ PHP legacy (only for routes not yet migrated)

DB: MySQL (via Prisma) Assets: S3 + CloudFront Observability: Sentry + Uptime + Logs

Key idea: route-by-route replacement. Nginx (or middleware) sends traffic to Next.js for migrated paths and to PHP for the rest.


Week-by-Week Plan (3 Weeks)

Week 1 β€” Discovery, Foundations, and Proxy

Day 1–2: Audit & map

  1. Inventory URLs, templates, forms, and API endpoints.
  2. Identify critical flows (login, checkout/contact, search, top landing pages).
  3. Export MySQL schema and sample data.
  4. Decide which URLs migrate first (80/20).

Day 3: Environment & repo

  1. Create monorepo (or separate repos): apps/web (Next.js), infra, and scripts.
  2. Set up TypeScript, ESLint, Prettier, Husky, and CI (GitHub Actions).
  3. Configure Prisma against existing MySQL:
npx prisma init
npx prisma db pull # introspect legacy schema
  1. Add Redis (ElastiCache or Docker) for hot caching.

Day 4: Routing & SEO parity

  1. Implement App Router structure mirroring legacy slugs:
/app
/[category]
/[slug]
/page.tsx
  1. Use generateMetadata for titles, OpenGraph, and canonicals:
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return { title: post.seoTitle, description: post.seoDesc, alternates: { canonical: `/${post.slug}` } };
}

Day 5: Strangler proxy

  1. Put Nginx in front; send migrated routes to Next.js, others to PHP:
server {
listen 80;
server_name example.com;

location ~* ^/(blog|products|about) {
proxy_pass http://nextjs:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}

# fallback to legacy
location / {
proxy_pass http://php_legacy:80;
}
}
  1. For Vercel/Edge, you can do it in middleware while PHP remains under /legacy:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(req: NextRequest) {
const url = new URL(req.url);
const migrated = /^\/(blog|products|about)/.test(url.pathname);
if (!migrated) {
url.hostname = process.env.LEGACY_HOST!; // e.g., legacy.example.com
return NextResponse.rewrite(url);
}
return NextResponse.next();
}

Deliverables end of Week 1:

Working Next.js shell with parity routing for the first set of pages, Nginx proxy in front, Prisma connected, Redis ready.

Week 2 β€” Feature Parity & Data Flows

Day 6–7: Data layer & caching

  1. Wrap legacy queries with Prisma services:
// lib/posts.ts
import { prisma } from "./prisma";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

export async function getPost(slug: string) {
const key = `post:${slug}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const post = await prisma.post.findUnique({ where: { slug } });
if (post) await redis.setex(key, 60, JSON.stringify(post));
return post;
}
  1. Use revalidate = 60 for ISR-style freshness where appropriate.

Day 8–9: Auth migration

  1. Legacy PHP session β†’ JWT session bridge:
  2. Create a compat endpoint on PHP that exchanges legacy PHPSESSID for a short-lived JWT.
  3. Next.js middleware verifies JWT for protected routes.
// app/api/auth/session/route.ts
import { NextResponse } from "next/server";
export async function GET(req: Request) {
// call legacy to exchange cookie -> jwt
// set httpOnly "session" cookie for Next
return NextResponse.json({ ok: true }, { headers: { "Set-Cookie": `session=JWT_HERE; HttpOnly; Path=/; Secure; SameSite=Lax` }});
}

Day 10: Forms & mutations

  1. Replace PHP form posts with Next.js Server Actions or API routes:
// app/contact/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
export async function submitContact(formData: FormData) {
await prisma.contact.create({ data: { name: formData.get("name") as string, email: formData.get("email") as string }});
}

Day 11: Media & assets

  1. Migrate /uploads to S3 + CloudFront.
  2. Write a script to copy and rewrite URLs in content:
node scripts/migrate-uploads.js # copies to S3 and updates DB references

Day 12–13: Critical flows

  1. Rebuild homepage, top landing pages, product/blog detail, and search.
  2. Snapshot test legacy vs new HTML where SEO matters.
  3. Add Playwright E2E for top 5 flows.

Deliverables end of Week 2:

Key pages & flows live on Next.js behind the same domain, auth bridged, forms functional, media served via CDN, tests passing.

Week 3 β€” Perf, SEO, Rollout & Cutover

Day 14–15: Performance

  1. Audit with Lighthouse and Web Vitals.
  2. Use next/image (remotePatterns) and proper sizes.
  3. Add HTTP caching for static/edge responses.
  4. Preload critical fonts; remove render-blocking assets.

Day 16: SEO & redirects

  1. Maintain 1:1 URL parity; add 301 for any changes:
// next.config.js
module.exports = {
async redirects() {
return [
{ source: "/old.php?id=:id", destination: "/products/:id", permanent: true },
];
},
};
  1. Add sitemap + robots via app routes:
/app/sitemap.ts
/app/robots.txt/route.ts

Day 17: Observability

  1. Sentry for errors, Uptime for monitoring, structured JSON logs.
  2. Add request-id headers in Nginx and propagate to logs.

Day 18–19: Staged rollout

  1. Blue-green: stand up Next.js as blue, keep PHP as green.
  2. Shift 10% traffic to blue via Nginx map or ALB weighted target groups.
  3. Watch errors/metrics, then 50%, then 100%.

Day 20–21: Decommission & cleanup

  1. Remove unused PHP routes; keep a /legacy admin-only path for a week.
  2. Final content resync; lock writes on legacy.
  3. Hand off docs & runbook.

Deliverables end of Week 3:

Next.js fully serves the site, SEO intact, improved performance, monitoring live; legacy safely retired.

Key Implementation Details

1) Data Access with Prisma (keeping legacy schema)

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
  1. Use db pull to respect existing tables.
  2. Avoid destructive migrations early; add new tables in a shadow schema if needed.

2) Re-using PHP logic safely

If an edge case is encoded in PHP, wrap it behind a small internal endpoint and call it from Next.js initially. Replace with TypeScript later.

3) Caching strategy

  1. Edge or route-level cache for read-heavy pages.
  2. Redis for DB results and expensive aggregations (TTL 60–300s).
  3. Invalidate keys on writes (Prisma middleware or service layer).

4) Accessibility & UX

  1. Server components β†’ minimal JS.
  2. Accessible forms, focus management, and skeleton/streaming for perceived speed.

Testing Matrix

  1. Unit: helpers, services (Vitest/Jest).
  2. Integration: API routes & Server Actions with test DB.
  3. E2E: Playwright for top paths & form submissions.
  4. Contract tests: ensure JSON shapes match any external consumers.

Deployment Options

  1. Vercel: simplest for Next.js; connect to existing DB/Redis; keep Nginx (or middleware) for legacy routing.
  2. EC2 + Nginx + PM2: control and proximity to legacy infra.
  3. Docker: reproducible builds; push to ECR; run via ASG.

Example GitHub Action (Vercel + Prisma deploy):

name: Deploy Web
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: npx prisma generate
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: apps/web

Risks & How I Mitigated Them

  1. SEO loss β†’ URL parity, canonical tags, 301s, sitemap parity.
  2. Auth mismatch β†’ JWT bridge; dual-write sessions until cutover.
  3. Hidden business rules in PHP β†’ temporary service wrapper; replace later.
  4. Timeline β†’ prioritize top 20% of traffic first; cut scope that doesn’t move KPIs.

Results (typical outcomes)

  1. TTFB down 30–50% on content pages
  2. LCP improved via server components + optimized images
  3. Deploy times from hours β†’ minutes via CI/CD
  4. Dev velocity up thanks to TypeScript, component reuse, and testing

(These improvements are representative of similar migrations; exact numbers vary by project.)

Migration Checklist (copy/paste)

  1. URL inventory & priority map
  2. Prisma introspection; Redis set up
  3. Nginx or middleware proxy for strangler pattern
  4. App Router routes & metadata parity
  5. Auth bridge (PHP session β†’ JWT)
  6. Top pages migrated; forms via Server Actions
  7. Assets to S3 + CDN; rewrite URLs
  8. Web Vitals targets met; tests passing
  9. Staged rollout (10% β†’ 50% β†’ 100%), monitors green
  10. Legacy decommission plan

Work with Dharmsy Innovations

Turn Your SaaS or App Idea Into a Real Product β€” Faster & Affordable

Dharmsy Innovations helps founders and businesses turn ideas into production-ready products β€” from MVP and prototypes to scalable platforms in web, mobile, and AI.

No sales pressure β€” just honest guidance on cost, timeline & tech stack.