{"id":151,"date":"2026-07-23T18:30:00","date_gmt":"2026-07-23T13:00:00","guid":{"rendered":"https:\/\/softcrony.com\/blog\/?p=151"},"modified":"2026-07-23T18:30:00","modified_gmt":"2026-07-23T13:00:00","slug":"wordpress-to-nextjs-migration-case-study","status":"publish","type":"post","link":"https:\/\/softcrony.com\/blog\/wordpress-to-nextjs-migration-case-study\/","title":{"rendered":"We Rebuilt a Client&#8217;s WordPress Site in Next.js \u2014 Here&#8217;s What Happened"},"content":{"rendered":"<p>Every developer has had the conversation. A client&#8217;s WordPress site is slow, the theme is unmaintainable, and every update breaks something. Someone suggests rebuilding it in Next.js. The client asks how long it will take and how much it will cost. And you think \u2014 actually, is this a good idea?<\/p>\n<p>We did this migration for a client in early 2026. This is the honest account \u2014 what worked, what didn&#8217;t, and whether we&#8217;d recommend it.<\/p>\n<h2>The Client and the Problem<\/h2>\n<p>An educational institution in Madhya Pradesh \u2014 three campuses, a student portal, an admissions system, and a public-facing website. The WordPress site had been running since 2018, built on a premium theme that had been customised to the point where the original theme was unrecognisable.<\/p>\n<p>The problems were real:<\/p>\n<ul>\n<li>PageSpeed score of 31 on mobile \u2014 three campuses worth of students trying to access it on phones<\/li>\n<li>The theme had conflicts with newer plugins \u2014 some form functionality had broken 8 months earlier and nobody knew how to fix it without rebuilding the entire contact section<\/li>\n<li>The site had been hacked twice in 2024. The second time resulted in student inquiry data being exposed \u2014 exactly the kind of thing that creates legal and reputational problems under DPDP Act<\/li>\n<li>Every content update required a developer because the Gutenberg editor had been partly disabled to prevent accidental breakage<\/li>\n<\/ul>\n<p>The institution wanted to rebuild the public website. The student portal and admissions system would stay separate \u2014 that&#8217;s a different project.<\/p>\n<h2>Why We Chose Next.js 15<\/h2>\n<p>We evaluated three options:<\/p>\n<p><strong>Option 1: Rebuild in WordPress<\/strong> \u2014 new theme, proper architecture, performance optimization. Cheapest upfront. But we&#8217;d be solving today&#8217;s problems without changing the underlying fragility. The client would be back in 2\u20133 years with the same situation.<\/p>\n<p><strong>Option 2: Headless WordPress (WordPress API + Next.js)<\/strong> \u2014 WordPress as CMS, Next.js as frontend. Best of both worlds in theory. In practice, it added complexity without eliminating WordPress&#8217;s security surface. The admin still needed to stay secure. We&#8217;d still need WordPress updates, plugin management, and all the associated maintenance.<\/p>\n<p><strong>Option 3: Next.js with a headless CMS (Sanity)<\/strong> \u2014 completely off WordPress. Next.js for the frontend, Sanity for content management. Higher upfront cost, but clean architecture, no plugin dependencies, significantly better performance ceiling, and a content editor that non-technical users can actually use confidently.<\/p>\n<p>We recommended Option 3. The client was hesitant about the cost. We modelled the total cost of ownership over 3 years \u2014 including the developer time that went into the existing site&#8217;s ongoing issues \u2014 and the numbers made the case. They agreed.<\/p>\n<h2>The Migration Plan<\/h2>\n<p>The site had approximately:<\/p>\n<ul>\n<li>45 pages of static content (About, Courses, Faculty, etc.)<\/li>\n<li>A news\/events section with 180+ posts<\/li>\n<li>A gallery section with 300+ images<\/li>\n<li>3 contact and inquiry forms<\/li>\n<li>An admissions deadline calendar<\/li>\n<\/ul>\n<p>We scoped it as a 10-week project:<\/p>\n<table>\n<thead>\n<tr>\n<th>Phase<\/th>\n<th>Duration<\/th>\n<th>What Happened<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Discovery and content audit<\/td>\n<td>Week 1<\/td>\n<td>Mapped all pages, identified what to migrate vs retire<\/td>\n<\/tr>\n<tr>\n<td>Design<\/td>\n<td>Weeks 2\u20133<\/td>\n<td>New design in Figma, mobile-first, client approvals<\/td>\n<\/tr>\n<tr>\n<td>Sanity CMS setup<\/td>\n<td>Week 3<\/td>\n<td>Schema design, content types, editor training prep<\/td>\n<\/tr>\n<tr>\n<td>Next.js development<\/td>\n<td>Weeks 4\u20137<\/td>\n<td>Pages, components, API routes, forms<\/td>\n<\/tr>\n<tr>\n<td>Content migration<\/td>\n<td>Weeks 6\u20138<\/td>\n<td>45 pages + 180 posts migrated to Sanity (overlapped with dev)<\/td>\n<\/tr>\n<tr>\n<td>Testing and optimization<\/td>\n<td>Week 9<\/td>\n<td>Performance, cross-browser, mobile, forms<\/td>\n<\/tr>\n<tr>\n<td>Deployment and DNS cutover<\/td>\n<td>Week 10<\/td>\n<td>Vercel deployment, DNS update, monitoring setup<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>The Technical Stack<\/h2>\n<pre><code>\/\/ package.json \u2014 key dependencies\r\n{\r\n  \"dependencies\": {\r\n    \"next\": \"^15.0.0\",\r\n    \"react\": \"^19.0.0\",\r\n    \"next-sanity\": \"^9.0.0\",\r\n    \"@sanity\/client\": \"^6.0.0\",\r\n    \"@sanity\/image-url\": \"^1.0.0\",\r\n    \"react-hook-form\": \"^7.0.0\",\r\n    \"nodemailer\": \"^6.0.0\",\r\n    \"resend\": \"^4.0.0\"\r\n  }\r\n}<\/code><\/pre>\n<pre><code>\/\/ app\/courses\/[slug]\/page.tsx \u2014 example page\r\nimport { sanityClient } from '@\/lib\/sanity';\r\nimport { CourseContent } from '@\/components\/CourseContent';\r\n\r\nasync function getCourse(slug: string) {\r\n  return sanityClient.fetch(\r\n    `*[_type == \"course\" && slug.current == $slug][0]{\r\n      title,\r\n      description,\r\n      duration,\r\n      eligibility,\r\n      fees,\r\n      highlights,\r\n      \"faculty\": faculty[]->{name, designation, image}\r\n    }`,\r\n    { slug }\r\n  );\r\n}\r\n\r\nexport async function generateStaticParams() {\r\n  const courses = await sanityClient.fetch(\r\n    `*[_type == \"course\"]{ \"slug\": slug.current }`\r\n  );\r\n  return courses.map((course: { slug: string }) => ({ slug: course.slug }));\r\n}\r\n\r\nexport default async function CoursePage({ params }: { params: { slug: string } }) {\r\n  const course = await getCourse(params.slug);\r\n\r\n  if (!course) notFound();\r\n\r\n  return &lt;CourseContent course={course} \/&gt;;\r\n}<\/code><\/pre>\n<h2>The Content Migration<\/h2>\n<p>This was harder than the development. 180 blog posts, each with images, categories, tags, and internal links \u2014 all living in WordPress&#8217;s database and media library.<\/p>\n<p>We wrote a Node.js migration script that:<\/p>\n<ol>\n<li>Pulled all posts from the WordPress REST API<\/li>\n<li>Converted HTML content to Portable Text (Sanity&#8217;s format) using <code>@portabletext\/from-html<\/code><\/li>\n<li>Downloaded all post images and uploaded them to Sanity&#8217;s CDN<\/li>\n<li>Updated internal links to match the new URL structure<\/li>\n<li>Created the posts in Sanity via the Mutations API<\/li>\n<\/ol>\n<pre><code>\/\/ migration\/migrate-posts.ts\r\nimport { createClient } from '@sanity\/client';\r\nimport { htmlToBlocks } from '@portabletext\/from-html';\r\n\r\nconst sanity = createClient({\r\n  projectId: process.env.SANITY_PROJECT_ID!,\r\n  dataset: 'production',\r\n  token: process.env.SANITY_TOKEN,\r\n  apiVersion: '2026-01-01',\r\n});\r\n\r\nasync function migratePost(wpPost: WordPressPost) {\r\n  \/\/ Convert WordPress HTML to Sanity Portable Text\r\n  const body = htmlToBlocks(wpPost.content.rendered, {\r\n    rules: [\r\n      \/\/ Handle WordPress image blocks\r\n      {\r\n        deserialize(node, next, block) {\r\n          if (node.nodeName === 'IMG') {\r\n            return block({\r\n              _type: 'image',\r\n              asset: { _ref: `image-${uploadedImages[node.src]}` }\r\n            });\r\n          }\r\n        }\r\n      }\r\n    ]\r\n  });\r\n\r\n  await sanity.create({\r\n    _type: 'post',\r\n    title: wpPost.title.rendered,\r\n    slug: { current: wpPost.slug },\r\n    publishedAt: wpPost.date,\r\n    body,\r\n    categories: await mapCategories(wpPost.categories),\r\n  });\r\n}\r\n<\/code><\/pre>\n<p>The migration script ran in 4 hours. Manual cleanup of edge cases took another day. We retired 40 posts that were outdated (pre-2022, no longer relevant) rather than migrating them \u2014 a conversation with the client that was worth having.<\/p>\n<h2>The Results \u2014 Six Weeks After Launch<\/h2>\n<table>\n<thead>\n<tr>\n<th>Metric<\/th>\n<th>WordPress (Before)<\/th>\n<th>Next.js (After)<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>PageSpeed Mobile<\/td>\n<td>31<\/td>\n<td>94<\/td>\n<\/tr>\n<tr>\n<td>PageSpeed Desktop<\/td>\n<td>67<\/td>\n<td>99<\/td>\n<\/tr>\n<tr>\n<td>LCP (mobile)<\/td>\n<td>8.2s<\/td>\n<td>1.1s<\/td>\n<\/tr>\n<tr>\n<td>CLS<\/td>\n<td>0.34<\/td>\n<td>0.02<\/td>\n<\/tr>\n<tr>\n<td>Time to First Byte<\/td>\n<td>1.8s<\/td>\n<td>0.12s (Vercel edge)<\/td>\n<\/tr>\n<tr>\n<td>Bounce rate<\/td>\n<td>74%<\/td>\n<td>51%<\/td>\n<\/tr>\n<tr>\n<td>Average session duration<\/td>\n<td>1:12<\/td>\n<td>2:34<\/td>\n<\/tr>\n<tr>\n<td>Online inquiry form submissions<\/td>\n<td>43\/month avg<\/td>\n<td>89\/month (first 6 weeks)<\/td>\n<\/tr>\n<tr>\n<td>Security incidents since launch<\/td>\n<td>2 in 12 months<\/td>\n<td>0<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The inquiry form submissions doubling is the number the client cares about most. Whether that&#8217;s entirely attributable to the site rebuild or partly to the admissions season is hard to isolate \u2014 but the correlation is there.<\/p>\n<h2>What Was Harder Than Expected<\/h2>\n<p><strong>Content editor training.<\/strong> The client&#8217;s marketing team was used to WordPress. Sanity is a different mental model. We underestimated the training time \u2014 what we planned as a half-day session took two full days plus ongoing support for 3 weeks.<\/p>\n<p><strong>Image migration edge cases.<\/strong> WordPress embeds images in post HTML in multiple ways. Our migration script handled 90% of them automatically. The remaining 10% \u2014 gallery blocks, custom HTML, caption formatting \u2014 required manual review for about 30 posts.<\/p>\n<p><strong>The admissions calendar.<\/strong> This was a custom WordPress plugin that had been built specifically for this client. Rebuilding the functionality in Next.js took longer than estimated because the original code was undocumented and the plugin&#8217;s author was unreachable.<\/p>\n<h2>Would We Do It Again?<\/h2>\n<p>Yes \u2014 for the right client and the right site.<\/p>\n<p>The case for a WordPress-to-Next.js migration is strong when:<\/p>\n<ul>\n<li>Performance is genuinely affecting the business (bounce rates, conversion, SEO)<\/li>\n<li>The WordPress site has become unmaintainable due to plugin conflicts or theme sprawl<\/li>\n<li>Security incidents have happened or are a credible risk<\/li>\n<li>The content team is willing to learn a new editor<\/li>\n<li>The site doesn&#8217;t rely heavily on the WordPress plugin ecosystem for core functionality<\/li>\n<\/ul>\n<p>The case is weak when:<\/p>\n<ul>\n<li>The WordPress site is well-maintained and performs reasonably<\/li>\n<li>The client needs WooCommerce \u2014 rebuilding an e-commerce system in Next.js is a much larger project<\/li>\n<li>The content team is resistant to change \u2014 you&#8217;ll spend more time on change management than development<\/li>\n<li>The budget is constrained \u2014 a proper migration costs more upfront than a WordPress rebuild<\/li>\n<\/ul>\n<p>If you&#8217;re evaluating whether a rebuild makes sense for your site or client, <a href=\"https:\/\/softcrony.com\/contact\/\">our team at Softcrony is happy to give you an honest assessment<\/a>. Sometimes the right answer is &#8220;fix what you have.&#8221;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Every developer has had the conversation. A client&#8217;s WordPress site is slow, the theme is unmaintainable, and every update breaks something. Someone suggests rebuilding it in Next.js. The client asks how long it will take and how much it will cost. And you think \u2014 actually, is this a good idea? We did this migration [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":153,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[16,20,67,94,18,22],"class_list":["post-151","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-case-studies","tag-case-study","tag-india","tag-migration","tag-next-js","tag-web-development","tag-wordpress"],"_links":{"self":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/151","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/comments?post=151"}],"version-history":[{"count":0,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/151\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media\/153"}],"wp:attachment":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media?parent=151"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/categories?post=151"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/tags?post=151"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}