Twenty eight active plugins is not twenty eight problems. Most of them stop existing.
This is the question that stalls migrations. Somebody opens the plugins screen, counts thirty one active rows, and decides the project is impossible before anyone has looked at what those rows are doing.
They are not doing thirty one things. In every audit I have run, the list breaks into four piles, one of them enormous, and only the smallest pile costs real money. Sorting the list takes about twenty minutes and it is the single most useful thing you can do before asking for a quote.
Sort the list before you price it
One command gets you the list in a form somebody can actually quote against. Run it over SSH, or use the plugin export in your hosting panel if WP-CLI is not available.
# The list itself, which is what a developer needs to scope the workwp plugin list --status=active --fields=name,title,version # Anything that registered its own tables usually owns data you have to movewp db query "SHOW TABLES" --skip-column-names | grep -v '^wp_\(post\|term\|comment\|option\|user\|link\)' # Custom post types, which tells you which plugins shaped your content modelwp post-type list --fields=name,label,publicThe second command is the one people skip and the one that matters. A plugin that only reads and renders is cheap to walk away from. A plugin that created its own tables owns data, and that data has to go somewhere before you can turn it off.
The four piles
Every list I have sorted lands in these four, in roughly this proportion.
The first pile is the one nobody believes until they see it. Something close to half of a normal plugin list exists to make PHP run less often, to compress something WordPress should have compressed, or to undo damage another plugin did. None of those jobs survive the move, because none of them exist on a site that was rendered before the visitor arrived.
What replaces what
Three tables, one per pile that matters. The fourth pile gets its own section further down, because it deserves more than a row.
Pile one: gone entirely
| What you are running | What it was doing | After the move |
|---|---|---|
| WP Rocket, W3 Total Cache, LiteSpeed Cache | Making PHP run less often by storing the output | Nothing. A pre-rendered route has no PHP to skip. |
| Smush, ShortPixel, Imagify | Resizing and compressing uploads after the fact | The image component. Sizes and modern formats are produced on demand. |
| Autoptimize, Asset CleanUp, Perfmatters | Merging, deferring and unloading CSS and JavaScript | The bundler. Code is split per route by default and unused code never ships. |
| Yoast sitemap, XML Sitemap Generator | Listing URLs for crawlers | app/sitemap.ts, generated from the same data the pages render from. |
| Redirection | Storing 301s in a database table | redirects() in the config for the static map, proxy.ts for anything computed. |
| Lazy loading plugins | Deferring offscreen images | Browser default, and the image component sets it for you. |
| Really Simple SSL, Classic Editor | Forcing https, and turning off the block editor | The host handles one. The other has nothing to port. |
Pile two: becomes code you own
| What you are running | What it was doing | After the move |
|---|---|---|
| Contact Form 7, WPForms, Gravity Forms | Markup, validation, delivery, spam filtering and storage | A route handler and an email API. Storage is a separate decision, covered below. |
| Akismet | Filtering spam submissions | A honeypot field and a rate limit, with a challenge added only if it is still bad. |
| MonsterInsights, Site Kit | Injecting an analytics snippet into the head | A script component, loaded after the page is interactive. |
| Cookie Notice, CookieYes | A consent banner and a cookie | A component and a cookie. Roughly forty lines. |
| Breadcrumb NavXT | Breadcrumb links and their structured data | A component fed by the route segments, and one JSON-LD block. |
| WP Mail SMTP | Making PHP mail() actually deliver | The email API you were going to use for the form anyway. |
| Broken Link Checker | Crawling your own site on a schedule | A job in CI that fails the build, so a dead link never reaches production. |
Pile three: becomes a service
| What you are running | What it was doing | After the move |
|---|---|---|
| Relevanssi, SearchWP | Search that is better than what core does | An index. Pagefind is enough for a static site, Typesense or Algolia above that. |
| Disqus, native comments | Comment storage and moderation | A hosted comment service, or keep them in WordPress and read them over the API. |
| Form plugins that store entries | Keeping a record of every submission | A database table you own, or a form service. This is the part people forget to budget. |
| Popup and newsletter plugins | Capturing addresses and pushing them to a list | A direct call to your email provider from the same route handler that takes the form. |
Forms, in full
Forms are where most of the anxiety sits, so they get a section rather than a row in a table. A form plugin is doing four separate jobs and people usually only replace the first two.
- 01Rendering the fields. HTML. This part is free.
- 02Validating what came in. Both in the browser as a courtesy and on the server as a control, because anything can post to your endpoint.
- 03Delivering it somewhere. An email, usually, and increasingly a webhook into a CRM as well.
- 04Keeping a record. The one that gets dropped. Your plugin has been quietly storing every submission for years, and if delivery fails on a Next.js site with no storage, that brief is gone.
import { NextResponse } from 'next/server';import { Resend } from 'resend'; export const runtime = 'nodejs'; // A bot fills every field it can find. A field a human never sees is the// cheapest spam filter there is, and it does not ask anyone to read letters.const HONEYPOT = 'company_website'; export async function POST(request: Request) { const body = await request.json().catch(() => null); if (!body) return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].length > 0) { // Answer normally. A bot told it failed will simply try again differently. return NextResponse.json({ ok: true }); } const name = typeof body.name === 'string' ? body.name.trim() : ''; const email = typeof body.email === 'string' ? body.email.trim() : ''; const message = typeof body.message === 'string' ? body.message.trim() : ''; // The browser already checked these. It is not a control, it is a courtesy. if (name.length < 2 || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email) || message.length < 10) { return NextResponse.json({ error: 'Please check the form.' }, { status: 400 }); } const resend = new Resend(process.env.RESEND_API_KEY!); const { error } = await resend.emails.send({ from: 'Website <forms@example.com>', to: ['you@example.com'], replyTo: email, subject: `New enquiry: ${name}`, text: [`From: ${name} <${email}>`, '', message].join('\n'), }); // Job four. Without this line, a failed send is a lost customer and // nobody finds out until they ask why you never replied. await recordSubmission({ name, email, message, delivered: !error }); if (error) { return NextResponse.json({ error: 'Could not send. Please email us.' }, { status: 502 }); } return NextResponse.json({ ok: true });}The SEO plugin
The second biggest worry, and the more reasonable one. Yoast or Rank Math is holding years of titles, descriptions, canonicals, robots rules, social cards and structured data. Losing it is how migrations lose rankings.
The replacement is not a plugin, it is two files and a habit. Metadata is exported per route from the same data the page renders, so the two cannot drift apart.
export async function generateMetadata({ params }): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug); if (!post) return {}; return { // The value Yoast was storing, carried across rather than regenerated title: post.seo.title ?? post.title, description: post.seo.metaDesc ?? post.excerpt, alternates: { canonical: post.seo.canonical ?? `https://example.com/blog/${slug}` }, robots: post.seo.noindex ? { index: false, follow: true } : undefined, openGraph: { type: 'article', title: post.seo.ogTitle ?? post.title, description: post.seo.ogDesc ?? post.excerpt, publishedTime: post.date, images: post.seo.ogImage ? [post.seo.ogImage] : undefined, }, };}Yoast stores all of that in postmeta under keys prefixed with an underscore, which means it does not come out in a normal export. It has to be pulled deliberately, mapped, and then diffed against the live site before launch. That is a real line item and it is the one I would never let anyone cut.
Related readingKeeping your Yoast SEO metadata after moving to Next.jsThe performance stack
Cache plugin, image plugin, minifier, lazy loader, script deferrer. On a lot of sites this is a third of the plugin list, and all of it disappears at once.
It disappears because each one is compensating for a decision WordPress made in 2003. A cache plugin exists because pages are assembled per request. An image plugin exists because uploads are served at whatever size they were uploaded at. A minifier exists because every plugin enqueues its own assets independently and nobody is coordinating.
Remove the per-request assembly and the compensations have nothing left to compensate for. Pages are rendered when content changes. Images are transformed on demand into modern formats at the size the layout asks for, with width and height set so nothing shifts. JavaScript is split per route and the code a page does not use is not in the bundle.
The ones that are genuinely hard
Everything above was the cheap news. This is the pile that decides whether your migration is a three week job or a three month one.
| What you are running | Why it is hard | The honest options |
|---|---|---|
| WooCommerce | Cart, tax, shipping, stock and payment are server side logic tied to your configuration | Keep the shop on WordPress, or budget a separate project with its own timeline. |
| LearnDash, LifterLMS | Courses, progress, quizzes and drip schedules are an application, not content | Keep it, or accept that you are commissioning a product build. |
| MemberPress, Restrict Content Pro | Gated content plus recurring billing plus every failure case in between | Auth and Stripe, done properly, or leave the members area where it is. |
| WPML, Polylang | Routing is easy. Storing, ordering and reviewing translations is not. | Keep translation management in the CMS and handle only routing and hreflang in the front end. |
| Booking and appointment plugins | Availability, conflicts, payment, reminders and cancellations | A hosted booking service embedded in the page, unless bookings are the business. |
| Advanced Custom Fields, Pods | This one is not a plugin so much as your content model | Nothing changes if WordPress stays headless. It becomes a schema file if it does not. |
There is a middle route people forget. You do not have to move everything at once. Serve the marketing pages, the blog and the landing pages from Next.js, where the traffic and the rankings are, and leave the members area or the shop on WordPress at its own path. Two systems, one domain, and the expensive pile stays untouched until you actually want to deal with it.
Auditing your own list
Print your active plugins and put each one in a pile. The rule that decides it is a single question: does this plugin write data on behalf of a visitor?
- No, it only renders. Pile one or two. The job either disappears or becomes a file. Price it in hours.
- It writes, but only for you. Editor tools, custom fields, admin conveniences. These stay if WordPress stays, and become schema if it does not.
- It writes for visitors. Orders, memberships, bookings, course progress, comments. Pile four. Each one is a decision, not a task.
Before anyone quotes the work
- Export the active plugin list with versions, not a screenshot of the admin screen.
- List every custom database table, and say which plugin created it.
- Say where form submissions currently go, and whether anybody has ever needed to look one up.
- Note which plugins are paid, when each renews, and what the annual total is.
- Flag anything that has not been updated in over a year. Those are decisions, not migrations.
- Name the one plugin your team would refuse to lose. There is always one, and it is better named early.
Take that to whoever is quoting you. A developer who reads it and asks about the custom tables is reading it properly. One who quotes off the plugin count is not.
Related readingA WordPress to Next.js migration checklist you can hand to a developerIf you want the sorting done rather than described, send me your active plugin list and the site address. You will get it back in the four piles with the hard rows called out, and an honest note on whether the migration is worth doing at all. The migration service page covers how the work runs, the free site check will tell you what your pages are carrying right now, and the contact form reaches me directly.
Resources
- WP-CLI command reference
Every command used above: plugin list, post-type list and db query.
- Next.js Metadata API
What replaces the SEO plugin, including per-route metadata and social images.
- Next.js Route Handlers
The file that takes over from your form plugin, and the runtime options that matter.
- Next.js Image component
Sizing, formats and layout stability, which is three plugins in one component.
- Pagefind
Static site search that runs entirely in the browser. Enough to replace a search plugin on most content sites.
- Patchstack vulnerability database
Worth checking your own plugin list against before you decide any of it is harmless.