pizzaPower

Moving a 600-URL WordPress Site off EC2 to Next.js on Vercel

9 min read

I'm a cofounder of Brackish. I also run templatesearch.io, brickreport.io, techreport.io, and this blog — all Next.js on Vercel. We recently moved the company site off WordPress and onto the same stack. The old site was a single EC2 instance running WordPress behind Cloudflare's proxy, with Elementor for page building, Fluent Forms for the contact form, Wordfence standing guard, and a purchased theme underneath all of it. About 110 blog posts, a handful of service pages, some landing pages, and roughly 600 URLs that the internet already knew about.

This is the general shape of how we did it and the things that bit us. It is not a tutorial - every WordPress-to-Next.js migration has its own issues to deal with - but the failure modes below are common enough that they are worth writing down.

The Brackish Security homepage after migrating off WordPress to Next.js on Vercel

Why leave WordPress on EC2 (or not)

WordPress is fine, but is it? Plenty of sites should stay on it. Ours had grown into the other case: a brochure-and-blog carrying a page builder, a forms plugin, a firewall plugin, a purchased theme, and the OS under all of it. At that scale, with that many plugins, it gets bloated. Every update is a layout lottery, and the patch cadence is a standing obligation on an instance somebody also has to keep patched at the OS level.

The other reason is the last couple of years. AI-assisted research has dumped a large spate of vulnerabilities across WordPress core and the plugin ecosystem. We sell offensive security work - sitting on that surface for a marketing site is a second job we did not want.

Page weight and Elementor sprawl were real. They were secondary.

I predict we see WordPress marketshare loss over the next few years as other people realize migration isn't TOO bad.

The Next.js stack we landed on

  • Next.js 16 App Router, TypeScript in strict mode, Node 24, pnpm.
  • Everything prerendered. 269 static pages, no SSR, no ISR.
  • Content is MDX in git, with a repository interface between the app and the filesystem so route code does not care who is allowed to publish. Blog posting is gated by a per-user permissioning system on Brackish Security's operations portal.
  • Images live on S3 behind CloudFront, optimized at build time with sharp, with images.unoptimized: true so Vercel never transforms one. Binaries never enter git; a committed manifest maps logical filenames to CDN URLs.
  • One runtime endpoint. POST /api/contact/ fires Microsoft Teams and Discord webhooks. No mail provider, no database, no queue.
  • Search is Pagefind, built as a post-build step against the prerendered output.

The total runtime surface is one POST handler. That was the goal.

Most of the implementation was done in Cursor, with Grok 4.6 and Opus 5 writing the bulk of the code. We still owned the URL inventory, the redirect contract, and the launch gates — the parts that fail silently if a model gets them slightly wrong.

How we migrated 600 URLs

1. Inventory before code

Before writing a line of the new site we built a single text file listing every URL the old site served, assembled from the WordPress sitemap, Search Console, analytics, and a crawl. It came to 608 URLs. That file became the contract: every URL in it must return either a 200 or a single-hop 301 on launch day, and a script verifies exactly that against a live deployment. Everything downstream — the redirect map, the disposition report, the launch gate — is derived from it.

2. Export and extract

WordPress gives you a WXR file (ours was 3.9 MB of XML). We wrote a one-off extractor to pull prose out of it and land 110 posts as MDX. Content built in a page builder does not export as clean prose; expect to spend real time on the extraction step and to hand-fix the output.

3. Put the old URLs in the content

Each MDX file carries a legacyUrls array in its frontmatter listing the WordPress URLs that were its own. This turned out to be the single best decision in the migration — see below.

4. Generate the redirect map

A script walks the content tree, reads every legacyUrls entry, adds the archive and taxonomy rules that have no content record to hang off, and writes a JSON map that next.config.ts reads. Around 637 rules. It also writes a markdown report accounting for the disposition of every URL in the inventory, which is what you hand to whoever asks whether the migration was done properly.

5. Pull the images out

A pipeline fetches the originals from /wp-content/uploads/, runs them through sharp into responsive WebP with content-addressed filenames, uploads to S3, and commits a manifest. Because the filenames are content-addressed, CloudFront never needs an invalidation.

6. Build, gate, cut over

Typecheck, lint, unit tests, Playwright against a production build, and the redirect verifier all run in CI. DNS moved last.

The gotchas

These are the ones that cost us time, roughly in order of how much I'd want to warn someone about them.

output: 'export' will silently ruin your migration

If your site is fully prerendered, static export looks like the obvious choice. It is not. Static export drops redirects(), headers(), and route handlers — with no build error. You get a site that builds green and quietly serves no redirect map, no security headers, and a 404 on your contact endpoint. Do not use it if you need any of those three.

permanent: true emits a 308, not a 301

Next prefers 307/308 because they preserve the request method. Google treats 301 and 308 the same, so this is not really a crawler problem — but every SEO audit tool and every migration report a client reads looks for 301. Set statusCode: 301 explicitly.

Match the old trailing-slash convention

WordPress canonicalizes with a trailing slash. Setting trailingSlash: true avoided 130+ gratuitous redirects on launch day. The consequence is that API paths must now be called with the slash — POST /api/contact/ — or Next issues a 308 and some clients drop the request body on the hop. That one is fun to debug.

Do not use wildcard redirect patterns

A rule like /:slug(.*-penetration-testing)/ would have swept up two landing pages that were live, ranking, and needed to keep returning 200 — and 301'd them into a 404. Enumerate your rules. It is more typing and it is correct.

Let junk 404

WordPress creates a page for every single upload. We had 67 orphaned attachment pages and theme demo assets with no equivalent on the new site. The tempting move is to redirect them to the homepage. Don't — that is a soft-404 signal, and Google reads it as one. Letting them 404 is the honest answer, and they drop out of the index on their own. We pinned the list to a committed file so that any change to it shows up as a reviewable diff rather than disappearing into a catch-all.

Every input to a generator must be committed

The WXR export is gitignored — it is migration source material, not build input. That means any generator reaching for it produces one result on a developer's laptop and a different one in CI. We hit this once and the fix was to derive everything from committed files. Relatedly: a stale generated map is invisible, because the build succeeds and one old URL 404s. A unit test rebuilds the map in memory and fails if the committed copy differs.

Query-string URLs can't be matched on pathname

Next matches on pathname, so the 20 Elementor library URLs with query strings each need a has condition. We deferred all of them and documented why.

Two CDNs in front of each other fight

Cloudflare's proxy in front of Vercel means two caching layers arguing over headers. Every Vercel and CloudFront record is DNS-only (grey cloud). Cloudflare still holds DNS.

A CSP nonce cannot work with prerendered HTML

The nonce has to appear in both the response header and every <script> tag, but the HTML is written at build time and cannot carry a per-request value. Our first attempt blocked every script on the page while the page still rendered — a completely silent failure. We ship a static policy instead and run a stricter Report-Only policy alongside it to measure what tightening would cost.

Pagefind needs 'wasm-unsafe-eval'

Not 'unsafe-eval' — they are different, and JS eval stays forbidden. Without it search initializes fine and then returns zero results for every query. Also: token-parse CSP directives in your tests, because a substring check for unsafe-eval matches wasm-unsafe-eval and fails for the wrong reason.

Your preview hostname is a public copy of your site

Vercel's <project>.vercel.app alias permanently resolves to the current production deployment, so the whole site answers on a second indexable hostname. robots.txt is prerendered and cannot vary by host. A host-scoped X-Robots-Tag: noindex header solves it at the CDN with no proxy involved.

Test against a production build, on a dedicated port

Playwright's reuseExistingServer will happily attach to a leftover dev server on port 3000 and assert dev-mode security headers, passing the whole suite for nothing. Ours runs on 3100 against next build && next start.

Watch the DNS records you are not changing

During cutover, the only edits were the apex and www. The SPF, DMARC, DKIM, domain-verification, CDN, and ACM certificate-validation records all stayed exactly as they were. That last one matters more than it looks: delete the ACM validation CNAME after the cert is issued and image hosting dies roughly a year later when auto-renew fails.

Cutover

Add the domains in Vercel first, then change DNS — not the other way around. Lower TTLs ahead of time if they aren't already low. Then verify on the real domain, not the preview: robots.txt isn't disallowing anything, no noindex leaked onto production pages, www 301s to the apex, and a curl -I sweep of 20 legacy URLs each showing one 301 to a 200. Submit the new sitemap and leave the old one submitted for about a month.

Keep the old site running for 30 days. WordPress stayed up, untouched, as the DNS rollback target. Two rollback paths: promote the last good Vercel deployment (under a minute, no DNS involved) for a bad build, or point DNS back at WordPress for anything fundamental. After 30 days there is no rollback and you fix forward — so decommission deliberately, with a final backup of files and database archived off the host.

The other post-launch discipline is not panicking. If organic traffic dips, check robots/noindex first, then redirect chains, then canonicals, then whether the sitemap actually got submitted — and only then consider that it might be a normal post-migration reshuffle. Google typically takes two to six weeks to settle. Nothing you do on day three is based on real information.

After that window, SEO is probably better than it was on WordPress. Pages are lighter, the 301 map is explicit, and we stopped feeding Google sixty-seven attachment URLs. Wait the two to six weeks before saying so.

What I'd keep

Putting the old URLs in the content frontmatter rather than in a redirects file is the thing I would do again without hesitation. A post knows which URLs used to be its own. When somebody renames a slug, the redirect follows the content instead of silently pointing at a page that no longer exists, and the map is regenerated from the tree rather than maintained by hand.

The other keeper is that every claim about the migration is executable. "All 608 URLs resolve in one hop" is not an assertion in a status document, it is a script that hits a live deployment and exits non-zero. Migrations accumulate claims nobody has checked in weeks. Make the gates run.

← all posts