<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title><![CDATA[Build with Matija]]></title>
        <description><![CDATA[Insights, tutorials, and expert guides to help you grow your business and master your craft.]]></description>
        <link>https://www.buildwithmatija.com</link>
        <generator>RSS for Node</generator>
        <lastBuildDate>Sun, 12 Apr 2026 06:24:07 GMT</lastBuildDate>
        <atom:link href="https://www.buildwithmatija.com/feed.xml" rel="self" type="application/rss+xml"/>
        <pubDate>Sun, 12 Apr 2026 06:24:07 GMT</pubDate>
        <copyright><![CDATA[© 2026 Build with Matija]]></copyright>
        <language><![CDATA[en]]></language>
        <item>
            <title><![CDATA[Payload Async Hooks: Avoid the Transaction Trap - 3 Fixes]]></title>
            <description><![CDATA[<p>If you fire async work inside a Payload CMS hook without awaiting it, and that work still carries the original <code>req</code> object, you can receive a success response for data that never actually committed to the database. The fix is straightforward: either <code>await</code> anything that passes <code>req</code>, or drop <code>req</code> entirely from work you want to run as a detached side effect. This article walks through exactly why that distinction matters and how to apply it consistently in your hooks.</p>
<p>I ran into this while building out a more ambitious <code>afterChange</code> hook for a client project — one that triggered several secondary writes on top of the main operation. Everything worked perfectly in happy-path testing. The failure only surfaced under specific timing conditions, and by then the client-facing response had already confirmed success. That kind of silent inconsistency is hard to diagnose. Once I understood the actual mechanism, I realized it came down to one overlooked detail: what it means to pass <code>req</code> into a Payload operation.</p>
<h2>Why <code>req</code> Is More Than Request Metadata</h2>
<p>In Payload, <code>req</code> carries transactional context. When you pass it into an internal operation like <code>req.payload.create({ req, ... })</code>, you are not just routing the request through the system — you are attaching that operation to the same database transaction the original request is running inside.</p>
<p>That is exactly what you want when the operation is a core part of the business action. Shared transaction context means the whole unit succeeds or fails together. The problem surfaces when you combine that shared context with unawaited async work.</p>
<h2>The Dangerous Pattern</h2>
<p>Here is the hook that looks harmless but creates real risk:</p>
<pre><code class="language-ts">// File: src/collections/Orders/hooks/afterChange.ts

import type { CollectionAfterChangeHook } from &#39;payload&#39;

const afterChange: CollectionAfterChangeHook = async ({ req }) =&gt; {
  const result = req.payload.create({
    req,
    collection: &#39;order-logs&#39;,
    data: {
      message: &#39;Order created&#39;,
    },
  })

  // hook continues without waiting
}
</code></pre>
<p>The sequence of events this creates is the core of the problem. The async create operation starts. The hook does not wait for it. The request moves forward toward its response. Payload returns a success to the client. Then the async operation fails — and because it was tied to the same transaction via <code>req</code>, the transaction rolls back. The client now holds a success response for data that does not exist in committed form.</p>
<p>This is the warning buried in Payload&#39;s documentation:</p>
<blockquote>
<p>Since Payload hooks can be async and be written to not await the result, it is possible to have an incorrect success response returned on a request that is rolled back.</p>
</blockquote>
<p>It is a dense sentence to read past, but it describes exactly this failure mode.</p>
<h2>The Two Safe Patterns</h2>
<p>The decision point is one question: <strong>should this operation share the fate of the main request?</strong></p>
<p>If yes, it belongs inside the transaction, and you need to both pass <code>req</code> and <code>await</code> the result. If no, it is a detached side effect, and <code>req</code> should not be passed at all.</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Pass <code>req</code></th>
<th>Await</th>
<th>Use when</th>
</tr>
</thead>
<tbody><tr>
<td>Transactional</td>
<td>Yes</td>
<td>Yes</td>
<td>Operation is part of the core business action — it must succeed or fail with the request</td>
</tr>
<tr>
<td>Detached</td>
<td>No</td>
<td>No (void)</td>
<td>Operation is a side effect — analytics, logging, best-effort writes</td>
</tr>
<tr>
<td>Dangerous</td>
<td>Yes</td>
<td>No</td>
<td>Never — carries transactional consequences without transactional timing</td>
</tr>
</tbody></table>
<h3>Pattern A: Transactional and Awaited</h3>
<pre><code class="language-ts">// File: src/collections/Orders/hooks/afterChange.ts

import type { CollectionAfterChangeHook } from &#39;payload&#39;

const afterChange: CollectionAfterChangeHook = async ({ req }) =&gt; {
  await req.payload.create({
    req,
    collection: &#39;order-logs&#39;,
    data: {
      message: &#39;Order created&#39;,
    },
  })
}
</code></pre>
<p>This preserves the full contract. If the create fails, the transaction rolls back. If it succeeds, you know it is committed before the response goes out.</p>
<h3>Pattern B: Detached and Non-Blocking</h3>
<pre><code class="language-ts">// File: src/collections/Orders/hooks/afterChange.ts

import type { CollectionAfterChangeHook } from &#39;payload&#39;

const afterChange: CollectionAfterChangeHook = async ({ req }) =&gt; {
  void req.payload.create({
    collection: &#39;analytics-events&#39;,
    data: {
      type: &#39;order_created&#39;,
    },
  })
}
</code></pre>
<p>No <code>req</code>. This operation runs on its own transaction, with its own fate. If it fails, that failure is independent — it does not reach back into the original request and undermine the success response.</p>
<h2>How This Creeps into Real Projects</h2>
<p>Hook code tends to evolve incrementally. You start simple, then add a secondary write, then a notification, then an audit record. At some point it becomes tempting to let some of that work happen in the background to avoid blocking the response.</p>
<p>That is exactly where the dangerous half-state gets introduced. The API reports the main action as successful. One of the secondary operations was still tied to the original transaction. That operation fails. The transaction rolls back. The client believes in a success that never truly committed.</p>
<p>Because the failure only appears when timing and failure conditions align just wrong, happy-path testing never catches it. Everything looks fine until it does not.</p>
<h2>FAQ</h2>
<p><strong>Does this apply to all Payload hook types, or just <code>afterChange</code>?</strong></p>
<p>It applies to any async hook where you can kick off operations without awaiting them — <code>afterChange</code>, <code>afterOperation</code>, <code>afterRead</code>, and others. The transaction context travels with <code>req</code> regardless of which hook you are in.</p>
<p><strong>What if I want a secondary write to be best-effort but still use the same request context for other reasons?</strong></p>
<p>You cannot safely have both. If you pass <code>req</code>, you opt into the transaction. If you want best-effort behavior, drop <code>req</code> and accept that the operation runs independently with its own transaction.</p>
<p><strong>Is there a way to start a separate transaction explicitly instead of omitting <code>req</code>?</strong></p>
<p>Payload does not currently expose a public API for manually starting a fresh transaction inside a hook. Omitting <code>req</code> is the correct mechanism for detaching an operation from the original request&#39;s transactional context.</p>
<p><strong>Will TypeScript catch this mistake?</strong></p>
<p>No. The type signatures for Payload&#39;s local API methods accept <code>req</code> as optional. There is no compiler-level warning for the unawaited-with-req combination. It is purely a runtime behavior issue.</p>
<p><strong>How do I handle errors from detached operations if I still care about them?</strong></p>
<p>Wrap the detached call in a standalone try/catch or attach a <code>.catch()</code> handler. Since it is no longer tied to the main request, you are responsible for its error handling independently.</p>
<pre><code class="language-ts">// File: src/collections/Orders/hooks/afterChange.ts

req.payload
  .create({
    collection: &#39;analytics-events&#39;,
    data: { type: &#39;order_created&#39; },
  })
  .catch((err) =&gt; {
    console.error(&#39;Analytics write failed:&#39;, err)
  })
</code></pre>
<h2>Conclusion</h2>
<p>The Payload async hook transaction trap comes down to one detail: passing <code>req</code> into an operation opts that operation into the request&#39;s transaction boundary. Combine that with unawaited async work, and you create a window where a success response goes out before the transaction has actually committed everything it depends on.</p>
<p>The safe habit is straightforward. Await anything that shares the transaction. Detach anything you truly want to run independently by omitting <code>req</code>. That single distinction keeps your hook design consistent and your success responses trustworthy.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical Payload and Next.js development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/payload-async-hooks-transaction-trap</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/payload-async-hooks-transaction-trap</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sun, 12 Apr 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you fire async work inside a Payload CMS hook without awaiting it, and that work still carries the original &lt;code&gt;req&lt;/code&gt; object, you can receive a success response for data that never actually committed to the database. The fix is straightforward: either &lt;code&gt;await&lt;/code&gt; anything that passes &lt;code&gt;req&lt;/code&gt;, or drop &lt;code&gt;req&lt;/code&gt; entirely from work you want to run as a detached side effect. This article walks through exactly why that distinction matters and how to apply it consistently in your hooks.&lt;/p&gt;
&lt;p&gt;I ran into this while building out a more ambitious &lt;code&gt;afterChange&lt;/code&gt; hook for a client project — one that triggered several secondary writes on top of the main operation. Everything worked perfectly in happy-path testing. The failure only surfaced under specific timing conditions, and by then the client-facing response had already confirmed success. That kind of silent inconsistency is hard to diagnose. Once I understood the actual mechanism, I realized it came down to one overlooked detail: what it means to pass &lt;code&gt;req&lt;/code&gt; into a Payload operation.&lt;/p&gt;
&lt;h2&gt;Why &lt;code&gt;req&lt;/code&gt; Is More Than Request Metadata&lt;/h2&gt;
&lt;p&gt;In Payload, &lt;code&gt;req&lt;/code&gt; carries transactional context. When you pass it into an internal operation like &lt;code&gt;req.payload.create({ req, ... })&lt;/code&gt;, you are not just routing the request through the system — you are attaching that operation to the same database transaction the original request is running inside.&lt;/p&gt;
&lt;p&gt;That is exactly what you want when the operation is a core part of the business action. Shared transaction context means the whole unit succeeds or fails together. The problem surfaces when you combine that shared context with unawaited async work.&lt;/p&gt;
&lt;h2&gt;The Dangerous Pattern&lt;/h2&gt;
&lt;p&gt;Here is the hook that looks harmless but creates real risk:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/collections/Orders/hooks/afterChange.ts

import type { CollectionAfterChangeHook } from &amp;#39;payload&amp;#39;

const afterChange: CollectionAfterChangeHook = async ({ req }) =&amp;gt; {
  const result = req.payload.create({
    req,
    collection: &amp;#39;order-logs&amp;#39;,
    data: {
      message: &amp;#39;Order created&amp;#39;,
    },
  })

  // hook continues without waiting
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sequence of events this creates is the core of the problem. The async create operation starts. The hook does not wait for it. The request moves forward toward its response. Payload returns a success to the client. Then the async operation fails — and because it was tied to the same transaction via &lt;code&gt;req&lt;/code&gt;, the transaction rolls back. The client now holds a success response for data that does not exist in committed form.&lt;/p&gt;
&lt;p&gt;This is the warning buried in Payload&amp;#39;s documentation:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Since Payload hooks can be async and be written to not await the result, it is possible to have an incorrect success response returned on a request that is rolled back.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It is a dense sentence to read past, but it describes exactly this failure mode.&lt;/p&gt;
&lt;h2&gt;The Two Safe Patterns&lt;/h2&gt;
&lt;p&gt;The decision point is one question: &lt;strong&gt;should this operation share the fate of the main request?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If yes, it belongs inside the transaction, and you need to both pass &lt;code&gt;req&lt;/code&gt; and &lt;code&gt;await&lt;/code&gt; the result. If no, it is a detached side effect, and &lt;code&gt;req&lt;/code&gt; should not be passed at all.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Pass &lt;code&gt;req&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Await&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Transactional&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Operation is part of the core business action — it must succeed or fail with the request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detached&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (void)&lt;/td&gt;
&lt;td&gt;Operation is a side effect — analytics, logging, best-effort writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dangerous&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Never — carries transactional consequences without transactional timing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;Pattern A: Transactional and Awaited&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/collections/Orders/hooks/afterChange.ts

import type { CollectionAfterChangeHook } from &amp;#39;payload&amp;#39;

const afterChange: CollectionAfterChangeHook = async ({ req }) =&amp;gt; {
  await req.payload.create({
    req,
    collection: &amp;#39;order-logs&amp;#39;,
    data: {
      message: &amp;#39;Order created&amp;#39;,
    },
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This preserves the full contract. If the create fails, the transaction rolls back. If it succeeds, you know it is committed before the response goes out.&lt;/p&gt;
&lt;h3&gt;Pattern B: Detached and Non-Blocking&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/collections/Orders/hooks/afterChange.ts

import type { CollectionAfterChangeHook } from &amp;#39;payload&amp;#39;

const afterChange: CollectionAfterChangeHook = async ({ req }) =&amp;gt; {
  void req.payload.create({
    collection: &amp;#39;analytics-events&amp;#39;,
    data: {
      type: &amp;#39;order_created&amp;#39;,
    },
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;req&lt;/code&gt;. This operation runs on its own transaction, with its own fate. If it fails, that failure is independent — it does not reach back into the original request and undermine the success response.&lt;/p&gt;
&lt;h2&gt;How This Creeps into Real Projects&lt;/h2&gt;
&lt;p&gt;Hook code tends to evolve incrementally. You start simple, then add a secondary write, then a notification, then an audit record. At some point it becomes tempting to let some of that work happen in the background to avoid blocking the response.&lt;/p&gt;
&lt;p&gt;That is exactly where the dangerous half-state gets introduced. The API reports the main action as successful. One of the secondary operations was still tied to the original transaction. That operation fails. The transaction rolls back. The client believes in a success that never truly committed.&lt;/p&gt;
&lt;p&gt;Because the failure only appears when timing and failure conditions align just wrong, happy-path testing never catches it. Everything looks fine until it does not.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does this apply to all Payload hook types, or just &lt;code&gt;afterChange&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It applies to any async hook where you can kick off operations without awaiting them — &lt;code&gt;afterChange&lt;/code&gt;, &lt;code&gt;afterOperation&lt;/code&gt;, &lt;code&gt;afterRead&lt;/code&gt;, and others. The transaction context travels with &lt;code&gt;req&lt;/code&gt; regardless of which hook you are in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if I want a secondary write to be best-effort but still use the same request context for other reasons?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You cannot safely have both. If you pass &lt;code&gt;req&lt;/code&gt;, you opt into the transaction. If you want best-effort behavior, drop &lt;code&gt;req&lt;/code&gt; and accept that the operation runs independently with its own transaction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is there a way to start a separate transaction explicitly instead of omitting &lt;code&gt;req&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Payload does not currently expose a public API for manually starting a fresh transaction inside a hook. Omitting &lt;code&gt;req&lt;/code&gt; is the correct mechanism for detaching an operation from the original request&amp;#39;s transactional context.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Will TypeScript catch this mistake?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No. The type signatures for Payload&amp;#39;s local API methods accept &lt;code&gt;req&lt;/code&gt; as optional. There is no compiler-level warning for the unawaited-with-req combination. It is purely a runtime behavior issue.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I handle errors from detached operations if I still care about them?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Wrap the detached call in a standalone try/catch or attach a &lt;code&gt;.catch()&lt;/code&gt; handler. Since it is no longer tied to the main request, you are responsible for its error handling independently.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/collections/Orders/hooks/afterChange.ts

req.payload
  .create({
    collection: &amp;#39;analytics-events&amp;#39;,
    data: { type: &amp;#39;order_created&amp;#39; },
  })
  .catch((err) =&amp;gt; {
    console.error(&amp;#39;Analytics write failed:&amp;#39;, err)
  })
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The Payload async hook transaction trap comes down to one detail: passing &lt;code&gt;req&lt;/code&gt; into an operation opts that operation into the request&amp;#39;s transaction boundary. Combine that with unawaited async work, and you create a window where a success response goes out before the transaction has actually committed everything it depends on.&lt;/p&gt;
&lt;p&gt;The safe habit is straightforward. Await anything that shares the transaction. Detach anything you truly want to run independently by omitting &lt;code&gt;req&lt;/code&gt;. That single distinction keeps your hook design consistent and your success responses trustworthy.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical Payload and Next.js development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/payload-async-hooks-transaction-trap"/>
        </item>
        <item>
            <title><![CDATA[Payload CMS Jobs: Separate Web & Worker Roles for Safe Scale]]></title>
            <description><![CDATA[<p>If you&#39;re running a Payload CMS application and your background jobs are competing with your web server for resources, the fix is architectural. Payload&#39;s jobs system is built around the idea that the web runtime and the worker runtime are two separate operational roles — even when they come from the same codebase. This article walks through how that separation works, why it matters at scale, and how concurrency keys keep multi-worker setups from colliding with each other.</p>
<hr>
<h2>The Problem With One Runtime Doing Everything</h2>
<p>I was working on a multi-tenant platform built on Payload and Next.js when I noticed something frustrating. Background imports — syncing data from external sources per tenant — were visibly degrading the admin UI and API response times. The jobs were queued correctly, running asynchronously, and technically non-blocking. And yet the web app felt sluggish during heavy import runs.</p>
<p>The root cause was simple. The background jobs and the Next.js web app were running in the same process, on the same container, competing for the same CPU and memory. Non-blocking is not the same thing as isolated. A background job that no longer blocks an HTTP request can still saturate the container it shares with your web server.</p>
<p>That framing changed how I thought about the problem. The question shifted from &quot;should this job run inside Payload?&quot; to &quot;which Payload runtime should own it?&quot;</p>
<hr>
<h2>Web Runtime vs Worker Runtime</h2>
<p>Payload&#39;s jobs system is designed around role separation. The web runtime handles the admin UI, REST, GraphQL, and fast document operations. The worker runtime executes queued tasks and workflows. Payload explicitly recommends bin scripts for dedicated worker servers because they run in a separate process from the Next.js server, which makes them easier to deploy, monitor, and scale independently.</p>
<p>In practice, the setup is straightforward. You use the same codebase — often the same Docker image — and initialize it differently depending on the role. One container starts the web app. Another starts the job runner, optionally with scheduling enabled. Payload&#39;s deployment examples show this directly, with a main app process and separate worker processes assigned to specific queues.</p>
<p>The conceptual shift here is important. This is not about maintaining two separate applications. It is about running one application in two different modes.</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Responsibilities</th>
<th>Performance Profile</th>
</tr>
</thead>
<tbody><tr>
<td>Web runtime</td>
<td>Admin UI, REST, GraphQL, document reads/writes</td>
<td>Low latency, stable memory</td>
</tr>
<tr>
<td>Worker runtime</td>
<td>Queued tasks, scheduled workflows, heavy processing</td>
<td>Burst CPU, longer execution windows, retry tolerance</td>
</tr>
</tbody></table>
<hr>
<h2>Why Compute Separation Is the Real Win</h2>
<p>Moving the worker onto separate compute is where the architecture pays off. You can deploy it as another container, another VPS, another ECS service, or another Kubernetes deployment. That gives your web tier predictable latency because it no longer shares resources with long-running jobs.</p>
<p>Your web tier and your worker tier have different performance profiles and different scaling needs. The web tier wants consistent, low-latency responses. The worker tier needs burst CPU, tolerance for longer execution, and more headroom for retries. Forcing one runtime to serve both equally well means neither performs optimally. With the roles split, you can scale each tier horizontally based on what it actually needs.</p>
<p>Payload&#39;s queue design supports exactly this. Workers are independent processes. They can be deployed separately, assigned to specific queues, and scaled without touching the web tier.</p>
<hr>
<h2>The Database Is Still the Shared Bottleneck</h2>
<p>Separating compute does not eliminate all pressure points. Payload&#39;s job system works by queuing work into the database and having workers pick it up and execute it. That shared persistence is what makes independent workers possible — and it also means the database remains the coordination layer for the whole system.</p>
<p>You can isolate CPU-intensive execution away from the web app completely, but if too many jobs write aggressively, lock data frequently, or create excessive transaction churn, the database becomes the limiting factor. Isolating compute is a meaningful gain. Understanding that the database is still shared is the realistic counterpart to that gain.</p>
<hr>
<h2>Concurrency Keys: Safe Scale Across Multiple Workers</h2>
<p>In a single-worker setup, job sequencing happens naturally. There is only one active worker loop, so jobs run one after another. As soon as you add more workers, you introduce the possibility that two valid jobs run at the same time against the same resource.</p>
<p>Payload&#39;s concurrency model solves this by letting you define a concurrency key per job. When two jobs share the same key, Payload guarantees they run sequentially. The key is stored when the job is queued, and the runner excludes jobs whose key is already being processed. If multiple pending jobs with the same key are picked up in one batch, only the first runs. The rest are released to wait for a later pass.</p>
<p>This is especially relevant for tenant-scoped imports and syncs. Say you have two workers and two queued imports for the same tenant. Without a concurrency key, both workers might process those jobs simultaneously. The jobs are not duplicates — they are different jobs — but they operate on the same tenant data. That is a resource-collision problem. A shared key like <code>import:tenant-123</code> tells Payload those jobs belong to the same protected lane and must be serialized. Jobs for a different tenant use a different key and can still run in parallel.</p>
<p>The important design decision here is that the concurrency key should be shared by jobs that touch the same resource — not unique per job. Unique keys provide no protection at all.</p>
<hr>
<h2>Tasks vs Workflows: Choosing the Right Model</h2>
<p>Payload describes a task as one isolated unit of business logic. A workflow is an ordered group of tasks that can be retried from a specific point of failure rather than from the beginning.</p>
<p>Tasks are a good fit for simple, atomic background work. Workflows become more valuable when a process has multiple dependent stages — fetch, transform, write, finalize — and you care about durable resumption. If a workflow fails on the &quot;write&quot; step, it can resume from there rather than restarting the entire sequence.</p>
<p>For most straightforward background jobs, tasks are sufficient. For anything multi-stage where partial completion has meaningful value, workflows are the right model.</p>
<hr>
<h2>FAQ</h2>
<p><strong>Do I need separate infrastructure to separate web and worker roles?</strong>
You do not need a fundamentally different infrastructure setup. The simplest separation is running two containers from the same Docker image with different start commands — one for the web app and one for the worker. The value scales up from there if you move to separate VPS instances or managed container services.</p>
<p><strong>What happens if I run the worker on the same container as the web app?</strong>
You will still benefit from the queue architecture and async execution, but the web app and worker will compete for the same CPU, memory, and I/O. Under heavy job load, you can see degraded web app response times even though the jobs are technically non-blocking.</p>
<p><strong>Can I run multiple worker processes against the same queue?</strong>
Yes, and Payload&#39;s queue system is designed to support it. Each worker independently polls the database for jobs. You can run as many worker processes as you need across separate containers or servers.</p>
<p><strong>How does Payload prevent two workers from picking up the same job?</strong>
Payload uses database-level locking when a worker picks up a job, which prevents other workers from claiming the same job. This is how the queue remains safe across multiple concurrent workers.</p>
<p><strong>When should I use a workflow instead of a task?</strong>
Use a workflow when the background process has multiple dependent stages and you want durable resumption on failure. If the entire job is a single atomic operation, a task is simpler and sufficient.</p>
<hr>
<h2>Conclusion</h2>
<p>The practical takeaway from Payload&#39;s jobs system is that it gives you a clean way to separate concerns operationally and scale each concern on its own terms. The web app serves users. Workers handle asynchronous load. The database acts as the shared coordination layer. Concurrency keys protect shared resources when multiple workers run in parallel. And workflows give you a more durable model for complex multi-stage processes.</p>
<p>If you are seeing performance pressure in your Payload app during heavy background work, the architecture is telling you something. The jobs system is built for separation. Using it that way is the intended path.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/payload-cms-jobs-separate-web-worker-roles-safe-scale</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/payload-cms-jobs-separate-web-worker-roles-safe-scale</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sat, 11 Apr 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you&amp;#39;re running a Payload CMS application and your background jobs are competing with your web server for resources, the fix is architectural. Payload&amp;#39;s jobs system is built around the idea that the web runtime and the worker runtime are two separate operational roles — even when they come from the same codebase. This article walks through how that separation works, why it matters at scale, and how concurrency keys keep multi-worker setups from colliding with each other.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Problem With One Runtime Doing Everything&lt;/h2&gt;
&lt;p&gt;I was working on a multi-tenant platform built on Payload and Next.js when I noticed something frustrating. Background imports — syncing data from external sources per tenant — were visibly degrading the admin UI and API response times. The jobs were queued correctly, running asynchronously, and technically non-blocking. And yet the web app felt sluggish during heavy import runs.&lt;/p&gt;
&lt;p&gt;The root cause was simple. The background jobs and the Next.js web app were running in the same process, on the same container, competing for the same CPU and memory. Non-blocking is not the same thing as isolated. A background job that no longer blocks an HTTP request can still saturate the container it shares with your web server.&lt;/p&gt;
&lt;p&gt;That framing changed how I thought about the problem. The question shifted from &amp;quot;should this job run inside Payload?&amp;quot; to &amp;quot;which Payload runtime should own it?&amp;quot;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Web Runtime vs Worker Runtime&lt;/h2&gt;
&lt;p&gt;Payload&amp;#39;s jobs system is designed around role separation. The web runtime handles the admin UI, REST, GraphQL, and fast document operations. The worker runtime executes queued tasks and workflows. Payload explicitly recommends bin scripts for dedicated worker servers because they run in a separate process from the Next.js server, which makes them easier to deploy, monitor, and scale independently.&lt;/p&gt;
&lt;p&gt;In practice, the setup is straightforward. You use the same codebase — often the same Docker image — and initialize it differently depending on the role. One container starts the web app. Another starts the job runner, optionally with scheduling enabled. Payload&amp;#39;s deployment examples show this directly, with a main app process and separate worker processes assigned to specific queues.&lt;/p&gt;
&lt;p&gt;The conceptual shift here is important. This is not about maintaining two separate applications. It is about running one application in two different modes.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Responsibilities&lt;/th&gt;
&lt;th&gt;Performance Profile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Web runtime&lt;/td&gt;
&lt;td&gt;Admin UI, REST, GraphQL, document reads/writes&lt;/td&gt;
&lt;td&gt;Low latency, stable memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker runtime&lt;/td&gt;
&lt;td&gt;Queued tasks, scheduled workflows, heavy processing&lt;/td&gt;
&lt;td&gt;Burst CPU, longer execution windows, retry tolerance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Why Compute Separation Is the Real Win&lt;/h2&gt;
&lt;p&gt;Moving the worker onto separate compute is where the architecture pays off. You can deploy it as another container, another VPS, another ECS service, or another Kubernetes deployment. That gives your web tier predictable latency because it no longer shares resources with long-running jobs.&lt;/p&gt;
&lt;p&gt;Your web tier and your worker tier have different performance profiles and different scaling needs. The web tier wants consistent, low-latency responses. The worker tier needs burst CPU, tolerance for longer execution, and more headroom for retries. Forcing one runtime to serve both equally well means neither performs optimally. With the roles split, you can scale each tier horizontally based on what it actually needs.&lt;/p&gt;
&lt;p&gt;Payload&amp;#39;s queue design supports exactly this. Workers are independent processes. They can be deployed separately, assigned to specific queues, and scaled without touching the web tier.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Database Is Still the Shared Bottleneck&lt;/h2&gt;
&lt;p&gt;Separating compute does not eliminate all pressure points. Payload&amp;#39;s job system works by queuing work into the database and having workers pick it up and execute it. That shared persistence is what makes independent workers possible — and it also means the database remains the coordination layer for the whole system.&lt;/p&gt;
&lt;p&gt;You can isolate CPU-intensive execution away from the web app completely, but if too many jobs write aggressively, lock data frequently, or create excessive transaction churn, the database becomes the limiting factor. Isolating compute is a meaningful gain. Understanding that the database is still shared is the realistic counterpart to that gain.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Concurrency Keys: Safe Scale Across Multiple Workers&lt;/h2&gt;
&lt;p&gt;In a single-worker setup, job sequencing happens naturally. There is only one active worker loop, so jobs run one after another. As soon as you add more workers, you introduce the possibility that two valid jobs run at the same time against the same resource.&lt;/p&gt;
&lt;p&gt;Payload&amp;#39;s concurrency model solves this by letting you define a concurrency key per job. When two jobs share the same key, Payload guarantees they run sequentially. The key is stored when the job is queued, and the runner excludes jobs whose key is already being processed. If multiple pending jobs with the same key are picked up in one batch, only the first runs. The rest are released to wait for a later pass.&lt;/p&gt;
&lt;p&gt;This is especially relevant for tenant-scoped imports and syncs. Say you have two workers and two queued imports for the same tenant. Without a concurrency key, both workers might process those jobs simultaneously. The jobs are not duplicates — they are different jobs — but they operate on the same tenant data. That is a resource-collision problem. A shared key like &lt;code&gt;import:tenant-123&lt;/code&gt; tells Payload those jobs belong to the same protected lane and must be serialized. Jobs for a different tenant use a different key and can still run in parallel.&lt;/p&gt;
&lt;p&gt;The important design decision here is that the concurrency key should be shared by jobs that touch the same resource — not unique per job. Unique keys provide no protection at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Tasks vs Workflows: Choosing the Right Model&lt;/h2&gt;
&lt;p&gt;Payload describes a task as one isolated unit of business logic. A workflow is an ordered group of tasks that can be retried from a specific point of failure rather than from the beginning.&lt;/p&gt;
&lt;p&gt;Tasks are a good fit for simple, atomic background work. Workflows become more valuable when a process has multiple dependent stages — fetch, transform, write, finalize — and you care about durable resumption. If a workflow fails on the &amp;quot;write&amp;quot; step, it can resume from there rather than restarting the entire sequence.&lt;/p&gt;
&lt;p&gt;For most straightforward background jobs, tasks are sufficient. For anything multi-stage where partial completion has meaningful value, workflows are the right model.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need separate infrastructure to separate web and worker roles?&lt;/strong&gt;
You do not need a fundamentally different infrastructure setup. The simplest separation is running two containers from the same Docker image with different start commands — one for the web app and one for the worker. The value scales up from there if you move to separate VPS instances or managed container services.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if I run the worker on the same container as the web app?&lt;/strong&gt;
You will still benefit from the queue architecture and async execution, but the web app and worker will compete for the same CPU, memory, and I/O. Under heavy job load, you can see degraded web app response times even though the jobs are technically non-blocking.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I run multiple worker processes against the same queue?&lt;/strong&gt;
Yes, and Payload&amp;#39;s queue system is designed to support it. Each worker independently polls the database for jobs. You can run as many worker processes as you need across separate containers or servers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How does Payload prevent two workers from picking up the same job?&lt;/strong&gt;
Payload uses database-level locking when a worker picks up a job, which prevents other workers from claiming the same job. This is how the queue remains safe across multiple concurrent workers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When should I use a workflow instead of a task?&lt;/strong&gt;
Use a workflow when the background process has multiple dependent stages and you want durable resumption on failure. If the entire job is a single atomic operation, a task is simpler and sufficient.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The practical takeaway from Payload&amp;#39;s jobs system is that it gives you a clean way to separate concerns operationally and scale each concern on its own terms. The web app serves users. Workers handle asynchronous load. The database acts as the shared coordination layer. Concurrency keys protect shared resources when multiple workers run in parallel. And workflows give you a more durable model for complex multi-stage processes.&lt;/p&gt;
&lt;p&gt;If you are seeing performance pressure in your Payload app during heavy background work, the architecture is telling you something. The jobs system is built for separation. Using it that way is the intended path.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/payload-cms-jobs-separate-web-worker-roles-safe-scale"/>
        </item>
        <item>
            <title><![CDATA[Stop Runtime Payload Migrations in Distributed Systems]]></title>
            <description><![CDATA[<p>If you are deploying Payload CMS with Postgres on Kubernetes, AWS ECS, or any environment with multiple running instances, migrations should run once per deployment in a controlled step — not on every app startup. Payload supports runtime production migrations through <code>prodMigrations</code>, but that feature is designed for single-instance environments. On distributed infrastructure, letting every replica attempt migrations at boot creates race conditions, startup contention, and deployment fragility. This article explains the problem, the right mental model, and where <code>prodMigrations</code> is actually appropriate.</p>
<p>I ran into this question while setting up a Payload project on ECS. The <code>prodMigrations</code> option looked like the obvious thing to reach for, and the docs did not immediately make clear why that would be a problem at scale. After thinking through how ECS tasks start up and what happens when you have multiple of them, the issue became obvious — and the fix was simpler than I expected.</p>
<h2>Schema Migrations Are a Deployment Step, Not a Startup Side Effect</h2>
<p>The core confusion is treating schema migrations and application startup as the same concern. They are not.</p>
<p>A schema migration changes the shape of the database — adding columns, renaming fields, modifying indexes, restructuring tables. That work belongs to the deployment lifecycle. Your running application replicas should assume the database is already at the correct schema version when they start. They should not be responsible for establishing it.</p>
<p>Once you hold that framing, the runtime migration pattern becomes obviously wrong in distributed setups. Multiple containers or Pods starting at roughly the same time will each attempt to run migrations against the same database. Even if Payload&#39;s migration system handles some failure cases gracefully, you are now depending on startup ordering and shared database state to prevent conflicts. That is not a pattern you want in production.</p>
<h2>The Kubernetes and ECS Specifics</h2>
<p>In Kubernetes, init containers run per Pod, not once globally for the cluster. If your Deployment scales to three replicas, all three get their own init sequence. A Kubernetes Job, by contrast, runs to completion — it is the right primitive for one-off work like a migration.</p>
<p>In ECS, the same distinction applies. An ECS service is for long-running replicated tasks. A standalone ECS task runs and stops. Migrations belong in the standalone task, not in the startup path of every service task.</p>
<p>The practical rule is:</p>
<p><strong>One migration runner per deployment. All application replicas start only after migrations succeed.</strong></p>
<p>Where that runner lives depends on your platform:</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Migration runner location</th>
</tr>
</thead>
<tbody><tr>
<td>Kubernetes</td>
<td>Kubernetes Job before Deployment rollout</td>
</tr>
<tr>
<td>AWS ECS</td>
<td>Standalone ECS task in the deploy pipeline</td>
</tr>
<tr>
<td>Docker Compose</td>
<td><code>command</code> override in a one-off service before app starts</td>
</tr>
<tr>
<td>CI/CD pipeline</td>
<td>Pre-deploy step with DB access before build or push</td>
</tr>
<tr>
<td>Single VM or container</td>
<td><code>prodMigrations</code> is acceptable here</td>
</tr>
</tbody></table>
<h2>When <code>prodMigrations</code> Actually Makes Sense</h2>
<p>Payload includes <code>prodMigrations</code> specifically for cases where build-time database access is not available, or where a single process genuinely owns startup. It is a reasonable option when all of the following are true: you have exactly one application instance, startup-time migration is acceptable, and you are not in a multi-replica race at boot.</p>
<p>Environments where <code>prodMigrations</code> is a reasonable default:</p>
<ul>
<li>a single Docker container</li>
<li>a single VM or long-running Node process</li>
<li>a tightly controlled internal environment with one startup authority</li>
</ul>
<p>Environments where it should not be your default:</p>
<ul>
<li>Kubernetes Deployments with multiple replicas</li>
<li>ECS services with multiple tasks</li>
<li>autoscaled containers</li>
<li>blue-green or rolling deployments</li>
<li>any serverless-style cold-start environment (Payload&#39;s docs explicitly flag this)</li>
</ul>
<h2>What Payload Recommends</h2>
<p>Payload&#39;s own guidance reflects this split cleanly. For local development, Drizzle push mode keeps your dev schema in sync while you iterate fast. Once the feature is ready, you generate migration files, commit them, and run them in shared or production environments. Payload also recommends running migrations in CI before the build when DB access is available, and notes that <code>prodMigrations</code> exists mainly as a fallback for when it is not.</p>
<p>The intended split is:</p>
<ul>
<li><strong>development</strong> — fast schema iteration with push mode</li>
<li><strong>deployment</strong> — explicit migration execution in a controlled step</li>
<li><strong>runtime</strong> — serve traffic, not mutate schema</li>
</ul>
<h2>Implementing the One-Runner Pattern</h2>
<p>The implementation varies by platform, but the principle is always the same: run <code>payload migrate</code> once before your replicas scale up, and gate the rollout on migration success.</p>
<p>For a Kubernetes Job running before a Deployment:</p>
<pre><code class="language-yaml"># File: k8s/migration-job.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: payload-migrate
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: your-app-image:latest
          command: [&quot;node&quot;, &quot;dist/payload&quot;, &quot;migrate&quot;]
          env:
            - name: DATABASE_URI
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: uri
      restartPolicy: Never
  backoffLimit: 0
</code></pre>
<p>Run this Job in your CI pipeline before applying the Deployment manifest. The Deployment rollout only proceeds after the Job completes successfully.</p>
<p>For ECS, the equivalent is a <code>run-task</code> call in your deploy pipeline before updating the service:</p>
<pre><code class="language-bash"># File: deploy.sh

aws ecs run-task \
  --cluster your-cluster \
  --task-definition payload-migrate \
  --launch-type FARGATE \
  --network-configuration &quot;...&quot;

# Wait for task to complete, then update the service
aws ecs update-service \
  --cluster your-cluster \
  --service your-service \
  --force-new-deployment
</code></pre>
<p>In both cases, the app replicas start already knowing the schema is correct. They do not race to establish it.</p>
<h2>FAQ</h2>
<p><strong>Can I just use a database lock to prevent concurrent migrations?</strong></p>
<p>Some migration frameworks use advisory locks to prevent concurrent runs. Payload&#39;s migration system does offer some protection, but relying on lock behavior in a startup race is operational complexity you do not need. Running migrations in a controlled one-off step is simpler and more explicit.</p>
<p><strong>What if my CI environment does not have database access?</strong></p>
<p>That is the exact case Payload designed <code>prodMigrations</code> for. If you cannot reach the database at build or deploy time, a controlled pre-start migration step is the next best option — either a Kubernetes init Job, an ECS task, or a startup script that runs before replicas come online.</p>
<p><strong>Should I commit Payload migration files to the repository?</strong></p>
<p>Yes. Payload migration files are ordered deploy artifacts with <code>up</code> and <code>down</code> paths. Treat them like release artifacts — generate, review, commit, and execute in a controlled step. Never skip committing them.</p>
<p><strong>Does this apply to local development with Docker Compose?</strong></p>
<p>In a single-developer local environment the risk is low, but the habit is still useful. You can add a migration service to your Compose file that runs and exits before the app service starts, using <code>depends_on</code> with <code>condition: service_completed_successfully</code>.</p>
<p><strong>What happens if a migration fails in the pipeline?</strong></p>
<p>The deploy stops. That is the right behavior. Payload migration files include <code>down</code> paths, so you can roll back if needed. A failed migration surfacing in the deploy pipeline is far better than a failed migration surfacing inside a running production container.</p>
<h2>Conclusion</h2>
<p>In distributed Payload deployments, migrations belong in a controlled deployment step — not in the startup path of every replica. Running <code>payload migrate</code> once per deployment, before replicas scale up, eliminates startup race conditions and keeps schema readiness as an explicit operational concern. Reserve <code>prodMigrations</code> for single-instance environments where one process genuinely owns startup. For everything else, treat migrations the same way you treat any other release artifact: controlled, sequential, and gated.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical Payload and Next.js guides.</p>
<p>Thanks, 
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/stop-runtime-payload-migrations</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/stop-runtime-payload-migrations</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Fri, 10 Apr 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you are deploying Payload CMS with Postgres on Kubernetes, AWS ECS, or any environment with multiple running instances, migrations should run once per deployment in a controlled step — not on every app startup. Payload supports runtime production migrations through &lt;code&gt;prodMigrations&lt;/code&gt;, but that feature is designed for single-instance environments. On distributed infrastructure, letting every replica attempt migrations at boot creates race conditions, startup contention, and deployment fragility. This article explains the problem, the right mental model, and where &lt;code&gt;prodMigrations&lt;/code&gt; is actually appropriate.&lt;/p&gt;
&lt;p&gt;I ran into this question while setting up a Payload project on ECS. The &lt;code&gt;prodMigrations&lt;/code&gt; option looked like the obvious thing to reach for, and the docs did not immediately make clear why that would be a problem at scale. After thinking through how ECS tasks start up and what happens when you have multiple of them, the issue became obvious — and the fix was simpler than I expected.&lt;/p&gt;
&lt;h2&gt;Schema Migrations Are a Deployment Step, Not a Startup Side Effect&lt;/h2&gt;
&lt;p&gt;The core confusion is treating schema migrations and application startup as the same concern. They are not.&lt;/p&gt;
&lt;p&gt;A schema migration changes the shape of the database — adding columns, renaming fields, modifying indexes, restructuring tables. That work belongs to the deployment lifecycle. Your running application replicas should assume the database is already at the correct schema version when they start. They should not be responsible for establishing it.&lt;/p&gt;
&lt;p&gt;Once you hold that framing, the runtime migration pattern becomes obviously wrong in distributed setups. Multiple containers or Pods starting at roughly the same time will each attempt to run migrations against the same database. Even if Payload&amp;#39;s migration system handles some failure cases gracefully, you are now depending on startup ordering and shared database state to prevent conflicts. That is not a pattern you want in production.&lt;/p&gt;
&lt;h2&gt;The Kubernetes and ECS Specifics&lt;/h2&gt;
&lt;p&gt;In Kubernetes, init containers run per Pod, not once globally for the cluster. If your Deployment scales to three replicas, all three get their own init sequence. A Kubernetes Job, by contrast, runs to completion — it is the right primitive for one-off work like a migration.&lt;/p&gt;
&lt;p&gt;In ECS, the same distinction applies. An ECS service is for long-running replicated tasks. A standalone ECS task runs and stops. Migrations belong in the standalone task, not in the startup path of every service task.&lt;/p&gt;
&lt;p&gt;The practical rule is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One migration runner per deployment. All application replicas start only after migrations succeed.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Where that runner lives depends on your platform:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Migration runner location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;Kubernetes Job before Deployment rollout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS ECS&lt;/td&gt;
&lt;td&gt;Standalone ECS task in the deploy pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Compose&lt;/td&gt;
&lt;td&gt;&lt;code&gt;command&lt;/code&gt; override in a one-off service before app starts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD pipeline&lt;/td&gt;
&lt;td&gt;Pre-deploy step with DB access before build or push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single VM or container&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prodMigrations&lt;/code&gt; is acceptable here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;When &lt;code&gt;prodMigrations&lt;/code&gt; Actually Makes Sense&lt;/h2&gt;
&lt;p&gt;Payload includes &lt;code&gt;prodMigrations&lt;/code&gt; specifically for cases where build-time database access is not available, or where a single process genuinely owns startup. It is a reasonable option when all of the following are true: you have exactly one application instance, startup-time migration is acceptable, and you are not in a multi-replica race at boot.&lt;/p&gt;
&lt;p&gt;Environments where &lt;code&gt;prodMigrations&lt;/code&gt; is a reasonable default:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a single Docker container&lt;/li&gt;
&lt;li&gt;a single VM or long-running Node process&lt;/li&gt;
&lt;li&gt;a tightly controlled internal environment with one startup authority&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Environments where it should not be your default:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Kubernetes Deployments with multiple replicas&lt;/li&gt;
&lt;li&gt;ECS services with multiple tasks&lt;/li&gt;
&lt;li&gt;autoscaled containers&lt;/li&gt;
&lt;li&gt;blue-green or rolling deployments&lt;/li&gt;
&lt;li&gt;any serverless-style cold-start environment (Payload&amp;#39;s docs explicitly flag this)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Payload Recommends&lt;/h2&gt;
&lt;p&gt;Payload&amp;#39;s own guidance reflects this split cleanly. For local development, Drizzle push mode keeps your dev schema in sync while you iterate fast. Once the feature is ready, you generate migration files, commit them, and run them in shared or production environments. Payload also recommends running migrations in CI before the build when DB access is available, and notes that &lt;code&gt;prodMigrations&lt;/code&gt; exists mainly as a fallback for when it is not.&lt;/p&gt;
&lt;p&gt;The intended split is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;development&lt;/strong&gt; — fast schema iteration with push mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;deployment&lt;/strong&gt; — explicit migration execution in a controlled step&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;runtime&lt;/strong&gt; — serve traffic, not mutate schema&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Implementing the One-Runner Pattern&lt;/h2&gt;
&lt;p&gt;The implementation varies by platform, but the principle is always the same: run &lt;code&gt;payload migrate&lt;/code&gt; once before your replicas scale up, and gate the rollout on migration success.&lt;/p&gt;
&lt;p&gt;For a Kubernetes Job running before a Deployment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# File: k8s/migration-job.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: payload-migrate
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: your-app-image:latest
          command: [&amp;quot;node&amp;quot;, &amp;quot;dist/payload&amp;quot;, &amp;quot;migrate&amp;quot;]
          env:
            - name: DATABASE_URI
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: uri
      restartPolicy: Never
  backoffLimit: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this Job in your CI pipeline before applying the Deployment manifest. The Deployment rollout only proceeds after the Job completes successfully.&lt;/p&gt;
&lt;p&gt;For ECS, the equivalent is a &lt;code&gt;run-task&lt;/code&gt; call in your deploy pipeline before updating the service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# File: deploy.sh

aws ecs run-task \
  --cluster your-cluster \
  --task-definition payload-migrate \
  --launch-type FARGATE \
  --network-configuration &amp;quot;...&amp;quot;

# Wait for task to complete, then update the service
aws ecs update-service \
  --cluster your-cluster \
  --service your-service \
  --force-new-deployment
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In both cases, the app replicas start already knowing the schema is correct. They do not race to establish it.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Can I just use a database lock to prevent concurrent migrations?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Some migration frameworks use advisory locks to prevent concurrent runs. Payload&amp;#39;s migration system does offer some protection, but relying on lock behavior in a startup race is operational complexity you do not need. Running migrations in a controlled one-off step is simpler and more explicit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if my CI environment does not have database access?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That is the exact case Payload designed &lt;code&gt;prodMigrations&lt;/code&gt; for. If you cannot reach the database at build or deploy time, a controlled pre-start migration step is the next best option — either a Kubernetes init Job, an ECS task, or a startup script that runs before replicas come online.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Should I commit Payload migration files to the repository?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes. Payload migration files are ordered deploy artifacts with &lt;code&gt;up&lt;/code&gt; and &lt;code&gt;down&lt;/code&gt; paths. Treat them like release artifacts — generate, review, commit, and execute in a controlled step. Never skip committing them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this apply to local development with Docker Compose?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In a single-developer local environment the risk is low, but the habit is still useful. You can add a migration service to your Compose file that runs and exits before the app service starts, using &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_completed_successfully&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if a migration fails in the pipeline?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The deploy stops. That is the right behavior. Payload migration files include &lt;code&gt;down&lt;/code&gt; paths, so you can roll back if needed. A failed migration surfacing in the deploy pipeline is far better than a failed migration surfacing inside a running production container.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;In distributed Payload deployments, migrations belong in a controlled deployment step — not in the startup path of every replica. Running &lt;code&gt;payload migrate&lt;/code&gt; once per deployment, before replicas scale up, eliminates startup race conditions and keeps schema readiness as an explicit operational concern. Reserve &lt;code&gt;prodMigrations&lt;/code&gt; for single-instance environments where one process genuinely owns startup. For everything else, treat migrations the same way you treat any other release artifact: controlled, sequential, and gated.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical Payload and Next.js guides.&lt;/p&gt;
&lt;p&gt;Thanks, 
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/stop-runtime-payload-migrations"/>
        </item>
        <item>
            <title><![CDATA[TanStack Start vs Next.js 16: Ultimate Comparison 2026]]></title>
            <description><![CDATA[<p>For the past few years, the answer to &quot;what full-stack React framework should we use?&quot; was simple: Next.js. Not because it was perfect, but because nothing else was close enough to justify the risk of going a different direction.</p>
<p>That&#39;s started to change. TanStack Start hit v1 RC in late 2025 with a clear architectural philosophy, a production-capable feature set, and a growing number of developers who are seriously asking whether they still need Next.js. The question is no longer hypothetical.</p>
<p>I&#39;ve been building with Next.js since the Pages Router era and I&#39;ve watched the App Router evolve from an interesting bet into the default for serious projects. I&#39;ve also been paying close attention to TanStack Start since Tanner Linsley&#39;s team began pushing it toward stability. This article is my honest take on where each framework wins, where each creates friction, and how to think about the decision if you&#39;re choosing right now.</p>
<p>This is not a beginner&#39;s guide to either framework. If you need a &quot;what is React Server Components&quot; explainer, this isn&#39;t the place. This is for developers and tech leads who already know both names and need a structured way to think about the tradeoff.</p>
<hr>
<h2>What TanStack Start Actually Is (and What It Isn&#39;t)</h2>
<p>TanStack Start is a full-stack React meta-framework built on top of TanStack Router, using Vite as its build tool and Vinxi as its bundler abstraction. It reached v1 RC on September 22, 2025 — feature-complete and dependency-locked, but not yet fully stable v1.</p>
<p>The important clarification: TanStack Start is not &quot;TanStack Router with a server bolted on.&quot; It&#39;s a complete rethink of how server rendering should work for teams who want full-stack capabilities without adopting React Server Components as their primary mental model. The server is a capability you reach for explicitly, not the default rendering context for every component.</p>
<p>It supports streaming SSR, full-document rendering, Suspense, and parallel data fetching through isomorphic loaders and server functions. Deployment targets are broad — Vercel, Netlify, Railway, bare Node, Docker, Cloudflare Workers. The Vite foundation means dev server performance that Next.js has historically struggled to match.</p>
<p>What it isn&#39;t yet: a framework with the production battle-testing and ecosystem depth of Next.js. More on what that means in practice shortly.</p>
<hr>
<h2>The Philosophical Split: RSC-First vs Client-First with SSR</h2>
<p>This is the core difference, and most comparisons either miss it or describe it in terms of syntax rather than architecture. Getting this wrong leads to choosing the wrong framework for reasons that won&#39;t hold up six months into a project.</p>
<p><strong>Next.js 16 is RSC-first.</strong> Components are server-rendered by default. The App Router treats the server as the primary execution environment and requires you to opt into client behaviour with <code>&#39;use client&#39;</code>. This model optimises for shipping less JavaScript to the browser and keeping data-fetching co-located with the components that need it. When it works well, it genuinely reduces bundle sizes and simplifies data architecture. When it doesn&#39;t, you spend time debugging where a component is running, why a state update isn&#39;t triggering what you expected, and why your third-party library broke because it assumed <code>window</code> would be available.</p>
<p><strong>TanStack Start is client-first with explicit server capability.</strong> The default mental model is a client-side React application. You reach for server execution explicitly, through <code>createServerFn</code> — a typed RPC-style API where you define server functions with explicit HTTP methods, validators, and middleware. The client-server boundary is something you draw deliberately, not something the framework infers from file structure and directives.</p>
<p>This is a philosophical difference as much as a technical one. Next.js App Router asks you to think in terms of server components and client components as two distinct component types that compose together. TanStack Start asks you to think in terms of a client application that makes typed calls to server endpoints. If your team has been writing React for years and finds the RSC mental model genuinely friction-heavy rather than just unfamiliar, TanStack Start&#39;s approach will feel more natural.</p>
<p>Neither model is objectively better. They optimise for different things. RSC-first is better when you want server rendering to be pervasive and low-ceremony. Client-first with explicit server functions is better when you want predictability, transparency, and explicit control over where code runs.</p>
<hr>
<h2>Routing and Data Fetching Compared</h2>
<p>Both frameworks use file-based routing. The similarity mostly ends there.</p>
<p><strong>TanStack Router</strong> (the routing layer inside TanStack Start) enforces type safety at the routing layer with compile-time param validation. Route params, search params, and loader data are all fully typed — not inferred at runtime, but validated at build time. If a route expects an <code>id</code> param and you pass something that doesn&#39;t match the schema, you get a type error before deployment. This is a genuine differentiator for large codebases where route-level bugs are expensive to catch.</p>
<p><strong>Next.js App Router</strong> uses file-based conventions with <code>page.tsx</code>, <code>layout.tsx</code>, <code>loading.tsx</code>, and the rest of its directory-based routing model. It&#39;s expressive and familiar, but it carries implicit behaviours — particularly around caching — that have been the source of significant developer frustration. Revalidation bugs, unexpected staleness, and the general opacity of the caching model have been recurring pain points since App Router became stable. Next.js 16 has improved this, but the underlying complexity is structural.</p>
<p>On data fetching: TanStack Start uses <strong>isomorphic loaders</strong> that run on the server during SSR and in the browser after hydration. They integrate naturally with TanStack Query, which means you get the full cache and background refetch behaviour of React Query without a separate data layer. <code>createServerFn</code> handles mutations and server-side logic with explicit type safety — you specify the HTTP method, define a validator (Zod or otherwise), and get full type inference on both ends of the call.</p>
<p>Next.js Server Actions use a <code>&#39;use server&#39;</code> directive and default to POST for mutations. They&#39;re simpler to set up for straightforward cases but less explicit about what&#39;s happening at the HTTP level. For teams that want to understand and control their network layer, TanStack Start&#39;s approach is easier to reason about. For teams that want to move fast without thinking about HTTP semantics, Server Actions are genuinely less friction.</p>
<p>If you&#39;re working through Next.js serialisation issues with complex data types, the patterns in <a href="/blog/nextjs-server-client-serialization">this guide on server-to-client serialisation in Next.js</a> illustrate exactly the kind of complexity the TanStack data model is designed to avoid.</p>
<hr>
<h2>Production Readiness — What &quot;RC&quot; Actually Means for Your Project</h2>
<p>TanStack Start being in RC status deserves an honest assessment rather than a dismissal or a handwave.</p>
<p>RC in the context of TanStack Start means: the API is locked, dependencies are pinned, the feature set is complete, and the team considers it stable for production use. It does not mean it has years of production battle-testing behind it, a rich ecosystem of third-party integrations, or a large community of Stack Overflow answers to lean on when something breaks.</p>
<p>In practice, this matters in specific ways. The deployment story is solid — deploying anywhere Node runs works correctly, and the Vite foundation means builds are fast and predictable. Performance on the server rendering path is competitive with Next.js. One team&#39;s migration from Next.js to TanStack Start (Inngest) reported 83% faster local dev times, which is a meaningful productivity signal even if it&#39;s a single data point.</p>
<p>Where ecosystem thinness shows up: third-party library compatibility, auth library support depth, and integration with CMS platforms. The <a href="/blog/nextjs-searchparams-static-generation-fix">Next.js App Router architectural patterns</a> that are well-documented and community-tested simply don&#39;t have equivalents in the TanStack Start ecosystem yet.</p>
<p>The honest summary: RC means you can ship production systems with it today, but you&#39;ll hit underdocumented edges, you&#39;ll have fewer community resources when you do, and you&#39;ll be making architectural decisions without the benefit of collective scar tissue from thousands of production deployments. For a team with strong senior engineers who are comfortable navigating undocumented territory, this is manageable. For a team that relies on ecosystem depth and community answers, it&#39;s a real risk.</p>
<hr>
<h2>Where Next.js 16 Still Wins</h2>
<p>Next.js 16, released October 21 2025, shipped Turbopack as the stable default bundler — a genuine improvement to dev performance that partly closes the gap TanStack Start&#39;s Vite foundation had. React Compiler support is now stable, and React 19.2 integration is deep. The App Router is mature enough that the worst early roughness has been sanded down.</p>
<p>More importantly: the ecosystem is vast. Auth libraries, CMS integrations, monitoring tools, and deployment platforms have all invested in Next.js specifically. <a href="/blog/nextjs-authentication-choose-right-strategy">Authentication patterns are well-established</a>, <a href="/blog/nextjs-internationalization-architecture-guide">internationalisation is well-understood</a>, and the number of senior developers who have Next.js production experience means hiring and team scaling is easier.</p>
<p>For content-heavy systems, Next.js still has a structural advantage. Its RSC model keeps pages lean by default, which matters for SEO-critical public websites where JavaScript payload directly affects core web vitals. The Pages Router is still supported for teams that need a migration path without abandoning the framework entirely.</p>
<p>If your project involves a headless CMS, Next.js wins clearly at the integration layer. The ecosystem has converged on Next.js as the default target — Payload CMS, Sanity, Contentful, and the rest all treat Next.js as the primary integration surface.</p>
<hr>
<h2>Where TanStack Start Wins</h2>
<p>For teams building applications rather than content sites — dashboards, SaaS products, internal tools — the TanStack Start philosophy aligns more naturally with how complex application state actually works.</p>
<p>Type safety at the routing layer is the most underrated advantage. When route params, search params, and loader data are all compile-time typed, a category of runtime bugs simply stops existing. For large applications with complex navigation and deep linking, this is meaningful correctness that Next.js App Router doesn&#39;t offer at the same level.</p>
<p>Explicit server boundaries reduce the debugging overhead of the &quot;is this component running on the server or the client?&quot; question that App Router creates. When you call a server function, you know you&#39;re calling a server function. There&#39;s no directive to misplace and no implicit serialisation to debug. The mental model is closer to how developers who grew up with REST APIs think about client-server communication.</p>
<p>Deployment flexibility matters for teams that want to avoid optimising for Vercel&#39;s infrastructure. TanStack Start builds produce standard Node output that runs correctly anywhere — no platform-specific edge cases, no implicit optimisations that only work on one provider.</p>
<p>And Vite&#39;s dev server speed is still faster than Turbopack in most real-world configurations. For teams doing active development on large codebases, the day-to-day productivity difference is real.</p>
<hr>
<h2>Choose TanStack Start If... / Choose Next.js 16 If...</h2>
<p><strong>Choose TanStack Start if:</strong></p>
<ul>
<li>You&#39;re building an application (dashboard, SaaS, internal tool) rather than a content site</li>
<li>Your team finds RSC mental model creates more confusion than it solves</li>
<li>Type-safe routing is a priority and you want compile-time guarantees on params and search state</li>
<li>You&#39;re deploying to non-Vercel infrastructure and want to avoid platform-specific behaviour</li>
<li>Your team has strong senior engineers comfortable with thinner ecosystem coverage</li>
<li>You&#39;re coming from a React SPA background and want SSR without re-learning the rendering model from scratch</li>
</ul>
<p><strong>Choose Next.js 16 if:</strong></p>
<ul>
<li>You&#39;re building a content-heavy public site where SEO and core web vitals are critical</li>
<li>You&#39;re integrating with a headless CMS — particularly Payload, Sanity, or Contentful</li>
<li>Your team needs to hire developers with existing framework experience</li>
<li>You need auth, i18n, and monitoring integrations with minimal custom configuration</li>
<li>You need the confidence that comes from years of production case studies and community answers</li>
<li>Your project requires the full depth of the React 19 ecosystem working together with server rendering</li>
</ul>
<p>The decision is rarely about which framework is technically superior in the abstract. It&#39;s about which framework&#39;s constraints align with your team&#39;s strengths and your project&#39;s requirements.</p>
<hr>
<h2>What This Means If You&#39;re Using Payload CMS</h2>
<p>This section matters specifically for anyone building Payload-backed systems, because the compatibility picture is not symmetric.</p>
<p>Payload CMS is architecturally native to Next.js. From v3.0 onwards, Payload runs inside a Next.js application — the same Next.js project hosts both the admin panel and the public frontend. The official <code>withPayload</code> plugin handles all the build wiring, and Turbopack compatibility was formally added in Next.js 16 (PR #14456). For teams building Payload-powered websites and applications, this is a deep, well-tested integration that removes an entire class of configuration problems.</p>
<p>TanStack Start can work alongside Payload CMS, but not in the same native way. Because TanStack Start uses Vite and Vinxi as its build system, you hit bundler conflicts when trying to run Payload in the same project — Vite&#39;s module resolution handles some file types differently from the webpack-based build Payload expects. The workable approach is a monorepo setup where Payload runs in a separate Next.js application and TanStack Start consumes it as an API. This is architecturally reasonable but it&#39;s a meaningfully higher integration overhead than the native approach.</p>
<p>For the kind of systems described in <a href="/blog/payload-cms-collection-structure-best-practices">how to structure Payload CMS collections for maintainability</a> — structured knowledge bases, customer-facing sites with semantic search, AI-powered interfaces — the native Next.js integration is a real advantage. The <a href="/blog/best-headless-cms-nextjs-2026-decision-framework">best headless CMS guide for Next.js in 2026</a> covers this in more detail for teams still evaluating the CMS layer.</p>
<p>If Payload is your CMS, Next.js is still the correct framework choice until TanStack Start&#39;s Vite compatibility story matures.</p>
<hr>
<h2>The Honest Summary</h2>
<p>TanStack Start is the most interesting architectural challenge to Next.js dominance that exists today. It makes a coherent argument — explicit server boundaries, type-safe routing, Vite performance, deploy-anywhere flexibility — and the argument is good enough that teams should take it seriously rather than dismissing it as an early experiment.</p>
<p>Next.js 16 remains the safer choice for most production systems, particularly content-heavy sites, CMS-backed applications, and teams that need ecosystem depth to move fast. The RSC model has matured significantly, Turbopack is now genuinely fast, and the collective knowledge base that comes from years of widespread production use is not something to underestimate.</p>
<p>The framework worth watching: TanStack Start will close the ecosystem gap over the next twelve to eighteen months. If type-safe routing and explicit server architecture resonate with how your team thinks, building familiarity with it now makes sense. If you&#39;re shipping a production system today and need confidence at every layer, Next.js 16 is still the right bet.</p>
<p>If you&#39;re making this architectural decision for a system that needs to handle real operational complexity — structured data, AI interfaces, workflow automation — and you want a senior perspective on getting the architecture right before writing code, feel free to <a href="/contact">reach out</a>.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/tanstack-start-vs-nextjs-16-comparison</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/tanstack-start-vs-nextjs-16-comparison</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Thu, 09 Apr 2026 05:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;For the past few years, the answer to &amp;quot;what full-stack React framework should we use?&amp;quot; was simple: Next.js. Not because it was perfect, but because nothing else was close enough to justify the risk of going a different direction.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s started to change. TanStack Start hit v1 RC in late 2025 with a clear architectural philosophy, a production-capable feature set, and a growing number of developers who are seriously asking whether they still need Next.js. The question is no longer hypothetical.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve been building with Next.js since the Pages Router era and I&amp;#39;ve watched the App Router evolve from an interesting bet into the default for serious projects. I&amp;#39;ve also been paying close attention to TanStack Start since Tanner Linsley&amp;#39;s team began pushing it toward stability. This article is my honest take on where each framework wins, where each creates friction, and how to think about the decision if you&amp;#39;re choosing right now.&lt;/p&gt;
&lt;p&gt;This is not a beginner&amp;#39;s guide to either framework. If you need a &amp;quot;what is React Server Components&amp;quot; explainer, this isn&amp;#39;t the place. This is for developers and tech leads who already know both names and need a structured way to think about the tradeoff.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What TanStack Start Actually Is (and What It Isn&amp;#39;t)&lt;/h2&gt;
&lt;p&gt;TanStack Start is a full-stack React meta-framework built on top of TanStack Router, using Vite as its build tool and Vinxi as its bundler abstraction. It reached v1 RC on September 22, 2025 — feature-complete and dependency-locked, but not yet fully stable v1.&lt;/p&gt;
&lt;p&gt;The important clarification: TanStack Start is not &amp;quot;TanStack Router with a server bolted on.&amp;quot; It&amp;#39;s a complete rethink of how server rendering should work for teams who want full-stack capabilities without adopting React Server Components as their primary mental model. The server is a capability you reach for explicitly, not the default rendering context for every component.&lt;/p&gt;
&lt;p&gt;It supports streaming SSR, full-document rendering, Suspense, and parallel data fetching through isomorphic loaders and server functions. Deployment targets are broad — Vercel, Netlify, Railway, bare Node, Docker, Cloudflare Workers. The Vite foundation means dev server performance that Next.js has historically struggled to match.&lt;/p&gt;
&lt;p&gt;What it isn&amp;#39;t yet: a framework with the production battle-testing and ecosystem depth of Next.js. More on what that means in practice shortly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Philosophical Split: RSC-First vs Client-First with SSR&lt;/h2&gt;
&lt;p&gt;This is the core difference, and most comparisons either miss it or describe it in terms of syntax rather than architecture. Getting this wrong leads to choosing the wrong framework for reasons that won&amp;#39;t hold up six months into a project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next.js 16 is RSC-first.&lt;/strong&gt; Components are server-rendered by default. The App Router treats the server as the primary execution environment and requires you to opt into client behaviour with &lt;code&gt;&amp;#39;use client&amp;#39;&lt;/code&gt;. This model optimises for shipping less JavaScript to the browser and keeping data-fetching co-located with the components that need it. When it works well, it genuinely reduces bundle sizes and simplifies data architecture. When it doesn&amp;#39;t, you spend time debugging where a component is running, why a state update isn&amp;#39;t triggering what you expected, and why your third-party library broke because it assumed &lt;code&gt;window&lt;/code&gt; would be available.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TanStack Start is client-first with explicit server capability.&lt;/strong&gt; The default mental model is a client-side React application. You reach for server execution explicitly, through &lt;code&gt;createServerFn&lt;/code&gt; — a typed RPC-style API where you define server functions with explicit HTTP methods, validators, and middleware. The client-server boundary is something you draw deliberately, not something the framework infers from file structure and directives.&lt;/p&gt;
&lt;p&gt;This is a philosophical difference as much as a technical one. Next.js App Router asks you to think in terms of server components and client components as two distinct component types that compose together. TanStack Start asks you to think in terms of a client application that makes typed calls to server endpoints. If your team has been writing React for years and finds the RSC mental model genuinely friction-heavy rather than just unfamiliar, TanStack Start&amp;#39;s approach will feel more natural.&lt;/p&gt;
&lt;p&gt;Neither model is objectively better. They optimise for different things. RSC-first is better when you want server rendering to be pervasive and low-ceremony. Client-first with explicit server functions is better when you want predictability, transparency, and explicit control over where code runs.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Routing and Data Fetching Compared&lt;/h2&gt;
&lt;p&gt;Both frameworks use file-based routing. The similarity mostly ends there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TanStack Router&lt;/strong&gt; (the routing layer inside TanStack Start) enforces type safety at the routing layer with compile-time param validation. Route params, search params, and loader data are all fully typed — not inferred at runtime, but validated at build time. If a route expects an &lt;code&gt;id&lt;/code&gt; param and you pass something that doesn&amp;#39;t match the schema, you get a type error before deployment. This is a genuine differentiator for large codebases where route-level bugs are expensive to catch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next.js App Router&lt;/strong&gt; uses file-based conventions with &lt;code&gt;page.tsx&lt;/code&gt;, &lt;code&gt;layout.tsx&lt;/code&gt;, &lt;code&gt;loading.tsx&lt;/code&gt;, and the rest of its directory-based routing model. It&amp;#39;s expressive and familiar, but it carries implicit behaviours — particularly around caching — that have been the source of significant developer frustration. Revalidation bugs, unexpected staleness, and the general opacity of the caching model have been recurring pain points since App Router became stable. Next.js 16 has improved this, but the underlying complexity is structural.&lt;/p&gt;
&lt;p&gt;On data fetching: TanStack Start uses &lt;strong&gt;isomorphic loaders&lt;/strong&gt; that run on the server during SSR and in the browser after hydration. They integrate naturally with TanStack Query, which means you get the full cache and background refetch behaviour of React Query without a separate data layer. &lt;code&gt;createServerFn&lt;/code&gt; handles mutations and server-side logic with explicit type safety — you specify the HTTP method, define a validator (Zod or otherwise), and get full type inference on both ends of the call.&lt;/p&gt;
&lt;p&gt;Next.js Server Actions use a &lt;code&gt;&amp;#39;use server&amp;#39;&lt;/code&gt; directive and default to POST for mutations. They&amp;#39;re simpler to set up for straightforward cases but less explicit about what&amp;#39;s happening at the HTTP level. For teams that want to understand and control their network layer, TanStack Start&amp;#39;s approach is easier to reason about. For teams that want to move fast without thinking about HTTP semantics, Server Actions are genuinely less friction.&lt;/p&gt;
&lt;p&gt;If you&amp;#39;re working through Next.js serialisation issues with complex data types, the patterns in &lt;a href=&quot;/blog/nextjs-server-client-serialization&quot;&gt;this guide on server-to-client serialisation in Next.js&lt;/a&gt; illustrate exactly the kind of complexity the TanStack data model is designed to avoid.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Production Readiness — What &amp;quot;RC&amp;quot; Actually Means for Your Project&lt;/h2&gt;
&lt;p&gt;TanStack Start being in RC status deserves an honest assessment rather than a dismissal or a handwave.&lt;/p&gt;
&lt;p&gt;RC in the context of TanStack Start means: the API is locked, dependencies are pinned, the feature set is complete, and the team considers it stable for production use. It does not mean it has years of production battle-testing behind it, a rich ecosystem of third-party integrations, or a large community of Stack Overflow answers to lean on when something breaks.&lt;/p&gt;
&lt;p&gt;In practice, this matters in specific ways. The deployment story is solid — deploying anywhere Node runs works correctly, and the Vite foundation means builds are fast and predictable. Performance on the server rendering path is competitive with Next.js. One team&amp;#39;s migration from Next.js to TanStack Start (Inngest) reported 83% faster local dev times, which is a meaningful productivity signal even if it&amp;#39;s a single data point.&lt;/p&gt;
&lt;p&gt;Where ecosystem thinness shows up: third-party library compatibility, auth library support depth, and integration with CMS platforms. The &lt;a href=&quot;/blog/nextjs-searchparams-static-generation-fix&quot;&gt;Next.js App Router architectural patterns&lt;/a&gt; that are well-documented and community-tested simply don&amp;#39;t have equivalents in the TanStack Start ecosystem yet.&lt;/p&gt;
&lt;p&gt;The honest summary: RC means you can ship production systems with it today, but you&amp;#39;ll hit underdocumented edges, you&amp;#39;ll have fewer community resources when you do, and you&amp;#39;ll be making architectural decisions without the benefit of collective scar tissue from thousands of production deployments. For a team with strong senior engineers who are comfortable navigating undocumented territory, this is manageable. For a team that relies on ecosystem depth and community answers, it&amp;#39;s a real risk.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Where Next.js 16 Still Wins&lt;/h2&gt;
&lt;p&gt;Next.js 16, released October 21 2025, shipped Turbopack as the stable default bundler — a genuine improvement to dev performance that partly closes the gap TanStack Start&amp;#39;s Vite foundation had. React Compiler support is now stable, and React 19.2 integration is deep. The App Router is mature enough that the worst early roughness has been sanded down.&lt;/p&gt;
&lt;p&gt;More importantly: the ecosystem is vast. Auth libraries, CMS integrations, monitoring tools, and deployment platforms have all invested in Next.js specifically. &lt;a href=&quot;/blog/nextjs-authentication-choose-right-strategy&quot;&gt;Authentication patterns are well-established&lt;/a&gt;, &lt;a href=&quot;/blog/nextjs-internationalization-architecture-guide&quot;&gt;internationalisation is well-understood&lt;/a&gt;, and the number of senior developers who have Next.js production experience means hiring and team scaling is easier.&lt;/p&gt;
&lt;p&gt;For content-heavy systems, Next.js still has a structural advantage. Its RSC model keeps pages lean by default, which matters for SEO-critical public websites where JavaScript payload directly affects core web vitals. The Pages Router is still supported for teams that need a migration path without abandoning the framework entirely.&lt;/p&gt;
&lt;p&gt;If your project involves a headless CMS, Next.js wins clearly at the integration layer. The ecosystem has converged on Next.js as the default target — Payload CMS, Sanity, Contentful, and the rest all treat Next.js as the primary integration surface.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Where TanStack Start Wins&lt;/h2&gt;
&lt;p&gt;For teams building applications rather than content sites — dashboards, SaaS products, internal tools — the TanStack Start philosophy aligns more naturally with how complex application state actually works.&lt;/p&gt;
&lt;p&gt;Type safety at the routing layer is the most underrated advantage. When route params, search params, and loader data are all compile-time typed, a category of runtime bugs simply stops existing. For large applications with complex navigation and deep linking, this is meaningful correctness that Next.js App Router doesn&amp;#39;t offer at the same level.&lt;/p&gt;
&lt;p&gt;Explicit server boundaries reduce the debugging overhead of the &amp;quot;is this component running on the server or the client?&amp;quot; question that App Router creates. When you call a server function, you know you&amp;#39;re calling a server function. There&amp;#39;s no directive to misplace and no implicit serialisation to debug. The mental model is closer to how developers who grew up with REST APIs think about client-server communication.&lt;/p&gt;
&lt;p&gt;Deployment flexibility matters for teams that want to avoid optimising for Vercel&amp;#39;s infrastructure. TanStack Start builds produce standard Node output that runs correctly anywhere — no platform-specific edge cases, no implicit optimisations that only work on one provider.&lt;/p&gt;
&lt;p&gt;And Vite&amp;#39;s dev server speed is still faster than Turbopack in most real-world configurations. For teams doing active development on large codebases, the day-to-day productivity difference is real.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Choose TanStack Start If... / Choose Next.js 16 If...&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Choose TanStack Start if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&amp;#39;re building an application (dashboard, SaaS, internal tool) rather than a content site&lt;/li&gt;
&lt;li&gt;Your team finds RSC mental model creates more confusion than it solves&lt;/li&gt;
&lt;li&gt;Type-safe routing is a priority and you want compile-time guarantees on params and search state&lt;/li&gt;
&lt;li&gt;You&amp;#39;re deploying to non-Vercel infrastructure and want to avoid platform-specific behaviour&lt;/li&gt;
&lt;li&gt;Your team has strong senior engineers comfortable with thinner ecosystem coverage&lt;/li&gt;
&lt;li&gt;You&amp;#39;re coming from a React SPA background and want SSR without re-learning the rendering model from scratch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose Next.js 16 if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&amp;#39;re building a content-heavy public site where SEO and core web vitals are critical&lt;/li&gt;
&lt;li&gt;You&amp;#39;re integrating with a headless CMS — particularly Payload, Sanity, or Contentful&lt;/li&gt;
&lt;li&gt;Your team needs to hire developers with existing framework experience&lt;/li&gt;
&lt;li&gt;You need auth, i18n, and monitoring integrations with minimal custom configuration&lt;/li&gt;
&lt;li&gt;You need the confidence that comes from years of production case studies and community answers&lt;/li&gt;
&lt;li&gt;Your project requires the full depth of the React 19 ecosystem working together with server rendering&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The decision is rarely about which framework is technically superior in the abstract. It&amp;#39;s about which framework&amp;#39;s constraints align with your team&amp;#39;s strengths and your project&amp;#39;s requirements.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What This Means If You&amp;#39;re Using Payload CMS&lt;/h2&gt;
&lt;p&gt;This section matters specifically for anyone building Payload-backed systems, because the compatibility picture is not symmetric.&lt;/p&gt;
&lt;p&gt;Payload CMS is architecturally native to Next.js. From v3.0 onwards, Payload runs inside a Next.js application — the same Next.js project hosts both the admin panel and the public frontend. The official &lt;code&gt;withPayload&lt;/code&gt; plugin handles all the build wiring, and Turbopack compatibility was formally added in Next.js 16 (PR #14456). For teams building Payload-powered websites and applications, this is a deep, well-tested integration that removes an entire class of configuration problems.&lt;/p&gt;
&lt;p&gt;TanStack Start can work alongside Payload CMS, but not in the same native way. Because TanStack Start uses Vite and Vinxi as its build system, you hit bundler conflicts when trying to run Payload in the same project — Vite&amp;#39;s module resolution handles some file types differently from the webpack-based build Payload expects. The workable approach is a monorepo setup where Payload runs in a separate Next.js application and TanStack Start consumes it as an API. This is architecturally reasonable but it&amp;#39;s a meaningfully higher integration overhead than the native approach.&lt;/p&gt;
&lt;p&gt;For the kind of systems described in &lt;a href=&quot;/blog/payload-cms-collection-structure-best-practices&quot;&gt;how to structure Payload CMS collections for maintainability&lt;/a&gt; — structured knowledge bases, customer-facing sites with semantic search, AI-powered interfaces — the native Next.js integration is a real advantage. The &lt;a href=&quot;/blog/best-headless-cms-nextjs-2026-decision-framework&quot;&gt;best headless CMS guide for Next.js in 2026&lt;/a&gt; covers this in more detail for teams still evaluating the CMS layer.&lt;/p&gt;
&lt;p&gt;If Payload is your CMS, Next.js is still the correct framework choice until TanStack Start&amp;#39;s Vite compatibility story matures.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Honest Summary&lt;/h2&gt;
&lt;p&gt;TanStack Start is the most interesting architectural challenge to Next.js dominance that exists today. It makes a coherent argument — explicit server boundaries, type-safe routing, Vite performance, deploy-anywhere flexibility — and the argument is good enough that teams should take it seriously rather than dismissing it as an early experiment.&lt;/p&gt;
&lt;p&gt;Next.js 16 remains the safer choice for most production systems, particularly content-heavy sites, CMS-backed applications, and teams that need ecosystem depth to move fast. The RSC model has matured significantly, Turbopack is now genuinely fast, and the collective knowledge base that comes from years of widespread production use is not something to underestimate.&lt;/p&gt;
&lt;p&gt;The framework worth watching: TanStack Start will close the ecosystem gap over the next twelve to eighteen months. If type-safe routing and explicit server architecture resonate with how your team thinks, building familiarity with it now makes sense. If you&amp;#39;re shipping a production system today and need confidence at every layer, Next.js 16 is still the right bet.&lt;/p&gt;
&lt;p&gt;If you&amp;#39;re making this architectural decision for a system that needs to handle real operational complexity — structured data, AI interfaces, workflow automation — and you want a senior perspective on getting the architecture right before writing code, feel free to &lt;a href=&quot;/contact&quot;&gt;reach out&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/tanstack-start-vs-nextjs-16-comparison"/>
        </item>
        <item>
            <title><![CDATA[Complete 2026 WordPress to Payload Migration Guide]]></title>
            <description><![CDATA[<p>Migrating from WordPress to Payload CMS means extracting content from <code>wp_posts</code> HTML blobs, mapping ACF field groups to typed TypeScript collections, re-uploading your media library programmatically, converting raw HTML to Lexical JSON, and redirecting every legacy WordPress URL with Next.js. This guide walks through the entire process end to end: what data types you&#39;re working with, how to model them in Payload, how to write the ETL script, how to handle media and shortcodes, and how to cut over without dropping search rankings. If you want to understand the architecture trade-offs before committing, <a href="/payload-cms-vs-wordpress">Payload CMS vs WordPress</a> covers the stack shift in detail. If you&#39;ve already decided and want help running the migration at scale, <a href="/payload-cms-migration">my Payload CMS migration service</a> is the next step.</p>
<hr>
<p>I took on my first WordPress-to-Payload migration for a client running a content-heavy site with seven years of posts, several ACF field groups, and a media library approaching 4,000 files. I assumed the hard part would be the infrastructure — setting up the Next.js app, configuring Payload collections, deploying to production. The infrastructure went fine. The hard part was the content. WordPress stores almost everything as unstructured HTML in a single <code>wp_posts</code> table, and getting that into Payload&#39;s typed, structured format takes real transformation work. This guide documents everything I learned, including the failure modes that cost me two days.</p>
<hr>
<h2>What You&#39;re Actually Migrating</h2>
<p>Before writing a single line of the ETL script, you need a complete inventory of what exists in WordPress and what needs to happen to each type.</p>
<p><strong>Posts and pages</strong> live in <code>wp_posts</code> with <code>post_type</code> values of <code>post</code> and <code>page</code>. The <code>post_content</code> column contains raw HTML — sometimes clean <code>&lt;p&gt;</code> tags, sometimes Gutenberg block comment markup, sometimes shortcode output from plugins that no longer exist. The <code>post_status</code> column tells you what&#39;s published (<code>publish</code>), drafted (<code>draft</code>), or in the trash (<code>trash</code>). You only want <code>publish</code>.</p>
<p><strong>Custom post types</strong> are also rows in <code>wp_posts</code> with custom <code>post_type</code> values registered by themes or plugins. If you&#39;ve been running ACF, your custom types will have corresponding meta rows in <code>wp_postmeta</code> keyed by ACF field keys.</p>
<p><strong>ACF fields</strong> are stored as individual rows in <code>wp_postmeta</code>. Each field becomes a <code>meta_key</code>/<code>meta_value</code> pair. Simple fields (text, number, image ID) produce a single row. Repeaters and flexible content layouts serialize their structure into a PHP-style array that gets stored as a string — more on that in the failure points section.</p>
<p><strong>The media library</strong> consists of <code>wp_posts</code> rows with <code>post_type = &#39;attachment&#39;</code>. Each row has a corresponding <code>_wp_attached_file</code> meta entry in <code>wp_postmeta</code> containing the relative path within <code>wp-content/uploads/</code>. The actual files live on disk or in an S3 bucket depending on your hosting setup.</p>
<p><strong>Users</strong> are in <code>wp_users</code> with roles stored in <code>wp_usermeta</code>. If you&#39;re migrating a multi-author site, you&#39;ll need a Payload <code>users</code> collection and a mapping between WordPress user IDs and Payload user document IDs.</p>
<p><strong>Taxonomies</strong> — categories and tags — live in <code>wp_terms</code>, <code>wp_term_taxonomy</code>, and <code>wp_term_relationships</code>. Each post&#39;s taxonomy assignments are rows in <code>wp_term_relationships</code>.</p>
<p><strong>Menus</strong> are stored in <code>wp_posts</code> as <code>nav_menu_item</code> post types with menu structure in <code>wp_postmeta</code>. Unless you&#39;re building a dynamic menu system in Payload, you&#39;ll likely hard-code these in your Next.js layout rather than migrating them.</p>
<p><strong><code>wp_options</code></strong> is the global key-value store for WordPress settings, plugin configuration, and serialized PHP data structures. Do not blindly copy it. Extract only the specific values you need (site URL, blog name) and treat everything else as WordPress-internal.</p>
<p>Here&#39;s how each type maps to Payload:</p>
<table>
<thead>
<tr>
<th>WordPress type</th>
<th>Payload equivalent</th>
<th>Migration complexity</th>
</tr>
</thead>
<tbody><tr>
<td><code>wp_posts</code> (post)</td>
<td><code>posts</code> collection</td>
<td>Medium — HTML→Lexical conversion required</td>
</tr>
<tr>
<td><code>wp_posts</code> (page)</td>
<td><code>pages</code> collection</td>
<td>Medium — same HTML issue, often has ACF</td>
</tr>
<tr>
<td>Custom post types</td>
<td>Custom collections</td>
<td>Medium — depends on ACF field complexity</td>
</tr>
<tr>
<td>ACF simple fields</td>
<td>Payload <code>text</code>, <code>number</code>, <code>select</code> fields</td>
<td>Low</td>
</tr>
<tr>
<td>ACF repeaters</td>
<td>Payload <code>array</code> field</td>
<td>Medium</td>
</tr>
<tr>
<td>ACF flexible content</td>
<td>Payload <code>blocks</code> field</td>
<td>High</td>
</tr>
<tr>
<td><code>wp_posts</code> (attachment)</td>
<td><code>media</code> collection</td>
<td>Medium — requires file re-upload</td>
</tr>
<tr>
<td><code>wp_users</code></td>
<td><code>users</code> collection</td>
<td>Low</td>
</tr>
<tr>
<td><code>wp_terms</code></td>
<td>Separate collections or relationship fields</td>
<td>Low</td>
</tr>
<tr>
<td><code>wp_options</code></td>
<td>Extract manually as needed</td>
<td>Low</td>
</tr>
</tbody></table>
<hr>
<h2>Content Model Mapping: WordPress Post Types → Payload Collections</h2>
<p>The content model is where you make the decisions that everything else depends on. Get this wrong and you&#39;ll be rewriting migration scripts halfway through.</p>
<p>A standard WordPress blog post maps straightforwardly to a Payload collection. Here&#39;s a complete TypeScript collection config for a <code>posts</code> collection that mirrors what WordPress stores:</p>
<pre><code class="language-typescript">// File: src/collections/Posts.ts
import type { CollectionConfig } from &#39;payload&#39;

export const Posts: CollectionConfig = {
  slug: &#39;posts&#39;,
  admin: {
    useAsTitle: &#39;title&#39;,
    defaultColumns: [&#39;title&#39;, &#39;status&#39;, &#39;publishedAt&#39;],
  },
  fields: [
    {
      name: &#39;title&#39;,
      type: &#39;text&#39;,
      required: true,
    },
    {
      name: &#39;slug&#39;,
      type: &#39;text&#39;,
      required: true,
      unique: true,
      index: true,
    },
    {
      name: &#39;wordpressId&#39;,
      type: &#39;number&#39;,
      index: true,
      admin: {
        description: &#39;Original WordPress post ID — used for relationship fixups and delta imports&#39;,
      },
    },
    {
      name: &#39;content&#39;,
      type: &#39;richText&#39;,
    },
    {
      name: &#39;excerpt&#39;,
      type: &#39;textarea&#39;,
    },
    {
      name: &#39;featuredImage&#39;,
      type: &#39;upload&#39;,
      relationTo: &#39;media&#39;,
    },
    {
      name: &#39;categories&#39;,
      type: &#39;relationship&#39;,
      relationTo: &#39;categories&#39;,
      hasMany: true,
    },
    {
      name: &#39;author&#39;,
      type: &#39;relationship&#39;,
      relationTo: &#39;users&#39;,
    },
    {
      name: &#39;status&#39;,
      type: &#39;select&#39;,
      options: [&#39;draft&#39;, &#39;published&#39;],
      defaultValue: &#39;draft&#39;,
    },
    {
      name: &#39;publishedAt&#39;,
      type: &#39;date&#39;,
    },
    {
      name: &#39;seoTitle&#39;,
      type: &#39;text&#39;,
    },
    {
      name: &#39;seoDescription&#39;,
      type: &#39;textarea&#39;,
    },
  ],
}
</code></pre>
<p>The <code>wordpressId</code> field is critical. Every document you create in Payload during migration should carry the original WordPress ID. You&#39;ll use it for relationship fixups (linking posts to their categories after both are imported), delta imports (querying WordPress for posts modified after your initial ETL run), and debugging when something goes wrong mid-migration.</p>
<p><strong>Mapping ACF field groups to Payload fields</strong> is where the real design work lives. Simple ACF fields map directly:</p>
<pre><code class="language-typescript">// File: src/collections/Projects.ts — ACF field group mapping examples
import type { CollectionConfig } from &#39;payload&#39;

export const Projects: CollectionConfig = {
  slug: &#39;projects&#39;,
  fields: [
    // ACF text field → Payload text field
    { name: &#39;clientName&#39;, type: &#39;text&#39; },

    // ACF image field → Payload upload field
    { name: &#39;heroImage&#39;, type: &#39;upload&#39;, relationTo: &#39;media&#39; },

    // ACF repeater field → Payload array field
    {
      name: &#39;testimonials&#39;,
      type: &#39;array&#39;,
      fields: [
        { name: &#39;quote&#39;, type: &#39;textarea&#39; },
        { name: &#39;author&#39;, type: &#39;text&#39; },
        { name: &#39;role&#39;, type: &#39;text&#39; },
      ],
    },

    // ACF flexible content → Payload blocks field
    {
      name: &#39;sections&#39;,
      type: &#39;blocks&#39;,
      blocks: [
        {
          slug: &#39;textBlock&#39;,
          fields: [
            { name: &#39;heading&#39;, type: &#39;text&#39; },
            { name: &#39;body&#39;, type: &#39;richText&#39; },
          ],
        },
        {
          slug: &#39;imageGrid&#39;,
          fields: [
            {
              name: &#39;images&#39;,
              type: &#39;array&#39;,
              fields: [{ name: &#39;image&#39;, type: &#39;upload&#39;, relationTo: &#39;media&#39; }],
            },
          ],
        },
        {
          slug: &#39;callToAction&#39;,
          fields: [
            { name: &#39;heading&#39;, type: &#39;text&#39; },
            { name: &#39;buttonText&#39;, type: &#39;text&#39; },
            { name: &#39;buttonUrl&#39;, type: &#39;text&#39; },
          ],
        },
      ],
    },
  ],
}
</code></pre>
<p>The ACF-to-Payload mapping follows a reliable pattern: simple scalar fields become Payload scalar fields, ACF <code>repeater</code> becomes a Payload <code>array</code> with the same sub-fields, and ACF <code>flexible_content</code> with its layout definitions becomes a Payload <code>blocks</code> field with one block per layout. The Payload <a href="https://github.com/payloadcms/wp-to-payload">wp-to-payload</a> repository shows this side-by-side for a sample site, which is useful as a reference alongside this mapping.</p>
<hr>
<h2>The ETL Script: Extract, Transform, Load</h2>
<p>With collections defined, you can write the migration script. I&#39;ll walk through extraction from the WordPress REST API, transformation to Payload&#39;s format, and loading via the Local API. For large sites (10,000+ posts), prefer a SQL dump as your source — it&#39;s faster and doesn&#39;t require keeping WordPress live throughout the migration. For most content sites, the REST API is simpler to work with.</p>
<p>First, set up the Payload client for standalone script usage. The <a href="/blog/payload-cms-sdk-cli-toolkit">Payload CMS SDK: CLI Toolkit</a> article covers the shared authenticated client pattern in detail — the Local API initialization below follows that same approach:</p>
<pre><code class="language-typescript">// File: scripts/migrate-posts.ts
import { getPayload } from &#39;payload&#39;
import config from &#39;@payload-config&#39;
import { convertHTMLToLexical, editorConfigFactory } from &#39;@payloadcms/richtext-lexical&#39;
import { JSDOM } from &#39;jsdom&#39;

const WP_BASE_URL = process.env.WP_BASE_URL // e.g. https://old-site.com
const WP_PER_PAGE = 100

async function fetchWordPressPosts(page: number) {
  const url = `${WP_BASE_URL}/wp-json/wp/v2/posts?per_page=${WP_PER_PAGE}&amp;page=${page}&amp;_embed=1&amp;status=publish`
  const res = await fetch(url)
  if (!res.ok) throw new Error(`WP REST error: ${res.status}`)
  const totalPages = Number(res.headers.get(&#39;X-WP-TotalPages&#39;) ?? 1)
  const posts = await res.json()
  return { posts, totalPages }
}
</code></pre>
<p>The REST API returns posts with <code>content.rendered</code> as an HTML string, <code>featured_media</code> as a media attachment ID, <code>categories</code> as an array of term IDs, and <code>acf</code> as an object of field values when the ACF REST API extension is active. The <code>_embed=1</code> parameter inlines the featured media object so you can get the source URL without a second request.</p>
<p>Before you can convert <code>content.rendered</code> to Lexical, you need to upload all images referenced in that HTML to Payload first. The <code>convertHTMLToLexical</code> function from <code>@payloadcms/richtext-lexical</code> does not auto-upload images — it will silently drop any <code>&lt;img&gt;</code> tags that aren&#39;t annotated with the correct Payload data attributes. Handle media upload in a separate pass before converting content, then annotate the HTML.</p>
<pre><code class="language-typescript">// File: scripts/upload-media.ts
import { getPayload } from &#39;payload&#39;
import config from &#39;@payload-config&#39;
import path from &#39;path&#39;
import fs from &#39;fs&#39;
import { pipeline } from &#39;stream/promises&#39;
import { createWriteStream } from &#39;fs&#39;
import os from &#39;os&#39;

// Map from WordPress attachment ID → Payload media document ID
const mediaIdMap = new Map&lt;number, string&gt;()

async function uploadWordPressMedia(
  payload: Awaited&lt;ReturnType&lt;typeof getPayload&gt;&gt;,
  wpMediaId: number,
  sourceUrl: string,
): Promise&lt;string | null&gt; {
  // Check if already uploaded
  const existing = await payload.find({
    collection: &#39;media&#39;,
    where: { wordpressId: { equals: wpMediaId } },
    limit: 1,
  })
  if (existing.docs.length &gt; 0) {
    return existing.docs[0].id as string
  }

  // Download to temp file
  const tmpPath = path.join(os.tmpdir(), `wp-media-${wpMediaId}-${Date.now()}`)
  const res = await fetch(sourceUrl)
  if (!res.ok || !res.body) return null
  await pipeline(res.body as any, createWriteStream(tmpPath))

  const filename = path.basename(new URL(sourceUrl).pathname)

  try {
    const doc = await payload.create({
      collection: &#39;media&#39;,
      data: {
        alt: filename,
        wordpressId: wpMediaId,
      },
      filePath: tmpPath,
    })
    mediaIdMap.set(wpMediaId, doc.id as string)
    return doc.id as string
  } finally {
    fs.unlinkSync(tmpPath)
  }
}
</code></pre>
<blockquote>
<p>[!WARNING]
Your <code>media</code> collection needs a <code>wordpressId</code> field (type <code>number</code>, indexed) — the same pattern as the <code>posts</code> collection. This lets you skip already-uploaded files on re-runs and perform relationship fixups.</p>
</blockquote>
<p>With media uploaded and <code>mediaIdMap</code> populated, annotate image tags in the HTML before converting to Lexical:</p>
<pre><code class="language-typescript">// File: scripts/migrate-posts.ts (continued)

function annotateImagesInHtml(
  html: string,
  mediaIdMap: Map&lt;number, string&gt;,
  wpMediaByUrl: Map&lt;string, number&gt;,
): string {
  const dom = new JSDOM(html)
  const document = dom.window.document

  document.querySelectorAll(&#39;img&#39;).forEach((img) =&gt; {
    const src = img.getAttribute(&#39;src&#39;) ?? &#39;&#39;
    // Match the WordPress media ID from the URL if we have it
    const wpId = wpMediaByUrl.get(src)
    const payloadId = wpId ? mediaIdMap.get(wpId) : undefined

    if (payloadId) {
      img.setAttribute(&#39;data-lexical-upload-id&#39;, payloadId)
      img.setAttribute(&#39;data-lexical-upload-relation-to&#39;, &#39;media&#39;)
    }
  })

  return dom.serialize()
}

async function convertToLexical(html: string) {
  const editorConfig = await editorConfigFactory({})
  const { editorState } = await convertHTMLToLexical({
    html,
    editorConfig,
    JSDOM,
  })
  return editorState
}
</code></pre>
<p>Now tie extraction, transformation, and loading together:</p>
<pre><code class="language-typescript">// File: scripts/migrate-posts.ts (continued)

async function migratePosts() {
  const payload = await getPayload({ config })

  // First, build a map of WP category term ID → Payload category document ID
  // (assumes categories collection is already migrated)
  const categoryIdMap = await buildCategoryIdMap(payload)

  let page = 1
  let totalPages = 1

  while (page &lt;= totalPages) {
    const { posts, totalPages: tp } = await fetchWordPressPosts(page)
    totalPages = tp

    for (const wpPost of posts) {
      // Skip if already migrated
      const existing = await payload.find({
        collection: &#39;posts&#39;,
        where: { wordpressId: { equals: wpPost.id } },
        limit: 1,
      })
      if (existing.docs.length &gt; 0) {
        console.log(`Skipping WP post ${wpPost.id} — already migrated`)
        continue
      }

      // Annotate image HTML before Lexical conversion
      const annotatedHtml = annotateImagesInHtml(
        wpPost.content.rendered,
        mediaIdMap,
        wpMediaByUrl, // Map&lt;url, wpMediaId&gt; built during media upload pass
      )
      const lexicalContent = await convertToLexical(annotatedHtml)

      // Map category IDs
      const payloadCategoryIds = (wpPost.categories as number[])
        .map((id) =&gt; categoryIdMap.get(id))
        .filter(Boolean) as string[]

      // Map featured image
      const featuredImageId = wpPost.featured_media
        ? mediaIdMap.get(wpPost.featured_media)
        : undefined

      await payload.create({
        collection: &#39;posts&#39;,
        data: {
          title: wpPost.title.rendered,
          slug: wpPost.slug,
          wordpressId: wpPost.id,
          content: lexicalContent,
          excerpt: wpPost.excerpt.rendered.replace(/&lt;[^&gt;]+&gt;/g, &#39;&#39;).trim(),
          featuredImage: featuredImageId ?? null,
          categories: payloadCategoryIds,
          status: &#39;published&#39;,
          publishedAt: wpPost.date,
        },
      })

      console.log(`Migrated: ${wpPost.title.rendered} (WP ID: ${wpPost.id})`)
    }

    page++
  }

  await payload.destroy()
  console.log(&#39;Migration complete.&#39;)
}

migratePosts().catch(console.error)
</code></pre>
<p>Run this with <code>tsx scripts/migrate-posts.ts</code> — the <a href="/blog/payload-cms-sdk-cli-toolkit">CLI toolkit article</a> covers the <code>tsx</code> + <code>tsconfig.paths.json</code> setup required to resolve <code>@payload-config</code> in standalone scripts.</p>
<p>For sites with thousands of posts, processing them in sequence is slow. The <a href="/blog/bulk-data-import-with-payload-queues">bulk data import with Payload Queues</a> article covers the queue-based pattern for large-volume imports — the same approach applies here, with each WP post ID dispatched as a job task.</p>
<hr>
<h2>Media Migration: Re-uploading the WordPress Media Library</h2>
<p>The script above uploads media on-demand during post migration. For a complete media library migration — including files not directly embedded in post content — you need a dedicated media pass first.</p>
<p>The WordPress REST API exposes all attachments at <code>/wp-json/wp/v2/media</code>. Each record includes a <code>source_url</code> for the full-size file and a <code>media_details</code> object with the available image sizes:</p>
<pre><code class="language-typescript">// File: scripts/migrate-media.ts
import { getPayload } from &#39;payload&#39;
import config from &#39;@payload-config&#39;

async function fetchAllWpMedia(baseUrl: string) {
  const allMedia: any[] = []
  let page = 1
  let totalPages = 1

  while (page &lt;= totalPages) {
    const res = await fetch(
      `${baseUrl}/wp-json/wp/v2/media?per_page=100&amp;page=${page}`,
    )
    totalPages = Number(res.headers.get(&#39;X-WP-TotalPages&#39;) ?? 1)
    const batch = await res.json()
    allMedia.push(...batch)
    page++
  }

  return allMedia
}

async function migrateMediaLibrary() {
  const payload = await getPayload({ config })
  const wpMedia = await fetchAllWpMedia(process.env.WP_BASE_URL!)

  for (const item of wpMedia) {
    const existing = await payload.find({
      collection: &#39;media&#39;,
      where: { wordpressId: { equals: item.id } },
      limit: 1,
    })
    if (existing.docs.length &gt; 0) continue

    // Download to temp, then create in Payload
    const payloadId = await uploadWordPressMedia(payload, item.id, item.source_url)
    if (payloadId) {
      console.log(`Uploaded: ${item.source_url} → Payload ID ${payloadId}`)
    }
  }

  await payload.destroy()
}

migrateMediaLibrary().catch(console.error)
</code></pre>
<blockquote>
<p>[!NOTE]
Payload won&#39;t replicate WordPress&#39;s media URL structure (e.g., <code>/wp-content/uploads/2023/04/image.jpg</code>). Uploaded files get Payload&#39;s own path structure. Accept this early and plan your redirect rules to cover <code>/wp-content/uploads/</code> paths pointing to Payload&#39;s new media URLs. This is also why the <code>wordpressId</code> field on media documents is essential — it lets you build a mapping table for any legacy media URLs embedded in non-Lexical content.</p>
</blockquote>
<hr>
<h2>Shortcodes and HTML That Won&#39;t Survive Conversion</h2>
<p><code>content.rendered</code> from the WordPress REST API is the post-processed HTML — shortcodes are already evaluated by the time the API returns them. This sounds helpful, until you look at what the shortcodes actually rendered. Plugin-generated tables. Slider markup. Contact form placeholders. Payment button HTML. None of it maps to Lexical nodes, and <code>convertHTMLToLexical</code> will silently discard markup it can&#39;t parse.</p>
<p>The realistic approach depends on your content&#39;s HTML complexity:</p>
<table>
<thead>
<tr>
<th>Content type</th>
<th>Recommended approach</th>
</tr>
</thead>
<tbody><tr>
<td>Standard blog posts (headings, paragraphs, lists, images)</td>
<td><code>convertHTMLToLexical</code> — works cleanly after image annotation</td>
</tr>
<tr>
<td>Posts with simple shortcodes (buttons, callouts)</td>
<td>Parse and replace shortcode output with clean HTML before conversion</td>
</tr>
<tr>
<td>Posts with page builder markup (Elementor, Divi)</td>
<td>Staged migration — store as raw HTML, convert post-launch</td>
</tr>
<tr>
<td>Posts with plugin-specific shortcodes that no longer render</td>
<td>Strip and flag for manual editorial review</td>
</tr>
</tbody></table>
<p>The staged migration approach stores the original HTML in a custom field and renders it via <code>dangerouslySetInnerHTML</code> on the front end, giving you a working site on day one while you migrate content to structured Lexical incrementally:</p>
<pre><code class="language-typescript">// File: src/collections/Posts.ts — staged migration variant
{
  name: &#39;legacyHtml&#39;,
  type: &#39;textarea&#39;,
  admin: {
    description: &#39;Raw HTML from WordPress — render via dangerouslySetInnerHTML until migrated to richText&#39;,
    condition: (data) =&gt; Boolean(data.legacyHtml),
  },
},
{
  name: &#39;contentMigrated&#39;,
  type: &#39;checkbox&#39;,
  defaultValue: false,
  admin: {
    description: &#39;Set to true once legacyHtml has been converted to the richText field&#39;,
  },
},
</code></pre>
<p>In your Next.js page component, check <code>contentMigrated</code> to decide which field to render. Once a post is fully converted, the <code>legacyHtml</code> field can be cleared.</p>
<p>The community package <a href="https://www.npmjs.com/package/@teagantb/payload-wordpress-migration"><code>@teagantb/payload-wordpress-migration</code></a> provides WordPress XML import, REST API import, and block-to-Lexical conversion via an Admin UI. Worth evaluating for your specific content mix before building custom shortcode parsers.</p>
<blockquote>
<p>[!WARNING]
Gutenberg block comment delimiters (<code>&lt;!-- wp:paragraph --&gt;</code>) appear verbatim in <code>post_content</code> when queried directly from the database or via the REST API&#39;s <code>content.raw</code> field. Always use <code>content.rendered</code> from the REST API, which returns the evaluated HTML output. If you&#39;re working from a SQL dump, run content through WordPress&#39;s <code>apply_filters(&#39;the_content&#39;, $post_content)</code> before exporting — or use the REST API as your extraction source.</p>
</blockquote>
<hr>
<h2>SEO and URL Preservation: 301 Redirect Strategy</h2>
<p>WordPress supports several permalink structures. The most common ones you&#39;ll encounter and their Next.js redirect equivalents:</p>
<p><strong>Date-based</strong> (<code>/2024/03/my-post/</code>) — WordPress&#39;s default for many sites:</p>
<pre><code class="language-typescript">// File: next.config.ts
import type { NextConfig } from &#39;next&#39;

const nextConfig: NextConfig = {
  async redirects() {
    return [
      // Date-based: /2024/03/my-post/ → /blog/my-post
      {
        source: &#39;/:year(\\d{4})/:month(\\d{2})/:slug&#39;,
        destination: &#39;/blog/:slug&#39;,
        permanent: true,
      },
      // Category-prefixed: /news/my-post/ → /blog/my-post
      {
        source: &#39;/news/:slug&#39;,
        destination: &#39;/blog/:slug&#39;,
        permanent: true,
      },
      // WordPress media library paths → Payload media URLs
      // If you can build a static mapping, use individual redirects
      // For large libraries, handle in Next.js middleware instead
      {
        source: &#39;/wp-content/uploads/:path*&#39;,
        destination: &#39;/api/media-redirect/:path*&#39;,
        permanent: false, // Keep as 302 until all URLs are verified
      },
    ]
  },
}

export default nextConfig
</code></pre>
<p>The <code>:year(\\d{4})</code> and <code>:month(\\d{2})</code> syntax uses path-to-regexp regex constraints — Next.js supports this natively. The captured <code>:slug</code> parameter carries through to the destination.</p>
<p>For category-prefixed URLs, you&#39;ll need one redirect entry per category unless all categories follow the same pattern. If you have dozens of categories, generate the redirects array programmatically by querying your Payload categories collection and building the array at build time.</p>
<p><strong>WordPress pagination</strong> (<code>/page/2/</code>, <code>/page/3/</code>) and <strong>archive URLs</strong> (<code>/category/news/</code>, <code>/tag/typescript/</code>, <code>/author/matija/</code>) don&#39;t exist in Payload by default. Redirect them to the closest equivalent pages you&#39;ve built in your Next.js app, or to the homepage if no equivalent exists:</p>
<pre><code class="language-typescript">// File: next.config.ts (additional redirect entries)
{
  source: &#39;/page/:pageNum&#39;,
  destination: &#39;/blog&#39;,
  permanent: true,
},
{
  source: &#39;/category/:category&#39;,
  destination: &#39;/blog&#39;,
  permanent: true,
},
{
  source: &#39;/tag/:tag&#39;,
  destination: &#39;/blog&#39;,
  permanent: true,
},
{
  source: &#39;/author/:author&#39;,
  destination: &#39;/&#39;,
  permanent: true,
},
// WordPress feed URLs
{
  source: &#39;/feed&#39;,
  destination: &#39;/rss.xml&#39;,
  permanent: true,
},
</code></pre>
<blockquote>
<p>[!TIP]
Before going live, run your full WordPress URL list through a redirect checker. Export all published post URLs from WordPress (Screaming Frog or a SQL query on <code>wp_posts</code> where <code>post_status = &#39;publish&#39;</code>) and verify every one returns a 301 in your Payload + Next.js setup. Missing redirects on high-authority URLs are the fastest way to lose search rankings during cutover.</p>
</blockquote>
<hr>
<h2>Content Freeze and Cutover</h2>
<p>The content freeze is the moment you stop WordPress from accepting new content and begin the final delta import. Done correctly, you lose no content and minimize the DNS switch window.</p>
<p><strong>Step 1 — Freeze WordPress publishing.</strong> Put WordPress in maintenance mode or disable editing for all users except your migration account. The simplest approach is a plugin like WP Maintenance Mode, or adding <code>define(&#39;DISALLOW_FILE_EDIT&#39;, true)</code> and removing editor roles temporarily.</p>
<p><strong>Step 2 — Run the full ETL.</strong> Execute your migration scripts in order: media first, then flat collections (categories, tags, users), then posts and pages (which reference the flat collections via relationship fields), then any CPTs. Log every WordPress ID you migrate successfully.</p>
<p><strong>Step 3 — Relationship fixup pass.</strong> Query every Payload document and verify that relationship fields (featuredImage, categories, author) are populated. The <code>wordpressId</code> field on each document is your key — query WordPress for the relationship IDs, look them up in Payload by <code>wordpressId</code>, and patch any gaps with <code>payload.update()</code>.</p>
<p><strong>Step 4 — Delta import.</strong> The initial ETL might take hours on a large site. By the time it finishes, WordPress may have received new content (before the freeze) or you may have missed a status filter. Query the WordPress REST API for posts modified after your ETL start timestamp:</p>
<pre><code class="language-typescript">// File: scripts/delta-import.ts
const etlStartTime = &#39;2026-03-01T00:00:00&#39; // ISO timestamp of initial ETL run

const res = await fetch(
  `${WP_BASE_URL}/wp-json/wp/v2/posts?modified_after=${etlStartTime}&amp;per_page=100&amp;status=publish`,
)
const newOrModifiedPosts = await res.json()
// Re-run the same create/update logic, using wordpressId to detect existing docs
</code></pre>
<p><strong>Step 5 — Smoke test.</strong> Verify a sample of 20–30 posts across different content types. Check that rich text renders correctly, images load, relationships are populated, and slugs match what you&#39;ve configured in Next.js routes.</p>
<p><strong>Step 6 — DNS switch.</strong> Point your domain to the new Payload + Next.js deployment. Keep the WordPress instance accessible (ideally on a subdomain) for at least 48 hours after cutover. <code>convertHTMLToLexical</code> downloads images from the original URL during migration — if any media uploads failed and you need to re-run them, you&#39;ll need the old site reachable.</p>
<p><strong>Step 7 — Monitor Search Console.</strong> Watch for crawl errors in Google Search Console over the first two weeks. Missing redirects show up as 404s on URLs that previously had inbound links. Fix them as they appear.</p>
<p>For infrastructure considerations during and after cutover, the <a href="/blog/deploy-payload-cms-nextjs-16-self-hosted">Payload CMS + Next.js self-hosted deployment guide</a> covers the production setup.</p>
<hr>
<h2>Common Failure Points</h2>
<p><strong>Serialized PHP in <code>wp_options</code>.</strong> WordPress plugins store configuration as PHP-serialized strings. If you extract <code>wp_options</code> directly from the database, you&#39;ll encounter values like <code>a:3:{i:0;s:4:&quot;text&quot;;}</code>. PHP serialization is not JSON — never attempt to parse these as JSON. Extract only the specific option keys you need by name (e.g., <code>blogname</code>, <code>blogdescription</code>, <code>siteurl</code>) and ignore the rest.</p>
<p><strong>Orphaned <code>wp_postmeta</code> rows.</strong> Deleting posts in WordPress leaves <code>wp_postmeta</code> rows behind. If your SQL query joins <code>wp_postmeta</code> to <code>wp_posts</code>, you may encounter meta rows with no matching post. Filter on <code>wp_posts.post_status = &#39;publish&#39;</code> and use an <code>INNER JOIN</code> to exclude orphaned meta.</p>
<p><strong>ACF repeater and flexible content serialization.</strong> When ACF stores complex field types, it writes the field values as individual rows in <code>wp_postmeta</code> with numbered key suffixes (e.g., <code>testimonials_0_quote</code>, <code>testimonials_0_author</code>, <code>testimonials_1_quote</code>). There&#39;s also a <code>testimonials</code> key containing the count. The REST API&#39;s <code>acf</code> object deserializes these into a proper array automatically. If you&#39;re working from a SQL dump, you&#39;ll need to reconstruct the array from the numbered rows yourself.</p>
<p><strong>WooCommerce data.</strong> If your WordPress site runs WooCommerce, treat the product, order, and customer data as a completely separate migration scope. WooCommerce stores its data across <code>wp_posts</code>, <code>wp_postmeta</code>, and several custom tables (<code>woocommerce_order_items</code>, <code>woocommerce_order_itemmeta</code>, etc.). Migrating WooCommerce to Payload&#39;s <a href="/blog/payload-cms-ecommerce-content-commerce-split">ecommerce architecture</a> is a distinct engagement from migrating editorial content.</p>
<p><strong>Page builder markup.</strong> If the WordPress site used Elementor, Divi, Beaver Builder, or WPBakery, the <code>post_content</code> field contains proprietary shortcode or JSON syntax specific to that builder. <code>content.rendered</code> from the REST API outputs the rendered HTML, which is usable but often heavily nested and will not convert cleanly to Lexical blocks. Use the staged migration approach for these posts.</p>
<p><strong><code>_wp_attachment_metadata</code> and custom image sizes.</strong> WordPress generates multiple image sizes for every upload (thumbnail, medium, large, custom). Payload&#39;s media handling works differently — you define image sizes in your collection config and Payload generates them on upload. You don&#39;t need to migrate the cropped variants, only the original files. Let Payload regenerate sizes from the originals.</p>
<hr>
<h2>FAQ</h2>
<p><strong>Do I need to keep WordPress running during the migration?</strong></p>
<p>For the media upload pass, yes — the ETL script downloads media files directly from their WordPress URLs. If you&#39;re working from a SQL dump rather than the REST API, you still need file access for the binary uploads. The simplest approach is keeping WordPress on a subdomain (e.g., <code>legacy.yourdomain.com</code>) until the migration is confirmed complete.</p>
<p><strong>How do I handle WordPress multisite?</strong></p>
<p>Multisite adds a site ID prefix to all tables (<code>wp_2_posts</code>, <code>wp_2_postmeta</code>). The migration logic is identical — you just parameterize the table prefix per site and run the ETL once per site into separate Payload collections or a multi-tenant Payload setup.</p>
<p><strong>What happens to WordPress user passwords?</strong></p>
<p>WordPress hashes passwords using a custom PHPass implementation that&#39;s incompatible with standard bcrypt. You cannot migrate WordPress password hashes into Payload directly. The cleanest approach: migrate user records without passwords, then trigger a password reset email for all users on launch day.</p>
<p><strong>Can I run the ETL script incrementally without a full re-run?</strong></p>
<p>Yes — the <code>wordpressId</code> field and the <code>modified_after</code> REST API parameter are what make this possible. On each script run, check for an existing Payload document with the matching <code>wordpressId</code> before creating. Use <code>payload.update()</code> for documents that already exist. This means you can stop and resume the ETL at any point without duplicating data.</p>
<p><strong>How long should I keep the 301 redirects in <code>next.config.ts</code>?</strong></p>
<p>Permanently. There&#39;s no cost to keeping redirect rules in <code>next.config.ts</code>, and removing them risks breaking inbound links from external sites that haven&#39;t updated their references. Treat legacy WordPress URL redirects as permanent infrastructure.</p>
<hr>
<h2>Doing This at Scale or With a Tight SEO Deadline?</h2>
<p>This guide covers the full technical path for a WordPress-to-Payload migration: content modeling, ETL scripting, media re-upload, HTML-to-Lexical conversion, shortcode handling, URL redirects, and cutover sequencing. Running this against a large site — heavy ACF usage, thousands of posts, complex permalink structures, SEO-critical URLs — adds significant scoping, QA, and testing work on top of what&#39;s covered here.</p>
<p><a href="/payload-cms-migration">My Payload CMS migration service</a> is exactly this work. If you want a principal-led migration with a realistic timeline and a clean SEO handoff, that&#39;s where to start.</p>
<hr>
<h2>Conclusion</h2>
<p>WordPress-to-Payload is a full stack shift, and the content layer is where most migrations get stuck. The <code>wp_posts</code> HTML blob problem, the <code>convertHTMLToLexical</code> image annotation requirement, the ACF field mapping decisions, the redirect coverage — each of these is a real source of project delay if you hit it unprepared. The approach in this guide — media-first migration, <code>wordpressId</code> mapping on every document, staged HTML handling for complex content, and a freeze + delta-import cutover — gives you a repeatable process that you can stop, resume, and verify at each step.</p>
<p>If you have questions about specific parts of your migration, drop them in the comments below.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/wordpress-to-payload-migration-guide</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/wordpress-to-payload-migration-guide</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Wed, 08 Apr 2026 14:56:40 GMT</pubDate>
            <content:encoded>&lt;p&gt;Migrating from WordPress to Payload CMS means extracting content from &lt;code&gt;wp_posts&lt;/code&gt; HTML blobs, mapping ACF field groups to typed TypeScript collections, re-uploading your media library programmatically, converting raw HTML to Lexical JSON, and redirecting every legacy WordPress URL with Next.js. This guide walks through the entire process end to end: what data types you&amp;#39;re working with, how to model them in Payload, how to write the ETL script, how to handle media and shortcodes, and how to cut over without dropping search rankings. If you want to understand the architecture trade-offs before committing, &lt;a href=&quot;/payload-cms-vs-wordpress&quot;&gt;Payload CMS vs WordPress&lt;/a&gt; covers the stack shift in detail. If you&amp;#39;ve already decided and want help running the migration at scale, &lt;a href=&quot;/payload-cms-migration&quot;&gt;my Payload CMS migration service&lt;/a&gt; is the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I took on my first WordPress-to-Payload migration for a client running a content-heavy site with seven years of posts, several ACF field groups, and a media library approaching 4,000 files. I assumed the hard part would be the infrastructure — setting up the Next.js app, configuring Payload collections, deploying to production. The infrastructure went fine. The hard part was the content. WordPress stores almost everything as unstructured HTML in a single &lt;code&gt;wp_posts&lt;/code&gt; table, and getting that into Payload&amp;#39;s typed, structured format takes real transformation work. This guide documents everything I learned, including the failure modes that cost me two days.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What You&amp;#39;re Actually Migrating&lt;/h2&gt;
&lt;p&gt;Before writing a single line of the ETL script, you need a complete inventory of what exists in WordPress and what needs to happen to each type.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Posts and pages&lt;/strong&gt; live in &lt;code&gt;wp_posts&lt;/code&gt; with &lt;code&gt;post_type&lt;/code&gt; values of &lt;code&gt;post&lt;/code&gt; and &lt;code&gt;page&lt;/code&gt;. The &lt;code&gt;post_content&lt;/code&gt; column contains raw HTML — sometimes clean &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tags, sometimes Gutenberg block comment markup, sometimes shortcode output from plugins that no longer exist. The &lt;code&gt;post_status&lt;/code&gt; column tells you what&amp;#39;s published (&lt;code&gt;publish&lt;/code&gt;), drafted (&lt;code&gt;draft&lt;/code&gt;), or in the trash (&lt;code&gt;trash&lt;/code&gt;). You only want &lt;code&gt;publish&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Custom post types&lt;/strong&gt; are also rows in &lt;code&gt;wp_posts&lt;/code&gt; with custom &lt;code&gt;post_type&lt;/code&gt; values registered by themes or plugins. If you&amp;#39;ve been running ACF, your custom types will have corresponding meta rows in &lt;code&gt;wp_postmeta&lt;/code&gt; keyed by ACF field keys.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ACF fields&lt;/strong&gt; are stored as individual rows in &lt;code&gt;wp_postmeta&lt;/code&gt;. Each field becomes a &lt;code&gt;meta_key&lt;/code&gt;/&lt;code&gt;meta_value&lt;/code&gt; pair. Simple fields (text, number, image ID) produce a single row. Repeaters and flexible content layouts serialize their structure into a PHP-style array that gets stored as a string — more on that in the failure points section.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The media library&lt;/strong&gt; consists of &lt;code&gt;wp_posts&lt;/code&gt; rows with &lt;code&gt;post_type = &amp;#39;attachment&amp;#39;&lt;/code&gt;. Each row has a corresponding &lt;code&gt;_wp_attached_file&lt;/code&gt; meta entry in &lt;code&gt;wp_postmeta&lt;/code&gt; containing the relative path within &lt;code&gt;wp-content/uploads/&lt;/code&gt;. The actual files live on disk or in an S3 bucket depending on your hosting setup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Users&lt;/strong&gt; are in &lt;code&gt;wp_users&lt;/code&gt; with roles stored in &lt;code&gt;wp_usermeta&lt;/code&gt;. If you&amp;#39;re migrating a multi-author site, you&amp;#39;ll need a Payload &lt;code&gt;users&lt;/code&gt; collection and a mapping between WordPress user IDs and Payload user document IDs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Taxonomies&lt;/strong&gt; — categories and tags — live in &lt;code&gt;wp_terms&lt;/code&gt;, &lt;code&gt;wp_term_taxonomy&lt;/code&gt;, and &lt;code&gt;wp_term_relationships&lt;/code&gt;. Each post&amp;#39;s taxonomy assignments are rows in &lt;code&gt;wp_term_relationships&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Menus&lt;/strong&gt; are stored in &lt;code&gt;wp_posts&lt;/code&gt; as &lt;code&gt;nav_menu_item&lt;/code&gt; post types with menu structure in &lt;code&gt;wp_postmeta&lt;/code&gt;. Unless you&amp;#39;re building a dynamic menu system in Payload, you&amp;#39;ll likely hard-code these in your Next.js layout rather than migrating them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;wp_options&lt;/code&gt;&lt;/strong&gt; is the global key-value store for WordPress settings, plugin configuration, and serialized PHP data structures. Do not blindly copy it. Extract only the specific values you need (site URL, blog name) and treat everything else as WordPress-internal.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s how each type maps to Payload:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;WordPress type&lt;/th&gt;
&lt;th&gt;Payload equivalent&lt;/th&gt;
&lt;th&gt;Migration complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_posts&lt;/code&gt; (post)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;posts&lt;/code&gt; collection&lt;/td&gt;
&lt;td&gt;Medium — HTML→Lexical conversion required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_posts&lt;/code&gt; (page)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pages&lt;/code&gt; collection&lt;/td&gt;
&lt;td&gt;Medium — same HTML issue, often has ACF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom post types&lt;/td&gt;
&lt;td&gt;Custom collections&lt;/td&gt;
&lt;td&gt;Medium — depends on ACF field complexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACF simple fields&lt;/td&gt;
&lt;td&gt;Payload &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt; fields&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACF repeaters&lt;/td&gt;
&lt;td&gt;Payload &lt;code&gt;array&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACF flexible content&lt;/td&gt;
&lt;td&gt;Payload &lt;code&gt;blocks&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_posts&lt;/code&gt; (attachment)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;media&lt;/code&gt; collection&lt;/td&gt;
&lt;td&gt;Medium — requires file re-upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt; collection&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_terms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Separate collections or relationship fields&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_options&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extract manually as needed&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Content Model Mapping: WordPress Post Types → Payload Collections&lt;/h2&gt;
&lt;p&gt;The content model is where you make the decisions that everything else depends on. Get this wrong and you&amp;#39;ll be rewriting migration scripts halfway through.&lt;/p&gt;
&lt;p&gt;A standard WordPress blog post maps straightforwardly to a Payload collection. Here&amp;#39;s a complete TypeScript collection config for a &lt;code&gt;posts&lt;/code&gt; collection that mirrors what WordPress stores:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: src/collections/Posts.ts
import type { CollectionConfig } from &amp;#39;payload&amp;#39;

export const Posts: CollectionConfig = {
  slug: &amp;#39;posts&amp;#39;,
  admin: {
    useAsTitle: &amp;#39;title&amp;#39;,
    defaultColumns: [&amp;#39;title&amp;#39;, &amp;#39;status&amp;#39;, &amp;#39;publishedAt&amp;#39;],
  },
  fields: [
    {
      name: &amp;#39;title&amp;#39;,
      type: &amp;#39;text&amp;#39;,
      required: true,
    },
    {
      name: &amp;#39;slug&amp;#39;,
      type: &amp;#39;text&amp;#39;,
      required: true,
      unique: true,
      index: true,
    },
    {
      name: &amp;#39;wordpressId&amp;#39;,
      type: &amp;#39;number&amp;#39;,
      index: true,
      admin: {
        description: &amp;#39;Original WordPress post ID — used for relationship fixups and delta imports&amp;#39;,
      },
    },
    {
      name: &amp;#39;content&amp;#39;,
      type: &amp;#39;richText&amp;#39;,
    },
    {
      name: &amp;#39;excerpt&amp;#39;,
      type: &amp;#39;textarea&amp;#39;,
    },
    {
      name: &amp;#39;featuredImage&amp;#39;,
      type: &amp;#39;upload&amp;#39;,
      relationTo: &amp;#39;media&amp;#39;,
    },
    {
      name: &amp;#39;categories&amp;#39;,
      type: &amp;#39;relationship&amp;#39;,
      relationTo: &amp;#39;categories&amp;#39;,
      hasMany: true,
    },
    {
      name: &amp;#39;author&amp;#39;,
      type: &amp;#39;relationship&amp;#39;,
      relationTo: &amp;#39;users&amp;#39;,
    },
    {
      name: &amp;#39;status&amp;#39;,
      type: &amp;#39;select&amp;#39;,
      options: [&amp;#39;draft&amp;#39;, &amp;#39;published&amp;#39;],
      defaultValue: &amp;#39;draft&amp;#39;,
    },
    {
      name: &amp;#39;publishedAt&amp;#39;,
      type: &amp;#39;date&amp;#39;,
    },
    {
      name: &amp;#39;seoTitle&amp;#39;,
      type: &amp;#39;text&amp;#39;,
    },
    {
      name: &amp;#39;seoDescription&amp;#39;,
      type: &amp;#39;textarea&amp;#39;,
    },
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;wordpressId&lt;/code&gt; field is critical. Every document you create in Payload during migration should carry the original WordPress ID. You&amp;#39;ll use it for relationship fixups (linking posts to their categories after both are imported), delta imports (querying WordPress for posts modified after your initial ETL run), and debugging when something goes wrong mid-migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mapping ACF field groups to Payload fields&lt;/strong&gt; is where the real design work lives. Simple ACF fields map directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: src/collections/Projects.ts — ACF field group mapping examples
import type { CollectionConfig } from &amp;#39;payload&amp;#39;

export const Projects: CollectionConfig = {
  slug: &amp;#39;projects&amp;#39;,
  fields: [
    // ACF text field → Payload text field
    { name: &amp;#39;clientName&amp;#39;, type: &amp;#39;text&amp;#39; },

    // ACF image field → Payload upload field
    { name: &amp;#39;heroImage&amp;#39;, type: &amp;#39;upload&amp;#39;, relationTo: &amp;#39;media&amp;#39; },

    // ACF repeater field → Payload array field
    {
      name: &amp;#39;testimonials&amp;#39;,
      type: &amp;#39;array&amp;#39;,
      fields: [
        { name: &amp;#39;quote&amp;#39;, type: &amp;#39;textarea&amp;#39; },
        { name: &amp;#39;author&amp;#39;, type: &amp;#39;text&amp;#39; },
        { name: &amp;#39;role&amp;#39;, type: &amp;#39;text&amp;#39; },
      ],
    },

    // ACF flexible content → Payload blocks field
    {
      name: &amp;#39;sections&amp;#39;,
      type: &amp;#39;blocks&amp;#39;,
      blocks: [
        {
          slug: &amp;#39;textBlock&amp;#39;,
          fields: [
            { name: &amp;#39;heading&amp;#39;, type: &amp;#39;text&amp;#39; },
            { name: &amp;#39;body&amp;#39;, type: &amp;#39;richText&amp;#39; },
          ],
        },
        {
          slug: &amp;#39;imageGrid&amp;#39;,
          fields: [
            {
              name: &amp;#39;images&amp;#39;,
              type: &amp;#39;array&amp;#39;,
              fields: [{ name: &amp;#39;image&amp;#39;, type: &amp;#39;upload&amp;#39;, relationTo: &amp;#39;media&amp;#39; }],
            },
          ],
        },
        {
          slug: &amp;#39;callToAction&amp;#39;,
          fields: [
            { name: &amp;#39;heading&amp;#39;, type: &amp;#39;text&amp;#39; },
            { name: &amp;#39;buttonText&amp;#39;, type: &amp;#39;text&amp;#39; },
            { name: &amp;#39;buttonUrl&amp;#39;, type: &amp;#39;text&amp;#39; },
          ],
        },
      ],
    },
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ACF-to-Payload mapping follows a reliable pattern: simple scalar fields become Payload scalar fields, ACF &lt;code&gt;repeater&lt;/code&gt; becomes a Payload &lt;code&gt;array&lt;/code&gt; with the same sub-fields, and ACF &lt;code&gt;flexible_content&lt;/code&gt; with its layout definitions becomes a Payload &lt;code&gt;blocks&lt;/code&gt; field with one block per layout. The Payload &lt;a href=&quot;https://github.com/payloadcms/wp-to-payload&quot;&gt;wp-to-payload&lt;/a&gt; repository shows this side-by-side for a sample site, which is useful as a reference alongside this mapping.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The ETL Script: Extract, Transform, Load&lt;/h2&gt;
&lt;p&gt;With collections defined, you can write the migration script. I&amp;#39;ll walk through extraction from the WordPress REST API, transformation to Payload&amp;#39;s format, and loading via the Local API. For large sites (10,000+ posts), prefer a SQL dump as your source — it&amp;#39;s faster and doesn&amp;#39;t require keeping WordPress live throughout the migration. For most content sites, the REST API is simpler to work with.&lt;/p&gt;
&lt;p&gt;First, set up the Payload client for standalone script usage. The &lt;a href=&quot;/blog/payload-cms-sdk-cli-toolkit&quot;&gt;Payload CMS SDK: CLI Toolkit&lt;/a&gt; article covers the shared authenticated client pattern in detail — the Local API initialization below follows that same approach:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/migrate-posts.ts
import { getPayload } from &amp;#39;payload&amp;#39;
import config from &amp;#39;@payload-config&amp;#39;
import { convertHTMLToLexical, editorConfigFactory } from &amp;#39;@payloadcms/richtext-lexical&amp;#39;
import { JSDOM } from &amp;#39;jsdom&amp;#39;

const WP_BASE_URL = process.env.WP_BASE_URL // e.g. https://old-site.com
const WP_PER_PAGE = 100

async function fetchWordPressPosts(page: number) {
  const url = `${WP_BASE_URL}/wp-json/wp/v2/posts?per_page=${WP_PER_PAGE}&amp;amp;page=${page}&amp;amp;_embed=1&amp;amp;status=publish`
  const res = await fetch(url)
  if (!res.ok) throw new Error(`WP REST error: ${res.status}`)
  const totalPages = Number(res.headers.get(&amp;#39;X-WP-TotalPages&amp;#39;) ?? 1)
  const posts = await res.json()
  return { posts, totalPages }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The REST API returns posts with &lt;code&gt;content.rendered&lt;/code&gt; as an HTML string, &lt;code&gt;featured_media&lt;/code&gt; as a media attachment ID, &lt;code&gt;categories&lt;/code&gt; as an array of term IDs, and &lt;code&gt;acf&lt;/code&gt; as an object of field values when the ACF REST API extension is active. The &lt;code&gt;_embed=1&lt;/code&gt; parameter inlines the featured media object so you can get the source URL without a second request.&lt;/p&gt;
&lt;p&gt;Before you can convert &lt;code&gt;content.rendered&lt;/code&gt; to Lexical, you need to upload all images referenced in that HTML to Payload first. The &lt;code&gt;convertHTMLToLexical&lt;/code&gt; function from &lt;code&gt;@payloadcms/richtext-lexical&lt;/code&gt; does not auto-upload images — it will silently drop any &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags that aren&amp;#39;t annotated with the correct Payload data attributes. Handle media upload in a separate pass before converting content, then annotate the HTML.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/upload-media.ts
import { getPayload } from &amp;#39;payload&amp;#39;
import config from &amp;#39;@payload-config&amp;#39;
import path from &amp;#39;path&amp;#39;
import fs from &amp;#39;fs&amp;#39;
import { pipeline } from &amp;#39;stream/promises&amp;#39;
import { createWriteStream } from &amp;#39;fs&amp;#39;
import os from &amp;#39;os&amp;#39;

// Map from WordPress attachment ID → Payload media document ID
const mediaIdMap = new Map&amp;lt;number, string&amp;gt;()

async function uploadWordPressMedia(
  payload: Awaited&amp;lt;ReturnType&amp;lt;typeof getPayload&amp;gt;&amp;gt;,
  wpMediaId: number,
  sourceUrl: string,
): Promise&amp;lt;string | null&amp;gt; {
  // Check if already uploaded
  const existing = await payload.find({
    collection: &amp;#39;media&amp;#39;,
    where: { wordpressId: { equals: wpMediaId } },
    limit: 1,
  })
  if (existing.docs.length &amp;gt; 0) {
    return existing.docs[0].id as string
  }

  // Download to temp file
  const tmpPath = path.join(os.tmpdir(), `wp-media-${wpMediaId}-${Date.now()}`)
  const res = await fetch(sourceUrl)
  if (!res.ok || !res.body) return null
  await pipeline(res.body as any, createWriteStream(tmpPath))

  const filename = path.basename(new URL(sourceUrl).pathname)

  try {
    const doc = await payload.create({
      collection: &amp;#39;media&amp;#39;,
      data: {
        alt: filename,
        wordpressId: wpMediaId,
      },
      filePath: tmpPath,
    })
    mediaIdMap.set(wpMediaId, doc.id as string)
    return doc.id as string
  } finally {
    fs.unlinkSync(tmpPath)
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
Your &lt;code&gt;media&lt;/code&gt; collection needs a &lt;code&gt;wordpressId&lt;/code&gt; field (type &lt;code&gt;number&lt;/code&gt;, indexed) — the same pattern as the &lt;code&gt;posts&lt;/code&gt; collection. This lets you skip already-uploaded files on re-runs and perform relationship fixups.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With media uploaded and &lt;code&gt;mediaIdMap&lt;/code&gt; populated, annotate image tags in the HTML before converting to Lexical:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/migrate-posts.ts (continued)

function annotateImagesInHtml(
  html: string,
  mediaIdMap: Map&amp;lt;number, string&amp;gt;,
  wpMediaByUrl: Map&amp;lt;string, number&amp;gt;,
): string {
  const dom = new JSDOM(html)
  const document = dom.window.document

  document.querySelectorAll(&amp;#39;img&amp;#39;).forEach((img) =&amp;gt; {
    const src = img.getAttribute(&amp;#39;src&amp;#39;) ?? &amp;#39;&amp;#39;
    // Match the WordPress media ID from the URL if we have it
    const wpId = wpMediaByUrl.get(src)
    const payloadId = wpId ? mediaIdMap.get(wpId) : undefined

    if (payloadId) {
      img.setAttribute(&amp;#39;data-lexical-upload-id&amp;#39;, payloadId)
      img.setAttribute(&amp;#39;data-lexical-upload-relation-to&amp;#39;, &amp;#39;media&amp;#39;)
    }
  })

  return dom.serialize()
}

async function convertToLexical(html: string) {
  const editorConfig = await editorConfigFactory({})
  const { editorState } = await convertHTMLToLexical({
    html,
    editorConfig,
    JSDOM,
  })
  return editorState
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now tie extraction, transformation, and loading together:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/migrate-posts.ts (continued)

async function migratePosts() {
  const payload = await getPayload({ config })

  // First, build a map of WP category term ID → Payload category document ID
  // (assumes categories collection is already migrated)
  const categoryIdMap = await buildCategoryIdMap(payload)

  let page = 1
  let totalPages = 1

  while (page &amp;lt;= totalPages) {
    const { posts, totalPages: tp } = await fetchWordPressPosts(page)
    totalPages = tp

    for (const wpPost of posts) {
      // Skip if already migrated
      const existing = await payload.find({
        collection: &amp;#39;posts&amp;#39;,
        where: { wordpressId: { equals: wpPost.id } },
        limit: 1,
      })
      if (existing.docs.length &amp;gt; 0) {
        console.log(`Skipping WP post ${wpPost.id} — already migrated`)
        continue
      }

      // Annotate image HTML before Lexical conversion
      const annotatedHtml = annotateImagesInHtml(
        wpPost.content.rendered,
        mediaIdMap,
        wpMediaByUrl, // Map&amp;lt;url, wpMediaId&amp;gt; built during media upload pass
      )
      const lexicalContent = await convertToLexical(annotatedHtml)

      // Map category IDs
      const payloadCategoryIds = (wpPost.categories as number[])
        .map((id) =&amp;gt; categoryIdMap.get(id))
        .filter(Boolean) as string[]

      // Map featured image
      const featuredImageId = wpPost.featured_media
        ? mediaIdMap.get(wpPost.featured_media)
        : undefined

      await payload.create({
        collection: &amp;#39;posts&amp;#39;,
        data: {
          title: wpPost.title.rendered,
          slug: wpPost.slug,
          wordpressId: wpPost.id,
          content: lexicalContent,
          excerpt: wpPost.excerpt.rendered.replace(/&amp;lt;[^&amp;gt;]+&amp;gt;/g, &amp;#39;&amp;#39;).trim(),
          featuredImage: featuredImageId ?? null,
          categories: payloadCategoryIds,
          status: &amp;#39;published&amp;#39;,
          publishedAt: wpPost.date,
        },
      })

      console.log(`Migrated: ${wpPost.title.rendered} (WP ID: ${wpPost.id})`)
    }

    page++
  }

  await payload.destroy()
  console.log(&amp;#39;Migration complete.&amp;#39;)
}

migratePosts().catch(console.error)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this with &lt;code&gt;tsx scripts/migrate-posts.ts&lt;/code&gt; — the &lt;a href=&quot;/blog/payload-cms-sdk-cli-toolkit&quot;&gt;CLI toolkit article&lt;/a&gt; covers the &lt;code&gt;tsx&lt;/code&gt; + &lt;code&gt;tsconfig.paths.json&lt;/code&gt; setup required to resolve &lt;code&gt;@payload-config&lt;/code&gt; in standalone scripts.&lt;/p&gt;
&lt;p&gt;For sites with thousands of posts, processing them in sequence is slow. The &lt;a href=&quot;/blog/bulk-data-import-with-payload-queues&quot;&gt;bulk data import with Payload Queues&lt;/a&gt; article covers the queue-based pattern for large-volume imports — the same approach applies here, with each WP post ID dispatched as a job task.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Media Migration: Re-uploading the WordPress Media Library&lt;/h2&gt;
&lt;p&gt;The script above uploads media on-demand during post migration. For a complete media library migration — including files not directly embedded in post content — you need a dedicated media pass first.&lt;/p&gt;
&lt;p&gt;The WordPress REST API exposes all attachments at &lt;code&gt;/wp-json/wp/v2/media&lt;/code&gt;. Each record includes a &lt;code&gt;source_url&lt;/code&gt; for the full-size file and a &lt;code&gt;media_details&lt;/code&gt; object with the available image sizes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/migrate-media.ts
import { getPayload } from &amp;#39;payload&amp;#39;
import config from &amp;#39;@payload-config&amp;#39;

async function fetchAllWpMedia(baseUrl: string) {
  const allMedia: any[] = []
  let page = 1
  let totalPages = 1

  while (page &amp;lt;= totalPages) {
    const res = await fetch(
      `${baseUrl}/wp-json/wp/v2/media?per_page=100&amp;amp;page=${page}`,
    )
    totalPages = Number(res.headers.get(&amp;#39;X-WP-TotalPages&amp;#39;) ?? 1)
    const batch = await res.json()
    allMedia.push(...batch)
    page++
  }

  return allMedia
}

async function migrateMediaLibrary() {
  const payload = await getPayload({ config })
  const wpMedia = await fetchAllWpMedia(process.env.WP_BASE_URL!)

  for (const item of wpMedia) {
    const existing = await payload.find({
      collection: &amp;#39;media&amp;#39;,
      where: { wordpressId: { equals: item.id } },
      limit: 1,
    })
    if (existing.docs.length &amp;gt; 0) continue

    // Download to temp, then create in Payload
    const payloadId = await uploadWordPressMedia(payload, item.id, item.source_url)
    if (payloadId) {
      console.log(`Uploaded: ${item.source_url} → Payload ID ${payloadId}`)
    }
  }

  await payload.destroy()
}

migrateMediaLibrary().catch(console.error)
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
Payload won&amp;#39;t replicate WordPress&amp;#39;s media URL structure (e.g., &lt;code&gt;/wp-content/uploads/2023/04/image.jpg&lt;/code&gt;). Uploaded files get Payload&amp;#39;s own path structure. Accept this early and plan your redirect rules to cover &lt;code&gt;/wp-content/uploads/&lt;/code&gt; paths pointing to Payload&amp;#39;s new media URLs. This is also why the &lt;code&gt;wordpressId&lt;/code&gt; field on media documents is essential — it lets you build a mapping table for any legacy media URLs embedded in non-Lexical content.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;Shortcodes and HTML That Won&amp;#39;t Survive Conversion&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;content.rendered&lt;/code&gt; from the WordPress REST API is the post-processed HTML — shortcodes are already evaluated by the time the API returns them. This sounds helpful, until you look at what the shortcodes actually rendered. Plugin-generated tables. Slider markup. Contact form placeholders. Payment button HTML. None of it maps to Lexical nodes, and &lt;code&gt;convertHTMLToLexical&lt;/code&gt; will silently discard markup it can&amp;#39;t parse.&lt;/p&gt;
&lt;p&gt;The realistic approach depends on your content&amp;#39;s HTML complexity:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content type&lt;/th&gt;
&lt;th&gt;Recommended approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Standard blog posts (headings, paragraphs, lists, images)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;convertHTMLToLexical&lt;/code&gt; — works cleanly after image annotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Posts with simple shortcodes (buttons, callouts)&lt;/td&gt;
&lt;td&gt;Parse and replace shortcode output with clean HTML before conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Posts with page builder markup (Elementor, Divi)&lt;/td&gt;
&lt;td&gt;Staged migration — store as raw HTML, convert post-launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Posts with plugin-specific shortcodes that no longer render&lt;/td&gt;
&lt;td&gt;Strip and flag for manual editorial review&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The staged migration approach stores the original HTML in a custom field and renders it via &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; on the front end, giving you a working site on day one while you migrate content to structured Lexical incrementally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: src/collections/Posts.ts — staged migration variant
{
  name: &amp;#39;legacyHtml&amp;#39;,
  type: &amp;#39;textarea&amp;#39;,
  admin: {
    description: &amp;#39;Raw HTML from WordPress — render via dangerouslySetInnerHTML until migrated to richText&amp;#39;,
    condition: (data) =&amp;gt; Boolean(data.legacyHtml),
  },
},
{
  name: &amp;#39;contentMigrated&amp;#39;,
  type: &amp;#39;checkbox&amp;#39;,
  defaultValue: false,
  admin: {
    description: &amp;#39;Set to true once legacyHtml has been converted to the richText field&amp;#39;,
  },
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In your Next.js page component, check &lt;code&gt;contentMigrated&lt;/code&gt; to decide which field to render. Once a post is fully converted, the &lt;code&gt;legacyHtml&lt;/code&gt; field can be cleared.&lt;/p&gt;
&lt;p&gt;The community package &lt;a href=&quot;https://www.npmjs.com/package/@teagantb/payload-wordpress-migration&quot;&gt;&lt;code&gt;@teagantb/payload-wordpress-migration&lt;/code&gt;&lt;/a&gt; provides WordPress XML import, REST API import, and block-to-Lexical conversion via an Admin UI. Worth evaluating for your specific content mix before building custom shortcode parsers.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
Gutenberg block comment delimiters (&lt;code&gt;&amp;lt;!-- wp:paragraph --&amp;gt;&lt;/code&gt;) appear verbatim in &lt;code&gt;post_content&lt;/code&gt; when queried directly from the database or via the REST API&amp;#39;s &lt;code&gt;content.raw&lt;/code&gt; field. Always use &lt;code&gt;content.rendered&lt;/code&gt; from the REST API, which returns the evaluated HTML output. If you&amp;#39;re working from a SQL dump, run content through WordPress&amp;#39;s &lt;code&gt;apply_filters(&amp;#39;the_content&amp;#39;, $post_content)&lt;/code&gt; before exporting — or use the REST API as your extraction source.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;SEO and URL Preservation: 301 Redirect Strategy&lt;/h2&gt;
&lt;p&gt;WordPress supports several permalink structures. The most common ones you&amp;#39;ll encounter and their Next.js redirect equivalents:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Date-based&lt;/strong&gt; (&lt;code&gt;/2024/03/my-post/&lt;/code&gt;) — WordPress&amp;#39;s default for many sites:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: next.config.ts
import type { NextConfig } from &amp;#39;next&amp;#39;

const nextConfig: NextConfig = {
  async redirects() {
    return [
      // Date-based: /2024/03/my-post/ → /blog/my-post
      {
        source: &amp;#39;/:year(\\d{4})/:month(\\d{2})/:slug&amp;#39;,
        destination: &amp;#39;/blog/:slug&amp;#39;,
        permanent: true,
      },
      // Category-prefixed: /news/my-post/ → /blog/my-post
      {
        source: &amp;#39;/news/:slug&amp;#39;,
        destination: &amp;#39;/blog/:slug&amp;#39;,
        permanent: true,
      },
      // WordPress media library paths → Payload media URLs
      // If you can build a static mapping, use individual redirects
      // For large libraries, handle in Next.js middleware instead
      {
        source: &amp;#39;/wp-content/uploads/:path*&amp;#39;,
        destination: &amp;#39;/api/media-redirect/:path*&amp;#39;,
        permanent: false, // Keep as 302 until all URLs are verified
      },
    ]
  },
}

export default nextConfig
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;:year(\\d{4})&lt;/code&gt; and &lt;code&gt;:month(\\d{2})&lt;/code&gt; syntax uses path-to-regexp regex constraints — Next.js supports this natively. The captured &lt;code&gt;:slug&lt;/code&gt; parameter carries through to the destination.&lt;/p&gt;
&lt;p&gt;For category-prefixed URLs, you&amp;#39;ll need one redirect entry per category unless all categories follow the same pattern. If you have dozens of categories, generate the redirects array programmatically by querying your Payload categories collection and building the array at build time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;WordPress pagination&lt;/strong&gt; (&lt;code&gt;/page/2/&lt;/code&gt;, &lt;code&gt;/page/3/&lt;/code&gt;) and &lt;strong&gt;archive URLs&lt;/strong&gt; (&lt;code&gt;/category/news/&lt;/code&gt;, &lt;code&gt;/tag/typescript/&lt;/code&gt;, &lt;code&gt;/author/matija/&lt;/code&gt;) don&amp;#39;t exist in Payload by default. Redirect them to the closest equivalent pages you&amp;#39;ve built in your Next.js app, or to the homepage if no equivalent exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: next.config.ts (additional redirect entries)
{
  source: &amp;#39;/page/:pageNum&amp;#39;,
  destination: &amp;#39;/blog&amp;#39;,
  permanent: true,
},
{
  source: &amp;#39;/category/:category&amp;#39;,
  destination: &amp;#39;/blog&amp;#39;,
  permanent: true,
},
{
  source: &amp;#39;/tag/:tag&amp;#39;,
  destination: &amp;#39;/blog&amp;#39;,
  permanent: true,
},
{
  source: &amp;#39;/author/:author&amp;#39;,
  destination: &amp;#39;/&amp;#39;,
  permanent: true,
},
// WordPress feed URLs
{
  source: &amp;#39;/feed&amp;#39;,
  destination: &amp;#39;/rss.xml&amp;#39;,
  permanent: true,
},
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP]
Before going live, run your full WordPress URL list through a redirect checker. Export all published post URLs from WordPress (Screaming Frog or a SQL query on &lt;code&gt;wp_posts&lt;/code&gt; where &lt;code&gt;post_status = &amp;#39;publish&amp;#39;&lt;/code&gt;) and verify every one returns a 301 in your Payload + Next.js setup. Missing redirects on high-authority URLs are the fastest way to lose search rankings during cutover.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;Content Freeze and Cutover&lt;/h2&gt;
&lt;p&gt;The content freeze is the moment you stop WordPress from accepting new content and begin the final delta import. Done correctly, you lose no content and minimize the DNS switch window.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1 — Freeze WordPress publishing.&lt;/strong&gt; Put WordPress in maintenance mode or disable editing for all users except your migration account. The simplest approach is a plugin like WP Maintenance Mode, or adding &lt;code&gt;define(&amp;#39;DISALLOW_FILE_EDIT&amp;#39;, true)&lt;/code&gt; and removing editor roles temporarily.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2 — Run the full ETL.&lt;/strong&gt; Execute your migration scripts in order: media first, then flat collections (categories, tags, users), then posts and pages (which reference the flat collections via relationship fields), then any CPTs. Log every WordPress ID you migrate successfully.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 3 — Relationship fixup pass.&lt;/strong&gt; Query every Payload document and verify that relationship fields (featuredImage, categories, author) are populated. The &lt;code&gt;wordpressId&lt;/code&gt; field on each document is your key — query WordPress for the relationship IDs, look them up in Payload by &lt;code&gt;wordpressId&lt;/code&gt;, and patch any gaps with &lt;code&gt;payload.update()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 4 — Delta import.&lt;/strong&gt; The initial ETL might take hours on a large site. By the time it finishes, WordPress may have received new content (before the freeze) or you may have missed a status filter. Query the WordPress REST API for posts modified after your ETL start timestamp:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/delta-import.ts
const etlStartTime = &amp;#39;2026-03-01T00:00:00&amp;#39; // ISO timestamp of initial ETL run

const res = await fetch(
  `${WP_BASE_URL}/wp-json/wp/v2/posts?modified_after=${etlStartTime}&amp;amp;per_page=100&amp;amp;status=publish`,
)
const newOrModifiedPosts = await res.json()
// Re-run the same create/update logic, using wordpressId to detect existing docs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 5 — Smoke test.&lt;/strong&gt; Verify a sample of 20–30 posts across different content types. Check that rich text renders correctly, images load, relationships are populated, and slugs match what you&amp;#39;ve configured in Next.js routes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 6 — DNS switch.&lt;/strong&gt; Point your domain to the new Payload + Next.js deployment. Keep the WordPress instance accessible (ideally on a subdomain) for at least 48 hours after cutover. &lt;code&gt;convertHTMLToLexical&lt;/code&gt; downloads images from the original URL during migration — if any media uploads failed and you need to re-run them, you&amp;#39;ll need the old site reachable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 7 — Monitor Search Console.&lt;/strong&gt; Watch for crawl errors in Google Search Console over the first two weeks. Missing redirects show up as 404s on URLs that previously had inbound links. Fix them as they appear.&lt;/p&gt;
&lt;p&gt;For infrastructure considerations during and after cutover, the &lt;a href=&quot;/blog/deploy-payload-cms-nextjs-16-self-hosted&quot;&gt;Payload CMS + Next.js self-hosted deployment guide&lt;/a&gt; covers the production setup.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Common Failure Points&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Serialized PHP in &lt;code&gt;wp_options&lt;/code&gt;.&lt;/strong&gt; WordPress plugins store configuration as PHP-serialized strings. If you extract &lt;code&gt;wp_options&lt;/code&gt; directly from the database, you&amp;#39;ll encounter values like &lt;code&gt;a:3:{i:0;s:4:&amp;quot;text&amp;quot;;}&lt;/code&gt;. PHP serialization is not JSON — never attempt to parse these as JSON. Extract only the specific option keys you need by name (e.g., &lt;code&gt;blogname&lt;/code&gt;, &lt;code&gt;blogdescription&lt;/code&gt;, &lt;code&gt;siteurl&lt;/code&gt;) and ignore the rest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Orphaned &lt;code&gt;wp_postmeta&lt;/code&gt; rows.&lt;/strong&gt; Deleting posts in WordPress leaves &lt;code&gt;wp_postmeta&lt;/code&gt; rows behind. If your SQL query joins &lt;code&gt;wp_postmeta&lt;/code&gt; to &lt;code&gt;wp_posts&lt;/code&gt;, you may encounter meta rows with no matching post. Filter on &lt;code&gt;wp_posts.post_status = &amp;#39;publish&amp;#39;&lt;/code&gt; and use an &lt;code&gt;INNER JOIN&lt;/code&gt; to exclude orphaned meta.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ACF repeater and flexible content serialization.&lt;/strong&gt; When ACF stores complex field types, it writes the field values as individual rows in &lt;code&gt;wp_postmeta&lt;/code&gt; with numbered key suffixes (e.g., &lt;code&gt;testimonials_0_quote&lt;/code&gt;, &lt;code&gt;testimonials_0_author&lt;/code&gt;, &lt;code&gt;testimonials_1_quote&lt;/code&gt;). There&amp;#39;s also a &lt;code&gt;testimonials&lt;/code&gt; key containing the count. The REST API&amp;#39;s &lt;code&gt;acf&lt;/code&gt; object deserializes these into a proper array automatically. If you&amp;#39;re working from a SQL dump, you&amp;#39;ll need to reconstruct the array from the numbered rows yourself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;WooCommerce data.&lt;/strong&gt; If your WordPress site runs WooCommerce, treat the product, order, and customer data as a completely separate migration scope. WooCommerce stores its data across &lt;code&gt;wp_posts&lt;/code&gt;, &lt;code&gt;wp_postmeta&lt;/code&gt;, and several custom tables (&lt;code&gt;woocommerce_order_items&lt;/code&gt;, &lt;code&gt;woocommerce_order_itemmeta&lt;/code&gt;, etc.). Migrating WooCommerce to Payload&amp;#39;s &lt;a href=&quot;/blog/payload-cms-ecommerce-content-commerce-split&quot;&gt;ecommerce architecture&lt;/a&gt; is a distinct engagement from migrating editorial content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Page builder markup.&lt;/strong&gt; If the WordPress site used Elementor, Divi, Beaver Builder, or WPBakery, the &lt;code&gt;post_content&lt;/code&gt; field contains proprietary shortcode or JSON syntax specific to that builder. &lt;code&gt;content.rendered&lt;/code&gt; from the REST API outputs the rendered HTML, which is usable but often heavily nested and will not convert cleanly to Lexical blocks. Use the staged migration approach for these posts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;_wp_attachment_metadata&lt;/code&gt; and custom image sizes.&lt;/strong&gt; WordPress generates multiple image sizes for every upload (thumbnail, medium, large, custom). Payload&amp;#39;s media handling works differently — you define image sizes in your collection config and Payload generates them on upload. You don&amp;#39;t need to migrate the cropped variants, only the original files. Let Payload regenerate sizes from the originals.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need to keep WordPress running during the migration?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For the media upload pass, yes — the ETL script downloads media files directly from their WordPress URLs. If you&amp;#39;re working from a SQL dump rather than the REST API, you still need file access for the binary uploads. The simplest approach is keeping WordPress on a subdomain (e.g., &lt;code&gt;legacy.yourdomain.com&lt;/code&gt;) until the migration is confirmed complete.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I handle WordPress multisite?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Multisite adds a site ID prefix to all tables (&lt;code&gt;wp_2_posts&lt;/code&gt;, &lt;code&gt;wp_2_postmeta&lt;/code&gt;). The migration logic is identical — you just parameterize the table prefix per site and run the ETL once per site into separate Payload collections or a multi-tenant Payload setup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens to WordPress user passwords?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;WordPress hashes passwords using a custom PHPass implementation that&amp;#39;s incompatible with standard bcrypt. You cannot migrate WordPress password hashes into Payload directly. The cleanest approach: migrate user records without passwords, then trigger a password reset email for all users on launch day.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I run the ETL script incrementally without a full re-run?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes — the &lt;code&gt;wordpressId&lt;/code&gt; field and the &lt;code&gt;modified_after&lt;/code&gt; REST API parameter are what make this possible. On each script run, check for an existing Payload document with the matching &lt;code&gt;wordpressId&lt;/code&gt; before creating. Use &lt;code&gt;payload.update()&lt;/code&gt; for documents that already exist. This means you can stop and resume the ETL at any point without duplicating data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How long should I keep the 301 redirects in &lt;code&gt;next.config.ts&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Permanently. There&amp;#39;s no cost to keeping redirect rules in &lt;code&gt;next.config.ts&lt;/code&gt;, and removing them risks breaking inbound links from external sites that haven&amp;#39;t updated their references. Treat legacy WordPress URL redirects as permanent infrastructure.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Doing This at Scale or With a Tight SEO Deadline?&lt;/h2&gt;
&lt;p&gt;This guide covers the full technical path for a WordPress-to-Payload migration: content modeling, ETL scripting, media re-upload, HTML-to-Lexical conversion, shortcode handling, URL redirects, and cutover sequencing. Running this against a large site — heavy ACF usage, thousands of posts, complex permalink structures, SEO-critical URLs — adds significant scoping, QA, and testing work on top of what&amp;#39;s covered here.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/payload-cms-migration&quot;&gt;My Payload CMS migration service&lt;/a&gt; is exactly this work. If you want a principal-led migration with a realistic timeline and a clean SEO handoff, that&amp;#39;s where to start.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;WordPress-to-Payload is a full stack shift, and the content layer is where most migrations get stuck. The &lt;code&gt;wp_posts&lt;/code&gt; HTML blob problem, the &lt;code&gt;convertHTMLToLexical&lt;/code&gt; image annotation requirement, the ACF field mapping decisions, the redirect coverage — each of these is a real source of project delay if you hit it unprepared. The approach in this guide — media-first migration, &lt;code&gt;wordpressId&lt;/code&gt; mapping on every document, staged HTML handling for complex content, and a freeze + delta-import cutover — gives you a repeatable process that you can stop, resume, and verify at each step.&lt;/p&gt;
&lt;p&gt;If you have questions about specific parts of your migration, drop them in the comments below.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/wordpress-to-payload-migration-guide"/>
        </item>
        <item>
            <title><![CDATA[Best Headless CMS for Next.js in 2026: Decision Guide]]></title>
            <description><![CDATA[<p>I&#39;ve watched teams make the wrong CMS call three times in a row. Not because they didn&#39;t do research — they did. They read the comparison articles, looked at the feature tables, checked the GitHub stars, and made what felt like an informed choice. Then eighteen months later they were rewriting migrations, arguing with non-technical editors who couldn&#39;t find the publish button, or staring at a Contentful invoice that had doubled.</p>
<p>The problem isn&#39;t a lack of information. It&#39;s that every &quot;best headless CMS for Next.js&quot; article in existence is either written by a vendor, curated by a vendor, or structured as a feature matrix that completely misses the actual decision. Feature tables tell you what a CMS can do. They don&#39;t tell you what you&#39;ll regret.</p>
<p>I&#39;ve built production systems on Strapi, Sanity, and Payload across clients in Germany, the UK, and the US. This article is a decision framework based on what actually drives those regrets — and the five axes that determine whether a CMS fits your specific situation, not someone else&#39;s.</p>
<p>If you&#39;ve already decided on Payload and want a deep technical evaluation, <a href="/blog/best-headless-cms-nextjs-payload-2026">that article exists here</a>. This one is for the decision that comes before that.</p>
<hr>
<h2>Why Every CMS Comparison Article Gets This Wrong</h2>
<p>The standard format is: list six CMSes, compare features, give each a star rating, declare a winner. It&#39;s easy to produce and useless to use, because it treats CMS selection as a tooling decision when it&#39;s actually an infrastructure commitment with a three-year tail.</p>
<p>The feature that matters in week one (visual editing, or TypeScript-native schema) is rarely the feature that causes problems in month eighteen. What causes problems in month eighteen is your data ownership model when the vendor changes pricing, your upgrade path when the framework releases a breaking version, your ops burden when your self-hosted instance needs a security patch at 2am.</p>
<p>None of that appears in a feature table.</p>
<p>So before looking at any specific CMS, the real question is which of the five decision axes are hard constraints for your situation. The CMSes follow from the constraints, not the other way around.</p>
<hr>
<h2>The Five Axes That Actually Determine the Right CMS</h2>
<h3>1. Self-Hosted vs SaaS: The Ownership Decision</h3>
<p>This is the most important axis and the one most comparison articles treat as a checkbox. It&#39;s not a checkbox. It&#39;s a commitment with financial, operational, and legal implications that compounds over time.</p>
<p>The SaaS model — Sanity, Contentful, Storyblok — gives you managed infrastructure, global CDN, automatic backups, and zero server administration. You pay a monthly fee and the vendor handles everything that would otherwise be your problem. The tradeoff is that your content lives in their system, their pricing model applies as you scale, and their roadmap decisions affect your product. The cost cliff is real: agencies that put clients on Contentful or Sanity at the free or entry tier consistently report the same pattern — growth is fine until it isn&#39;t, and the jump to the next pricing tier is not a gentle step. Contentful&#39;s Lite plan sits at $300/month; above that is custom enterprise pricing. Sanity&#39;s per-seat Growth plan compounds as the content team grows.</p>
<p>The self-hosted model — Payload, Strapi — means your content lives in your own Postgres instance, your own infrastructure, and you control the data completely. GDPR compliance becomes a constraint you manage rather than a promise you take on faith. Cost predictability at scale is real because you&#39;re paying for compute, not for content volume. The tradeoff is that you own the operational problem: database backups, version upgrades, security patches, Postgres management.</p>
<p>The regret pattern on the SaaS side is cost surprises and vendor pricing changes. The regret pattern on the self-hosted side is operational overhead — teams that choose Strapi routinely underestimate how resource-intensive it runs, and discover gaps like missing native 2FA and painful version transitions only after they&#39;re committed.</p>
<p>The decision rule here is clean: if data residency, GDPR compliance at the infrastructure level, or cost predictability at scale are hard requirements, start with self-hosted. If zero infrastructure ownership and fast onboarding matter more than those constraints, SaaS is the faster path.</p>
<h3>2. Code-First vs GUI-First: DX vs Editor UX</h3>
<p>The second axis determines who controls the content model and who lives in the CMS daily.</p>
<p>Code-first means your content schema is defined in code — TypeScript files, version-controlled, reviewed in PRs, deployed like application code. Payload is the extreme end of this: you define collections in TypeScript, your types are generated automatically, and the admin UI is a reflection of your code rather than a source of truth for schema. Strapi v5 sits in similar territory, with TypeScript schema definitions and a more code-first philosophy than its v4 predecessor. The developer experience of code-first is excellent. The editor experience for non-technical content teams can be unfamiliar.</p>
<p>GUI-first means content editors can modify content types, add fields, and reshape the data model through a visual interface without touching code. Contentful and Storyblok lean this way — marketing teams can adapt the content model without filing a ticket. The tradeoff is that schema changes made through a GUI are harder to version, review, and audit.</p>
<p>Sanity sits in the middle in practice. Schemas are defined in code, which gives you developer control and version history. But the Studio UI is genuinely polished — it&#39;s the one CMS where non-technical editors consistently adapt without much training.</p>
<p>One data point worth naming directly: Strapi v5.5 has had a difficult period. Developers in active community threads describe months of build issues, broken TypeScript compilation for custom plugins, and documentation that didn&#39;t keep up with the release. Some teams have switched to Directus or Payload mid-project as a result. This isn&#39;t fatal for Strapi — v5 is the current major and the TypeScript improvements are real — but it&#39;s a risk to weigh honestly if your team will be building custom plugins on an aggressive timeline.</p>
<p>The decision rule: if your entire team is developers and the content operation is small, code-first DX wins. If a non-technical marketing team manages content daily and the editor experience is a hard requirement, GUI-first or Sanity&#39;s balanced middle is the safer choice.</p>
<h3>3. Next.js App Router Compatibility: More Than &quot;Next.js Support&quot;</h3>
<p>Every comparison article says each CMS &quot;supports Next.js.&quot; That&#39;s nearly useless information in 2026. The App Router has fundamentally changed how Next.js applications are structured — React Server Components, streaming, Draft Mode, <code>revalidateTag</code> for ISR, and the shift away from <code>getServerSideProps</code>. CMS support varies significantly at this level, and the difference between native App Router integration and a generic REST API fetch in a server component is not just ergonomic. It&#39;s performance and architectural clarity.</p>
<p><strong>Payload</strong> has the strongest App Router integration by design. Because Payload installs directly into your Next.js application rather than running as a separate service, server components can query the database through Payload&#39;s local API without an HTTP round-trip. No authentication layer to cross. No serialisation boundary. Draft Mode is handled inside the same application. This is architecturally different from every other option on this list.</p>
<p><strong>Sanity</strong> has explicit App Router support through the <code>next-sanity</code> package, with documented <code>revalidateTag</code> integration and a functioning Draft Mode pattern via the Live Content API. This is mature and well-maintained.</p>
<p><strong>Storyblok</strong> has first-class App Router support with an official guide tested against Next.js 15.4. Draft Mode integration with the Visual Editor is documented and maintained. For teams that need Storyblok&#39;s visual editing with App Router, the path is clear.</p>
<p><strong>Contentful and Strapi</strong> work through their REST or GraphQL APIs, which are compatible with the App Router but don&#39;t offer SDK-level optimisations for server components, streaming, or App Router-specific patterns. They&#39;re adequate but generic.</p>
<p>If you&#39;re building for App Router from the start — which you should be for any new project in 2026 — this axis matters more than it appears in most comparisons.</p>
<h3>4. AI Readiness at the Data-Model Level</h3>
<p>This is the axis that no current comparison article addresses properly, and it&#39;s increasingly the one that determines whether your CMS choice ages well.</p>
<p>The question isn&#39;t which CMS has &quot;AI features.&quot; It&#39;s which CMS is architecturally ready for RAG pipelines, MCP server exposure, and event-driven AI automation. If you&#39;re building a content system in 2026 and not thinking about how AI will interact with it in the next two years, you&#39;re planning a migration later.</p>
<p>AI-ready content architecture requires four things at the data-model level. First, structured JSON fields rather than HTML blobs — AI systems need to consume specific fields reliably, not parse presentation-coupled markup. Second, event-driven change propagation so that when content changes, AI workflows (embedding sync, translation, validation) trigger automatically. Third, native or easily integrated vector index support for semantic search and RAG. Fourth, access-control-aware retrieval so that in user-scoped RAG scenarios, the LLM only sees content the authenticated user is permitted to access.</p>
<p>Here is how the main options score against those requirements:</p>
<p><strong>Payload</strong> is the only CMS on this list that positions itself as built for RAG from the ground up. AI Auto-Embedding is a native feature that adds vector indexes directly into your existing Postgres database, handles chunking and embedding sync automatically as content changes, and applies user-level access control to RAG queries. There is an official Payload MCP Server for exposing your content to AI assistants. For teams building AI-native applications, this is the only option with a first-party RAG story at the infrastructure level.</p>
<p><strong>Sanity</strong> has a comparable architecture story: the Embeddings Index handles semantic search natively, Sanity Functions enable event-driven automation where content changes can trigger LLM processing with results written back to draft fields, and an official MCP server exists. Sanity&#39;s AI architecture guidance — AI writes to drafts, humans publish — is well-documented. The difference from Payload is that Sanity&#39;s data lives in their managed infrastructure, which intersects with the data ownership axis.</p>
<p><strong>Contentful</strong> has an official MCP server and a webhook framework that can drive external vector sync. AI Actions and AI Suggestions handle content operations. But there is no native RAG story comparable to Payload or Sanity — you would integrate an external vector database and manage synchronisation yourself through webhooks.</p>
<p><strong>Storyblok</strong> includes AI Search and AI credits in their pricing tiers, and community MCP tooling exists. No first-party vector index or RAG-native architecture. External integration is possible but not guided.</p>
<p><strong>Strapi</strong> has webhooks and lifecycle hooks that can feed content into external AI systems, and a community MCP server. No native AI-readiness features in the core product. The most manual path of the five if AI integration is a priority.</p>
<p>If you know AI workflows will be part of your architecture in the next 18 months, treat this axis as a leading indicator. Picking a CMS that requires a full content model migration to enable RAG later is expensive in ways that don&#39;t show up in the initial evaluation.</p>
<h3>5. Upgrade Path Stability</h3>
<p>The most-ignored axis, and the one that generates the most post-mortem regret.</p>
<p>This applies differently depending on your hosting model. For self-hosted CMSes, the risk is major version transitions — API changes, plugin incompatibilities, documentation gaps that don&#39;t keep pace with releases. The Strapi v4 to v5 situation is a concrete example: v4 is now in maintenance mode with security updates guaranteed only until April 2026, v5 introduced breaking changes across plugin APIs, and teams with significant custom plugin investment have reported TypeScript compilation failures and weeks spent on what should have been routine upgrades.</p>
<p>For SaaS CMSes, the risk is different — pricing model changes, feature deprecations, and acquisition-driven roadmap uncertainty. Payload&#39;s acquisition by Figma in June 2025 raised genuine concerns in some developer communities about long-term OSS continuity, even though the weekly release cadence has continued unchanged (v3.78.0 shipped February 27, 2026) and the official position is that self-hosting remains fully supported. The concern is real regardless of whether it proves founded, because it creates evaluation friction for new adopters.</p>
<p>The honest framing: stability risk lives on both sides of the hosted/self-hosted divide. What to actually check before committing is the release history (are major versions introducing breaking changes at high frequency?), the migration documentation quality (is upgrading well-documented or does it require reading GitHub issues?), and whether the vendor has a track record of deprecating features teams depend on without adequate runway.</p>
<hr>
<h2>The Decision Matrix</h2>
<p>Based on the five axes, here is the framework in plain terms.</p>
<p><strong>Choose Payload if</strong> your team is all developers, TypeScript is native to your stack, data ownership and GDPR compliance are hard requirements, you&#39;re building AI workflows into the application, and you want the tightest possible App Router integration. Accept that the ecosystem is smaller than Strapi or WordPress, the documentation is still catching up with the release pace, and you will build more integrations yourself than you would with a more mature platform.</p>
<p><strong>Choose Sanity if</strong> you need a polished editor experience for non-technical content teams, your content operation is editorial-heavy, you want structured content at scale with a mature ecosystem, and SaaS hosting is acceptable. Sanity&#39;s AI architecture story is strong and the App Router integration is solid. Accept per-seat pricing that compounds as your team grows, and content that lives in Sanity&#39;s managed infrastructure rather than yours.</p>
<p><strong>Choose Contentful if</strong> you are in an enterprise context with existing vendor relationships, compliance SLAs and 24/7 support are contractual requirements, and budget is not the primary constraint. Accept that you are paying for enterprise assurance, the API model is generic with no App Router-level optimisations, and the jump from Lite to enterprise pricing is a significant step.</p>
<p><strong>Choose Storyblok if</strong> visual editing is a non-negotiable requirement — your marketing team needs to build and edit pages through a visual interface, not a form-based editor. The App Router and Draft Mode integration is first-class, and the pricing model is reasonable at small to medium scale. Accept no native RAG or AI-readiness story in the core product.</p>
<p><strong>Choose Strapi if</strong> open-source licensing is required, your team wants a GUI schema builder without going fully code-first, and SaaS pricing is a hard blocker. Strapi v5 is the current major with meaningful TypeScript improvements. Accept the v5 upgrade risk if you have significant custom plugin investment, and plan to build your AI integration layer manually.</p>
<p><strong>Choose WordPress headless if</strong> you are migrating an existing WordPress site with a large content corpus, your editorial team will not change tools, or your plugin dependencies are deep enough that replicating them in a headless CMS costs more than keeping WordPress as the backend. WordPress with WPGraphQL 2.x and MCP adapters is a real 2026 stack. Accept the developer experience overhead of building a Next.js frontend on WPGraphQL.</p>
<hr>
<h2>TCO: What You Actually Pay Over 36 Months</h2>
<p>Plan pricing is the least useful number in a CMS evaluation. Here is what the cost picture actually looks like across three scenarios.</p>
<p><strong>Solo founder or very small team with a simple content model:</strong> SaaS options are genuinely free or very low cost at this scale — Sanity&#39;s free tier, Storyblok Starter, Contentful Free. Payload or Strapi self-hosted on a $20/month VPS is comparable in spend. The real difference is time: SaaS takes minutes to set up, self-hosted takes a day and requires ongoing maintenance. At this scale, TCO favours SaaS unless data ownership is a hard constraint.</p>
<p><strong>10-person company with a content team of three to five non-technical editors:</strong> Sanity Growth at $15/seat/month means $45–75/month for the content team alone before any usage overages. Contentful Lite at $300/month is a flat fee but a meaningful commitment for a small business. Storyblok Growth at $99/month is competitive. Payload self-hosted on managed Postgres plus a small VPS runs $30–60/month in infrastructure, but requires DevOps time for setup, upgrades, and incident response — budget roughly two to four hours per month ongoing. Over 36 months, the SaaS options typically cost more in absolute spend; the self-hosted option costs more in engineering time. Which is more expensive for your team depends entirely on your engineering rate.</p>
<p><strong>Growth-stage company with compliance requirements and multi-market needs:</strong> Contentful Premium and Sanity Enterprise are both contact-sales territory. Budget $1,000–5,000+ per month depending on volume and seats. Self-hosted Payload at this scale requires a proper infrastructure setup — managed Postgres, automated backups, monitoring, staging environments — which runs $200–500/month in infrastructure plus meaningful DevOps overhead. The compliance advantage of self-hosting (your data, your jurisdiction, your audit trail) is real at this scale and often worth the operational cost when weighed against enterprise SaaS pricing.</p>
<p>The number nobody models is migration cost. If you choose wrong and need to change CMSes 18 months in, you are looking at a full content model re-mapping exercise, editor retraining, and frontend refactoring. That cost — typically two to four weeks of senior engineering time — should weigh in the initial evaluation, not only the monthly subscription.</p>
<hr>
<h2>Putting the Framework to Work</h2>
<p>There is no universally best headless CMS for Next.js. There is the right one for your specific constraint set, and that is determined by working through the five axes in order: ownership model first, then editor UX, then App Router depth, then AI readiness, then upgrade stability.</p>
<p>The matrix above gives you the shortcut. But the honest path is to identify which axes are hard constraints for your project — the ones where the wrong choice causes a migration later — and let those drive the decision. Feature tables don&#39;t surface those constraints. This framework tries to.</p>
<p>If you have worked through this and Payload is the answer for your situation, the deeper evaluation of Payload&#39;s architecture, growth trajectory, and specific gotchas is <a href="/blog/best-headless-cms-nextjs-payload-2026">in this article</a>. If you are comparing Payload directly against Sanity, <a href="/blog/sanity-vs-payload-hosted-vs-self-hosted-cms-decision-tree">that comparison covers the architectural trade-offs in detail</a>. For the vendor lock-in question specifically, <a href="/blog/cms-vendor-lock-in-sanity-payload-2026">this piece breaks down what switching actually costs</a>. And if you&#39;ve already chosen Payload and need to decide where to host it, the <a href="/payload-cms-hosting">Payload CMS hosting guide</a> walks through Vercel, Cloudflare, VPS, and managed container options with real cost comparisons.</p>
<p>If you have questions about how these axes apply to your specific project — the constraint set that doesn&#39;t fit neatly into a matrix — drop a comment below. And if you found this useful, subscribe for more.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/best-headless-cms-nextjs-2026-decision-framework</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/best-headless-cms-nextjs-2026-decision-framework</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Tue, 07 Apr 2026 05:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;I&amp;#39;ve watched teams make the wrong CMS call three times in a row. Not because they didn&amp;#39;t do research — they did. They read the comparison articles, looked at the feature tables, checked the GitHub stars, and made what felt like an informed choice. Then eighteen months later they were rewriting migrations, arguing with non-technical editors who couldn&amp;#39;t find the publish button, or staring at a Contentful invoice that had doubled.&lt;/p&gt;
&lt;p&gt;The problem isn&amp;#39;t a lack of information. It&amp;#39;s that every &amp;quot;best headless CMS for Next.js&amp;quot; article in existence is either written by a vendor, curated by a vendor, or structured as a feature matrix that completely misses the actual decision. Feature tables tell you what a CMS can do. They don&amp;#39;t tell you what you&amp;#39;ll regret.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve built production systems on Strapi, Sanity, and Payload across clients in Germany, the UK, and the US. This article is a decision framework based on what actually drives those regrets — and the five axes that determine whether a CMS fits your specific situation, not someone else&amp;#39;s.&lt;/p&gt;
&lt;p&gt;If you&amp;#39;ve already decided on Payload and want a deep technical evaluation, &lt;a href=&quot;/blog/best-headless-cms-nextjs-payload-2026&quot;&gt;that article exists here&lt;/a&gt;. This one is for the decision that comes before that.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Why Every CMS Comparison Article Gets This Wrong&lt;/h2&gt;
&lt;p&gt;The standard format is: list six CMSes, compare features, give each a star rating, declare a winner. It&amp;#39;s easy to produce and useless to use, because it treats CMS selection as a tooling decision when it&amp;#39;s actually an infrastructure commitment with a three-year tail.&lt;/p&gt;
&lt;p&gt;The feature that matters in week one (visual editing, or TypeScript-native schema) is rarely the feature that causes problems in month eighteen. What causes problems in month eighteen is your data ownership model when the vendor changes pricing, your upgrade path when the framework releases a breaking version, your ops burden when your self-hosted instance needs a security patch at 2am.&lt;/p&gt;
&lt;p&gt;None of that appears in a feature table.&lt;/p&gt;
&lt;p&gt;So before looking at any specific CMS, the real question is which of the five decision axes are hard constraints for your situation. The CMSes follow from the constraints, not the other way around.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Five Axes That Actually Determine the Right CMS&lt;/h2&gt;
&lt;h3&gt;1. Self-Hosted vs SaaS: The Ownership Decision&lt;/h3&gt;
&lt;p&gt;This is the most important axis and the one most comparison articles treat as a checkbox. It&amp;#39;s not a checkbox. It&amp;#39;s a commitment with financial, operational, and legal implications that compounds over time.&lt;/p&gt;
&lt;p&gt;The SaaS model — Sanity, Contentful, Storyblok — gives you managed infrastructure, global CDN, automatic backups, and zero server administration. You pay a monthly fee and the vendor handles everything that would otherwise be your problem. The tradeoff is that your content lives in their system, their pricing model applies as you scale, and their roadmap decisions affect your product. The cost cliff is real: agencies that put clients on Contentful or Sanity at the free or entry tier consistently report the same pattern — growth is fine until it isn&amp;#39;t, and the jump to the next pricing tier is not a gentle step. Contentful&amp;#39;s Lite plan sits at $300/month; above that is custom enterprise pricing. Sanity&amp;#39;s per-seat Growth plan compounds as the content team grows.&lt;/p&gt;
&lt;p&gt;The self-hosted model — Payload, Strapi — means your content lives in your own Postgres instance, your own infrastructure, and you control the data completely. GDPR compliance becomes a constraint you manage rather than a promise you take on faith. Cost predictability at scale is real because you&amp;#39;re paying for compute, not for content volume. The tradeoff is that you own the operational problem: database backups, version upgrades, security patches, Postgres management.&lt;/p&gt;
&lt;p&gt;The regret pattern on the SaaS side is cost surprises and vendor pricing changes. The regret pattern on the self-hosted side is operational overhead — teams that choose Strapi routinely underestimate how resource-intensive it runs, and discover gaps like missing native 2FA and painful version transitions only after they&amp;#39;re committed.&lt;/p&gt;
&lt;p&gt;The decision rule here is clean: if data residency, GDPR compliance at the infrastructure level, or cost predictability at scale are hard requirements, start with self-hosted. If zero infrastructure ownership and fast onboarding matter more than those constraints, SaaS is the faster path.&lt;/p&gt;
&lt;h3&gt;2. Code-First vs GUI-First: DX vs Editor UX&lt;/h3&gt;
&lt;p&gt;The second axis determines who controls the content model and who lives in the CMS daily.&lt;/p&gt;
&lt;p&gt;Code-first means your content schema is defined in code — TypeScript files, version-controlled, reviewed in PRs, deployed like application code. Payload is the extreme end of this: you define collections in TypeScript, your types are generated automatically, and the admin UI is a reflection of your code rather than a source of truth for schema. Strapi v5 sits in similar territory, with TypeScript schema definitions and a more code-first philosophy than its v4 predecessor. The developer experience of code-first is excellent. The editor experience for non-technical content teams can be unfamiliar.&lt;/p&gt;
&lt;p&gt;GUI-first means content editors can modify content types, add fields, and reshape the data model through a visual interface without touching code. Contentful and Storyblok lean this way — marketing teams can adapt the content model without filing a ticket. The tradeoff is that schema changes made through a GUI are harder to version, review, and audit.&lt;/p&gt;
&lt;p&gt;Sanity sits in the middle in practice. Schemas are defined in code, which gives you developer control and version history. But the Studio UI is genuinely polished — it&amp;#39;s the one CMS where non-technical editors consistently adapt without much training.&lt;/p&gt;
&lt;p&gt;One data point worth naming directly: Strapi v5.5 has had a difficult period. Developers in active community threads describe months of build issues, broken TypeScript compilation for custom plugins, and documentation that didn&amp;#39;t keep up with the release. Some teams have switched to Directus or Payload mid-project as a result. This isn&amp;#39;t fatal for Strapi — v5 is the current major and the TypeScript improvements are real — but it&amp;#39;s a risk to weigh honestly if your team will be building custom plugins on an aggressive timeline.&lt;/p&gt;
&lt;p&gt;The decision rule: if your entire team is developers and the content operation is small, code-first DX wins. If a non-technical marketing team manages content daily and the editor experience is a hard requirement, GUI-first or Sanity&amp;#39;s balanced middle is the safer choice.&lt;/p&gt;
&lt;h3&gt;3. Next.js App Router Compatibility: More Than &amp;quot;Next.js Support&amp;quot;&lt;/h3&gt;
&lt;p&gt;Every comparison article says each CMS &amp;quot;supports Next.js.&amp;quot; That&amp;#39;s nearly useless information in 2026. The App Router has fundamentally changed how Next.js applications are structured — React Server Components, streaming, Draft Mode, &lt;code&gt;revalidateTag&lt;/code&gt; for ISR, and the shift away from &lt;code&gt;getServerSideProps&lt;/code&gt;. CMS support varies significantly at this level, and the difference between native App Router integration and a generic REST API fetch in a server component is not just ergonomic. It&amp;#39;s performance and architectural clarity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Payload&lt;/strong&gt; has the strongest App Router integration by design. Because Payload installs directly into your Next.js application rather than running as a separate service, server components can query the database through Payload&amp;#39;s local API without an HTTP round-trip. No authentication layer to cross. No serialisation boundary. Draft Mode is handled inside the same application. This is architecturally different from every other option on this list.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sanity&lt;/strong&gt; has explicit App Router support through the &lt;code&gt;next-sanity&lt;/code&gt; package, with documented &lt;code&gt;revalidateTag&lt;/code&gt; integration and a functioning Draft Mode pattern via the Live Content API. This is mature and well-maintained.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Storyblok&lt;/strong&gt; has first-class App Router support with an official guide tested against Next.js 15.4. Draft Mode integration with the Visual Editor is documented and maintained. For teams that need Storyblok&amp;#39;s visual editing with App Router, the path is clear.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Contentful and Strapi&lt;/strong&gt; work through their REST or GraphQL APIs, which are compatible with the App Router but don&amp;#39;t offer SDK-level optimisations for server components, streaming, or App Router-specific patterns. They&amp;#39;re adequate but generic.&lt;/p&gt;
&lt;p&gt;If you&amp;#39;re building for App Router from the start — which you should be for any new project in 2026 — this axis matters more than it appears in most comparisons.&lt;/p&gt;
&lt;h3&gt;4. AI Readiness at the Data-Model Level&lt;/h3&gt;
&lt;p&gt;This is the axis that no current comparison article addresses properly, and it&amp;#39;s increasingly the one that determines whether your CMS choice ages well.&lt;/p&gt;
&lt;p&gt;The question isn&amp;#39;t which CMS has &amp;quot;AI features.&amp;quot; It&amp;#39;s which CMS is architecturally ready for RAG pipelines, MCP server exposure, and event-driven AI automation. If you&amp;#39;re building a content system in 2026 and not thinking about how AI will interact with it in the next two years, you&amp;#39;re planning a migration later.&lt;/p&gt;
&lt;p&gt;AI-ready content architecture requires four things at the data-model level. First, structured JSON fields rather than HTML blobs — AI systems need to consume specific fields reliably, not parse presentation-coupled markup. Second, event-driven change propagation so that when content changes, AI workflows (embedding sync, translation, validation) trigger automatically. Third, native or easily integrated vector index support for semantic search and RAG. Fourth, access-control-aware retrieval so that in user-scoped RAG scenarios, the LLM only sees content the authenticated user is permitted to access.&lt;/p&gt;
&lt;p&gt;Here is how the main options score against those requirements:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Payload&lt;/strong&gt; is the only CMS on this list that positions itself as built for RAG from the ground up. AI Auto-Embedding is a native feature that adds vector indexes directly into your existing Postgres database, handles chunking and embedding sync automatically as content changes, and applies user-level access control to RAG queries. There is an official Payload MCP Server for exposing your content to AI assistants. For teams building AI-native applications, this is the only option with a first-party RAG story at the infrastructure level.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sanity&lt;/strong&gt; has a comparable architecture story: the Embeddings Index handles semantic search natively, Sanity Functions enable event-driven automation where content changes can trigger LLM processing with results written back to draft fields, and an official MCP server exists. Sanity&amp;#39;s AI architecture guidance — AI writes to drafts, humans publish — is well-documented. The difference from Payload is that Sanity&amp;#39;s data lives in their managed infrastructure, which intersects with the data ownership axis.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Contentful&lt;/strong&gt; has an official MCP server and a webhook framework that can drive external vector sync. AI Actions and AI Suggestions handle content operations. But there is no native RAG story comparable to Payload or Sanity — you would integrate an external vector database and manage synchronisation yourself through webhooks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Storyblok&lt;/strong&gt; includes AI Search and AI credits in their pricing tiers, and community MCP tooling exists. No first-party vector index or RAG-native architecture. External integration is possible but not guided.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strapi&lt;/strong&gt; has webhooks and lifecycle hooks that can feed content into external AI systems, and a community MCP server. No native AI-readiness features in the core product. The most manual path of the five if AI integration is a priority.&lt;/p&gt;
&lt;p&gt;If you know AI workflows will be part of your architecture in the next 18 months, treat this axis as a leading indicator. Picking a CMS that requires a full content model migration to enable RAG later is expensive in ways that don&amp;#39;t show up in the initial evaluation.&lt;/p&gt;
&lt;h3&gt;5. Upgrade Path Stability&lt;/h3&gt;
&lt;p&gt;The most-ignored axis, and the one that generates the most post-mortem regret.&lt;/p&gt;
&lt;p&gt;This applies differently depending on your hosting model. For self-hosted CMSes, the risk is major version transitions — API changes, plugin incompatibilities, documentation gaps that don&amp;#39;t keep pace with releases. The Strapi v4 to v5 situation is a concrete example: v4 is now in maintenance mode with security updates guaranteed only until April 2026, v5 introduced breaking changes across plugin APIs, and teams with significant custom plugin investment have reported TypeScript compilation failures and weeks spent on what should have been routine upgrades.&lt;/p&gt;
&lt;p&gt;For SaaS CMSes, the risk is different — pricing model changes, feature deprecations, and acquisition-driven roadmap uncertainty. Payload&amp;#39;s acquisition by Figma in June 2025 raised genuine concerns in some developer communities about long-term OSS continuity, even though the weekly release cadence has continued unchanged (v3.78.0 shipped February 27, 2026) and the official position is that self-hosting remains fully supported. The concern is real regardless of whether it proves founded, because it creates evaluation friction for new adopters.&lt;/p&gt;
&lt;p&gt;The honest framing: stability risk lives on both sides of the hosted/self-hosted divide. What to actually check before committing is the release history (are major versions introducing breaking changes at high frequency?), the migration documentation quality (is upgrading well-documented or does it require reading GitHub issues?), and whether the vendor has a track record of deprecating features teams depend on without adequate runway.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Decision Matrix&lt;/h2&gt;
&lt;p&gt;Based on the five axes, here is the framework in plain terms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose Payload if&lt;/strong&gt; your team is all developers, TypeScript is native to your stack, data ownership and GDPR compliance are hard requirements, you&amp;#39;re building AI workflows into the application, and you want the tightest possible App Router integration. Accept that the ecosystem is smaller than Strapi or WordPress, the documentation is still catching up with the release pace, and you will build more integrations yourself than you would with a more mature platform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose Sanity if&lt;/strong&gt; you need a polished editor experience for non-technical content teams, your content operation is editorial-heavy, you want structured content at scale with a mature ecosystem, and SaaS hosting is acceptable. Sanity&amp;#39;s AI architecture story is strong and the App Router integration is solid. Accept per-seat pricing that compounds as your team grows, and content that lives in Sanity&amp;#39;s managed infrastructure rather than yours.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose Contentful if&lt;/strong&gt; you are in an enterprise context with existing vendor relationships, compliance SLAs and 24/7 support are contractual requirements, and budget is not the primary constraint. Accept that you are paying for enterprise assurance, the API model is generic with no App Router-level optimisations, and the jump from Lite to enterprise pricing is a significant step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose Storyblok if&lt;/strong&gt; visual editing is a non-negotiable requirement — your marketing team needs to build and edit pages through a visual interface, not a form-based editor. The App Router and Draft Mode integration is first-class, and the pricing model is reasonable at small to medium scale. Accept no native RAG or AI-readiness story in the core product.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose Strapi if&lt;/strong&gt; open-source licensing is required, your team wants a GUI schema builder without going fully code-first, and SaaS pricing is a hard blocker. Strapi v5 is the current major with meaningful TypeScript improvements. Accept the v5 upgrade risk if you have significant custom plugin investment, and plan to build your AI integration layer manually.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose WordPress headless if&lt;/strong&gt; you are migrating an existing WordPress site with a large content corpus, your editorial team will not change tools, or your plugin dependencies are deep enough that replicating them in a headless CMS costs more than keeping WordPress as the backend. WordPress with WPGraphQL 2.x and MCP adapters is a real 2026 stack. Accept the developer experience overhead of building a Next.js frontend on WPGraphQL.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;TCO: What You Actually Pay Over 36 Months&lt;/h2&gt;
&lt;p&gt;Plan pricing is the least useful number in a CMS evaluation. Here is what the cost picture actually looks like across three scenarios.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solo founder or very small team with a simple content model:&lt;/strong&gt; SaaS options are genuinely free or very low cost at this scale — Sanity&amp;#39;s free tier, Storyblok Starter, Contentful Free. Payload or Strapi self-hosted on a $20/month VPS is comparable in spend. The real difference is time: SaaS takes minutes to set up, self-hosted takes a day and requires ongoing maintenance. At this scale, TCO favours SaaS unless data ownership is a hard constraint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;10-person company with a content team of three to five non-technical editors:&lt;/strong&gt; Sanity Growth at $15/seat/month means $45–75/month for the content team alone before any usage overages. Contentful Lite at $300/month is a flat fee but a meaningful commitment for a small business. Storyblok Growth at $99/month is competitive. Payload self-hosted on managed Postgres plus a small VPS runs $30–60/month in infrastructure, but requires DevOps time for setup, upgrades, and incident response — budget roughly two to four hours per month ongoing. Over 36 months, the SaaS options typically cost more in absolute spend; the self-hosted option costs more in engineering time. Which is more expensive for your team depends entirely on your engineering rate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Growth-stage company with compliance requirements and multi-market needs:&lt;/strong&gt; Contentful Premium and Sanity Enterprise are both contact-sales territory. Budget $1,000–5,000+ per month depending on volume and seats. Self-hosted Payload at this scale requires a proper infrastructure setup — managed Postgres, automated backups, monitoring, staging environments — which runs $200–500/month in infrastructure plus meaningful DevOps overhead. The compliance advantage of self-hosting (your data, your jurisdiction, your audit trail) is real at this scale and often worth the operational cost when weighed against enterprise SaaS pricing.&lt;/p&gt;
&lt;p&gt;The number nobody models is migration cost. If you choose wrong and need to change CMSes 18 months in, you are looking at a full content model re-mapping exercise, editor retraining, and frontend refactoring. That cost — typically two to four weeks of senior engineering time — should weigh in the initial evaluation, not only the monthly subscription.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Putting the Framework to Work&lt;/h2&gt;
&lt;p&gt;There is no universally best headless CMS for Next.js. There is the right one for your specific constraint set, and that is determined by working through the five axes in order: ownership model first, then editor UX, then App Router depth, then AI readiness, then upgrade stability.&lt;/p&gt;
&lt;p&gt;The matrix above gives you the shortcut. But the honest path is to identify which axes are hard constraints for your project — the ones where the wrong choice causes a migration later — and let those drive the decision. Feature tables don&amp;#39;t surface those constraints. This framework tries to.&lt;/p&gt;
&lt;p&gt;If you have worked through this and Payload is the answer for your situation, the deeper evaluation of Payload&amp;#39;s architecture, growth trajectory, and specific gotchas is &lt;a href=&quot;/blog/best-headless-cms-nextjs-payload-2026&quot;&gt;in this article&lt;/a&gt;. If you are comparing Payload directly against Sanity, &lt;a href=&quot;/blog/sanity-vs-payload-hosted-vs-self-hosted-cms-decision-tree&quot;&gt;that comparison covers the architectural trade-offs in detail&lt;/a&gt;. For the vendor lock-in question specifically, &lt;a href=&quot;/blog/cms-vendor-lock-in-sanity-payload-2026&quot;&gt;this piece breaks down what switching actually costs&lt;/a&gt;. And if you&amp;#39;ve already chosen Payload and need to decide where to host it, the &lt;a href=&quot;/payload-cms-hosting&quot;&gt;Payload CMS hosting guide&lt;/a&gt; walks through Vercel, Cloudflare, VPS, and managed container options with real cost comparisons.&lt;/p&gt;
&lt;p&gt;If you have questions about how these axes apply to your specific project — the constraint set that doesn&amp;#39;t fit neatly into a matrix — drop a comment below. And if you found this useful, subscribe for more.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/best-headless-cms-nextjs-2026-decision-framework"/>
        </item>
        <item>
            <title><![CDATA[Multi-tenant CMS: Reduce Website Fragmentation Fast]]></title>
            <description><![CDATA[<p>Multi-brand and multi-subsidiary companies often reach a point where managing their websites costs more than it should — not because they have too many websites, but because those websites run on too many disconnected systems. A multi-tenant CMS architecture solves this by letting multiple websites share one technical foundation while keeping brand identity and content fully independent where it needs to be. This article explains what that means in practical terms, when it makes sense, and what questions to ask before deciding whether consolidation is right for your organization.</p>
<hr>
<p>I recently worked with a company operating across several markets — multiple brands, each with its own website, its own CMS, its own hosting setup, and its own publishing workflow. One market ran on WordPress. Another on a different platform. A third on something nobody quite remembered choosing. On paper, the setup looked reasonable. In practice, every website update had to be coordinated separately, brand consistency drifted over time, and adding a new market meant standing up an entirely new system from scratch. The question they came to me with was not really a technical question. It was an operational one: how do we stop managing five versions of the same problem?</p>
<p>That situation is more common than it sounds. And the answer, more often than not, involves rethinking the underlying architecture rather than rebuilding each website one by one.</p>
<hr>
<h2>Multi-Site Management Should Not Require Multiple Systems</h2>
<p>Every growing company eventually manages more than one web presence. That is normal. What creates problems is not the number of websites — it is what happens to the systems behind them.</p>
<p>When websites are built separately, they tend to accumulate separately too. Different CMS platforms, different hosting providers, different design systems, different vendor contracts, different technical setups. Over time, the stack multiplies because each new website or market makes sense as a standalone decision at the moment it is launched.</p>
<p>The fragmentation is not always visible from the outside. Brand websites can look well-maintained while the infrastructure behind them is a patchwork of disconnected systems. But internally, the cost of that fragmentation is constant.</p>
<hr>
<h2>What Fragmentation Looks Like Inside a Growing Organization</h2>
<p>If your organization manages websites across multiple brands, subsidiaries, or geographic markets, some of these will sound familiar.</p>
<p>Every update gets repeated. A global navigation change, a new privacy policy, a design adjustment — it has to be applied separately on each website, by different people, on different platforms, sometimes in different languages. Teams spend time on repetition instead of meaningful work.</p>
<p>Updates fall out of sync. Because changes are applied manually across systems, they drift. One website reflects the new brand guidelines. Another still shows the old ones. A third has an intermediate version that nobody intended to ship. The gap between what the business decided and what visitors actually see grows quietly over time.</p>
<p>Governance becomes unclear. When each website has its own system and its own team managing it, accountability becomes fragmented too. Who owns the standard? Who approves content before it goes live? Who notices when something is wrong? These questions are hard to answer cleanly when the infrastructure is different for every brand.</p>
<p>Technical debt compounds. Each system has its own update cycle, its own security patches, its own hosting contract. Maintaining five platforms means maintaining five separate cost and risk profiles. When something breaks, it is harder to diagnose and fix because there is no shared foundation.</p>
<p>New market launches are slow. Standing up a new brand website from scratch — finding a vendor, configuring a new CMS, migrating content, setting up workflows — takes months. With a shared foundation, that same process can take weeks.</p>
<hr>
<h2>The Better Model: One Foundation, Controlled Independence</h2>
<p>A multi-tenant CMS architecture changes the operating model. Instead of running separate systems for each brand or market, you run one system that can power multiple websites simultaneously. Each website still has its own identity, its own content, and its own team managing it. But the underlying infrastructure is shared.</p>
<p>The key distinction decision makers often miss is this: multi-tenancy is not about forcing all brands into one giant generic website. It is about sharing the infrastructure while keeping the surface layer distinct.</p>
<p>Think of it the way a well-run group operates at the corporate level. Shared finance, legal, and HR systems. Separate brand strategies. The structure is shared. The identity is independent.</p>
<p>That is the same principle applied to web infrastructure.</p>
<hr>
<h2>What Stays Shared, What Stays Separate</h2>
<p>The practical question is always: what actually gets shared, and what stays brand-specific?</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Shared across brands</th>
<th>Stays brand-specific</th>
</tr>
</thead>
<tbody><tr>
<td>CMS and admin interface</td>
<td>Single login, one system</td>
<td>Content, pages, media per brand</td>
</tr>
<tr>
<td>User management</td>
<td>Central permissions model</td>
<td>Team access scoped per brand</td>
</tr>
<tr>
<td>Hosting and infrastructure</td>
<td>One deployment, one maintenance cycle</td>
<td>Subdomain or domain per brand</td>
</tr>
<tr>
<td>Developer foundation</td>
<td>One codebase to maintain</td>
<td>Templates and design tokens per brand</td>
</tr>
<tr>
<td>Security and compliance</td>
<td>One update cycle, one patch process</td>
<td>N/A</td>
</tr>
<tr>
<td>Brand identity</td>
<td>—</td>
<td>Logo, color, typography, tone</td>
</tr>
<tr>
<td>Content and pages</td>
<td>—</td>
<td>Market-specific copy, local pages</td>
</tr>
<tr>
<td>Publishing workflows</td>
<td>—</td>
<td>Permissions and approvals per team</td>
</tr>
<tr>
<td>Domain structure</td>
<td>—</td>
<td>Each brand keeps its own domain</td>
</tr>
</tbody></table>
<p>The result is that teams operating within a brand still feel full ownership of their website. They manage their content, control their publishing workflow, and see only their brand in the admin interface. What they do not see — and do not need to think about — is the shared infrastructure running underneath.</p>
<hr>
<h2>When This Architecture Makes Business Sense</h2>
<p>Multi-tenant website architecture is a strong fit in specific situations. It is worth evaluating seriously if your organization matches any of these.</p>
<p><strong>Multiple subsidiaries under one parent company.</strong> You have distinct brand identities that need to remain independent, but they share enough structural similarity that rebuilding the same foundation five times does not make sense.</p>
<p><strong>Regional or market-specific websites with local content needs.</strong> The core website structure is the same across markets, but content, language, and local pages need to be managed separately by regional teams.</p>
<p><strong>Growth through acquisition.</strong> You have absorbed brands with their own websites and now manage a fragmented mix of platforms. Consolidation onto a shared foundation reduces ongoing maintenance without requiring each brand to sacrifice its identity.</p>
<p><strong>New market expansion.</strong> You are planning to launch into new geographies and want to do so without building a new system each time. A shared foundation makes each launch faster and cheaper.</p>
<p><strong>Inconsistency across brands.</strong> Brand standards are drifting across websites and there is no clean mechanism to enforce them centrally. A shared component and template layer gives central teams more control without removing local autonomy.</p>
<hr>
<h2>When It Does Not Make Sense</h2>
<p>Not every multi-website situation benefits from consolidation. It is worth being clear about this.</p>
<p>If your brands have fundamentally different structures — one is a product catalogue, another is a service brochure, another is a community platform — the technical divergence may outweigh the benefit of a shared foundation.</p>
<p>If your organization has only one or two websites and no near-term expansion plans, the overhead of setting up a multi-tenant architecture adds complexity without a clear return.</p>
<p>If your brands need to remain completely separate for regulatory or competitive reasons, shared infrastructure may not be appropriate even with logical data isolation.</p>
<p>The honest answer is that this architecture is a strong fit when there is real structural overlap across brands and a genuine operational cost to maintaining them separately. Where that overlap is thin, simpler solutions usually serve better.</p>
<hr>
<h2>Why Payload CMS Is a Strong Fit for This Model</h2>
<p>Payload CMS handles multi-tenancy natively through its official multi-tenant plugin. This matters practically because it means the architecture does not require stitching together multiple systems or maintaining custom isolation logic across the codebase.</p>
<p>Each brand or market operates as a separate tenant within one Payload instance. Content, users, and permissions are scoped per tenant automatically. A regional editor logs in and sees only their brand&#39;s content. A super admin can access and manage all brands from one place.</p>
<p>The plugin is maintained officially and kept current with Payload&#39;s release cycle, which means the isolation logic does not drift as the system is updated. For organizations managing multiple brands over a multi-year horizon, that kind of maintained stability matters more than it might seem at first.</p>
<p>Because Payload runs natively inside a Next.js application, the frontend and the CMS share the same codebase, the same TypeScript types, and the same deployment. That tight coupling reduces the architectural surface area that needs to be maintained — fewer moving parts, fewer vendor dependencies, fewer integration points that can break.</p>
<hr>
<h2>Questions to Ask Before Consolidating Your Website Stack</h2>
<p>If you are evaluating whether multi-tenant CMS architecture is the right move for your organization, these are the questions worth working through first.</p>
<p>How many websites are we managing today, and how many are we likely to manage in three years? The answer changes the calculus significantly.</p>
<p>How much duplication exists across our current websites? Are we rebuilding the same structures, the same templates, the same content types on each one?</p>
<p>Where are updates getting stuck? Are changes delayed because they need to be applied separately across systems? Is that causing visible inconsistency?</p>
<p>What should be standardized across brands, and what must remain brand-specific? The clearer this line is, the more straightforward the architecture becomes.</p>
<p>Is our current vendor and platform mix sustainable at scale? If adding one more brand means adding one more system, that cost compounds quickly.</p>
<p>Are we scaling a system or just duplicating one? This is the question that usually clarifies the decision.</p>
<hr>
<h2>Frequently Asked Questions</h2>
<p><strong>Does multi-tenancy mean all our brands will look the same?</strong></p>
<p>No. Visual identity, content, domain, and publishing workflows all remain separate per brand. What is shared is the infrastructure underneath — the CMS, the hosting setup, the component foundation, and the maintenance cycle. Brands still look, feel, and operate independently for both visitors and editors.</p>
<p><strong>How does user access work? Will editors from one brand see content from another?</strong></p>
<p>In a properly configured multi-tenant setup, editors are scoped to their brand. They log into the same admin interface but see only the content, media, and settings belonging to their tenant. Super admins can access everything. Brand-level editors cannot.</p>
<p><strong>We have brands running on different CMS platforms. Is migration a realistic option?</strong></p>
<p>It depends on the scale and structure of the content. Migrations are common and manageable, but they require realistic planning. For most organizations, the right approach is a phased consolidation — starting with the brand that has the cleanest structure — rather than migrating everything at once.</p>
<p><strong>Can we keep brand-specific domains?</strong></p>
<p>Yes. Each brand retains its own domain. The multi-tenant architecture routes each domain to the correct brand content within the shared system. From a visitor&#39;s perspective, nothing changes.</p>
<p><strong>How much faster is launching a new market with this architecture in place?</strong></p>
<p>The time saving is significant but depends on content complexity. For structurally similar markets, the difference between launching a new brand on a shared foundation versus standing up an entirely new system can be measured in weeks versus months. The shared foundation handles the setup; the brand team focuses on content.</p>
<hr>
<h2>Closing</h2>
<p>For multi-brand companies, the issue is rarely the number of websites. It is the number of disconnected systems behind them.</p>
<p>Fragmentation is the real cost — in time, in consistency, in governance, in the speed at which the business can move. Multi-tenant architecture does not eliminate brand independence. It gives organizations one shared foundation from which multiple brands can operate cleanly, without duplicating the infrastructure every time the portfolio grows.</p>
<p>If you are managing multiple brand or subsidiary websites and want to understand whether this model applies to your situation, the starting point is a clear picture of where your current stack is creating friction. That is the conversation worth having before any architecture decision is made.</p>
<p>If you found this useful or have questions about your specific situation, let me know in the comments. And if you are exploring this for your organization, the <a href="/contact">discovery call</a> is the right place to start.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/multi-tenant-cms-reduce-website-fragmentation</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/multi-tenant-cms-reduce-website-fragmentation</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Mon, 06 Apr 2026 09:41:53 GMT</pubDate>
            <content:encoded>&lt;p&gt;Multi-brand and multi-subsidiary companies often reach a point where managing their websites costs more than it should — not because they have too many websites, but because those websites run on too many disconnected systems. A multi-tenant CMS architecture solves this by letting multiple websites share one technical foundation while keeping brand identity and content fully independent where it needs to be. This article explains what that means in practical terms, when it makes sense, and what questions to ask before deciding whether consolidation is right for your organization.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I recently worked with a company operating across several markets — multiple brands, each with its own website, its own CMS, its own hosting setup, and its own publishing workflow. One market ran on WordPress. Another on a different platform. A third on something nobody quite remembered choosing. On paper, the setup looked reasonable. In practice, every website update had to be coordinated separately, brand consistency drifted over time, and adding a new market meant standing up an entirely new system from scratch. The question they came to me with was not really a technical question. It was an operational one: how do we stop managing five versions of the same problem?&lt;/p&gt;
&lt;p&gt;That situation is more common than it sounds. And the answer, more often than not, involves rethinking the underlying architecture rather than rebuilding each website one by one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Multi-Site Management Should Not Require Multiple Systems&lt;/h2&gt;
&lt;p&gt;Every growing company eventually manages more than one web presence. That is normal. What creates problems is not the number of websites — it is what happens to the systems behind them.&lt;/p&gt;
&lt;p&gt;When websites are built separately, they tend to accumulate separately too. Different CMS platforms, different hosting providers, different design systems, different vendor contracts, different technical setups. Over time, the stack multiplies because each new website or market makes sense as a standalone decision at the moment it is launched.&lt;/p&gt;
&lt;p&gt;The fragmentation is not always visible from the outside. Brand websites can look well-maintained while the infrastructure behind them is a patchwork of disconnected systems. But internally, the cost of that fragmentation is constant.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What Fragmentation Looks Like Inside a Growing Organization&lt;/h2&gt;
&lt;p&gt;If your organization manages websites across multiple brands, subsidiaries, or geographic markets, some of these will sound familiar.&lt;/p&gt;
&lt;p&gt;Every update gets repeated. A global navigation change, a new privacy policy, a design adjustment — it has to be applied separately on each website, by different people, on different platforms, sometimes in different languages. Teams spend time on repetition instead of meaningful work.&lt;/p&gt;
&lt;p&gt;Updates fall out of sync. Because changes are applied manually across systems, they drift. One website reflects the new brand guidelines. Another still shows the old ones. A third has an intermediate version that nobody intended to ship. The gap between what the business decided and what visitors actually see grows quietly over time.&lt;/p&gt;
&lt;p&gt;Governance becomes unclear. When each website has its own system and its own team managing it, accountability becomes fragmented too. Who owns the standard? Who approves content before it goes live? Who notices when something is wrong? These questions are hard to answer cleanly when the infrastructure is different for every brand.&lt;/p&gt;
&lt;p&gt;Technical debt compounds. Each system has its own update cycle, its own security patches, its own hosting contract. Maintaining five platforms means maintaining five separate cost and risk profiles. When something breaks, it is harder to diagnose and fix because there is no shared foundation.&lt;/p&gt;
&lt;p&gt;New market launches are slow. Standing up a new brand website from scratch — finding a vendor, configuring a new CMS, migrating content, setting up workflows — takes months. With a shared foundation, that same process can take weeks.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Better Model: One Foundation, Controlled Independence&lt;/h2&gt;
&lt;p&gt;A multi-tenant CMS architecture changes the operating model. Instead of running separate systems for each brand or market, you run one system that can power multiple websites simultaneously. Each website still has its own identity, its own content, and its own team managing it. But the underlying infrastructure is shared.&lt;/p&gt;
&lt;p&gt;The key distinction decision makers often miss is this: multi-tenancy is not about forcing all brands into one giant generic website. It is about sharing the infrastructure while keeping the surface layer distinct.&lt;/p&gt;
&lt;p&gt;Think of it the way a well-run group operates at the corporate level. Shared finance, legal, and HR systems. Separate brand strategies. The structure is shared. The identity is independent.&lt;/p&gt;
&lt;p&gt;That is the same principle applied to web infrastructure.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What Stays Shared, What Stays Separate&lt;/h2&gt;
&lt;p&gt;The practical question is always: what actually gets shared, and what stays brand-specific?&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Shared across brands&lt;/th&gt;
&lt;th&gt;Stays brand-specific&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;CMS and admin interface&lt;/td&gt;
&lt;td&gt;Single login, one system&lt;/td&gt;
&lt;td&gt;Content, pages, media per brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User management&lt;/td&gt;
&lt;td&gt;Central permissions model&lt;/td&gt;
&lt;td&gt;Team access scoped per brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting and infrastructure&lt;/td&gt;
&lt;td&gt;One deployment, one maintenance cycle&lt;/td&gt;
&lt;td&gt;Subdomain or domain per brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer foundation&lt;/td&gt;
&lt;td&gt;One codebase to maintain&lt;/td&gt;
&lt;td&gt;Templates and design tokens per brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security and compliance&lt;/td&gt;
&lt;td&gt;One update cycle, one patch process&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand identity&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Logo, color, typography, tone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content and pages&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Market-specific copy, local pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing workflows&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Permissions and approvals per team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain structure&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Each brand keeps its own domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The result is that teams operating within a brand still feel full ownership of their website. They manage their content, control their publishing workflow, and see only their brand in the admin interface. What they do not see — and do not need to think about — is the shared infrastructure running underneath.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;When This Architecture Makes Business Sense&lt;/h2&gt;
&lt;p&gt;Multi-tenant website architecture is a strong fit in specific situations. It is worth evaluating seriously if your organization matches any of these.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Multiple subsidiaries under one parent company.&lt;/strong&gt; You have distinct brand identities that need to remain independent, but they share enough structural similarity that rebuilding the same foundation five times does not make sense.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Regional or market-specific websites with local content needs.&lt;/strong&gt; The core website structure is the same across markets, but content, language, and local pages need to be managed separately by regional teams.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Growth through acquisition.&lt;/strong&gt; You have absorbed brands with their own websites and now manage a fragmented mix of platforms. Consolidation onto a shared foundation reduces ongoing maintenance without requiring each brand to sacrifice its identity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;New market expansion.&lt;/strong&gt; You are planning to launch into new geographies and want to do so without building a new system each time. A shared foundation makes each launch faster and cheaper.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Inconsistency across brands.&lt;/strong&gt; Brand standards are drifting across websites and there is no clean mechanism to enforce them centrally. A shared component and template layer gives central teams more control without removing local autonomy.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;When It Does Not Make Sense&lt;/h2&gt;
&lt;p&gt;Not every multi-website situation benefits from consolidation. It is worth being clear about this.&lt;/p&gt;
&lt;p&gt;If your brands have fundamentally different structures — one is a product catalogue, another is a service brochure, another is a community platform — the technical divergence may outweigh the benefit of a shared foundation.&lt;/p&gt;
&lt;p&gt;If your organization has only one or two websites and no near-term expansion plans, the overhead of setting up a multi-tenant architecture adds complexity without a clear return.&lt;/p&gt;
&lt;p&gt;If your brands need to remain completely separate for regulatory or competitive reasons, shared infrastructure may not be appropriate even with logical data isolation.&lt;/p&gt;
&lt;p&gt;The honest answer is that this architecture is a strong fit when there is real structural overlap across brands and a genuine operational cost to maintaining them separately. Where that overlap is thin, simpler solutions usually serve better.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Why Payload CMS Is a Strong Fit for This Model&lt;/h2&gt;
&lt;p&gt;Payload CMS handles multi-tenancy natively through its official multi-tenant plugin. This matters practically because it means the architecture does not require stitching together multiple systems or maintaining custom isolation logic across the codebase.&lt;/p&gt;
&lt;p&gt;Each brand or market operates as a separate tenant within one Payload instance. Content, users, and permissions are scoped per tenant automatically. A regional editor logs in and sees only their brand&amp;#39;s content. A super admin can access and manage all brands from one place.&lt;/p&gt;
&lt;p&gt;The plugin is maintained officially and kept current with Payload&amp;#39;s release cycle, which means the isolation logic does not drift as the system is updated. For organizations managing multiple brands over a multi-year horizon, that kind of maintained stability matters more than it might seem at first.&lt;/p&gt;
&lt;p&gt;Because Payload runs natively inside a Next.js application, the frontend and the CMS share the same codebase, the same TypeScript types, and the same deployment. That tight coupling reduces the architectural surface area that needs to be maintained — fewer moving parts, fewer vendor dependencies, fewer integration points that can break.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Questions to Ask Before Consolidating Your Website Stack&lt;/h2&gt;
&lt;p&gt;If you are evaluating whether multi-tenant CMS architecture is the right move for your organization, these are the questions worth working through first.&lt;/p&gt;
&lt;p&gt;How many websites are we managing today, and how many are we likely to manage in three years? The answer changes the calculus significantly.&lt;/p&gt;
&lt;p&gt;How much duplication exists across our current websites? Are we rebuilding the same structures, the same templates, the same content types on each one?&lt;/p&gt;
&lt;p&gt;Where are updates getting stuck? Are changes delayed because they need to be applied separately across systems? Is that causing visible inconsistency?&lt;/p&gt;
&lt;p&gt;What should be standardized across brands, and what must remain brand-specific? The clearer this line is, the more straightforward the architecture becomes.&lt;/p&gt;
&lt;p&gt;Is our current vendor and platform mix sustainable at scale? If adding one more brand means adding one more system, that cost compounds quickly.&lt;/p&gt;
&lt;p&gt;Are we scaling a system or just duplicating one? This is the question that usually clarifies the decision.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Frequently Asked Questions&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does multi-tenancy mean all our brands will look the same?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No. Visual identity, content, domain, and publishing workflows all remain separate per brand. What is shared is the infrastructure underneath — the CMS, the hosting setup, the component foundation, and the maintenance cycle. Brands still look, feel, and operate independently for both visitors and editors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How does user access work? Will editors from one brand see content from another?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In a properly configured multi-tenant setup, editors are scoped to their brand. They log into the same admin interface but see only the content, media, and settings belonging to their tenant. Super admins can access everything. Brand-level editors cannot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;We have brands running on different CMS platforms. Is migration a realistic option?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It depends on the scale and structure of the content. Migrations are common and manageable, but they require realistic planning. For most organizations, the right approach is a phased consolidation — starting with the brand that has the cleanest structure — rather than migrating everything at once.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can we keep brand-specific domains?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes. Each brand retains its own domain. The multi-tenant architecture routes each domain to the correct brand content within the shared system. From a visitor&amp;#39;s perspective, nothing changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much faster is launching a new market with this architecture in place?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The time saving is significant but depends on content complexity. For structurally similar markets, the difference between launching a new brand on a shared foundation versus standing up an entirely new system can be measured in weeks versus months. The shared foundation handles the setup; the brand team focuses on content.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;For multi-brand companies, the issue is rarely the number of websites. It is the number of disconnected systems behind them.&lt;/p&gt;
&lt;p&gt;Fragmentation is the real cost — in time, in consistency, in governance, in the speed at which the business can move. Multi-tenant architecture does not eliminate brand independence. It gives organizations one shared foundation from which multiple brands can operate cleanly, without duplicating the infrastructure every time the portfolio grows.&lt;/p&gt;
&lt;p&gt;If you are managing multiple brand or subsidiary websites and want to understand whether this model applies to your situation, the starting point is a clear picture of where your current stack is creating friction. That is the conversation worth having before any architecture decision is made.&lt;/p&gt;
&lt;p&gt;If you found this useful or have questions about your specific situation, let me know in the comments. And if you are exploring this for your organization, the &lt;a href=&quot;/contact&quot;&gt;discovery call&lt;/a&gt; is the right place to start.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/multi-tenant-cms-reduce-website-fragmentation"/>
        </item>
        <item>
            <title><![CDATA[CMS Migration Checklist: Complete 5-Phase Guide 2026]]></title>
            <description><![CDATA[<h1>CMS Migration Checklist: The Complete 5-Phase Guide (2026)</h1>
<p>A CMS migration has five phases: content audit, content model redesign, data transformation, SEO and URL preservation, and cutover. Most migrations fail in phase two — not phase five. This guide walks through every phase in the order you actually need to execute them, with the specific decisions that determine whether you end up with a cleaner system or just the same mess in a new container. If your team is handling the migration in-house, this is the checklist. If you&#39;re evaluating whether to hand it off, the <a href="/payload-cms-migration">Payload CMS migration service</a> page covers what that engagement looks like.</p>
<hr>
<p>I&#39;ve run enough CMS migrations to know exactly where they break. The pattern is consistent: a company treats migration as a data transfer problem, copies their existing content structure into the new system, and ships a site that inherits every piece of technical debt from the old one. Six months later they&#39;re asking why the new CMS feels exactly as painful as the old one.</p>
<p>The real work of a migration isn&#39;t moving data. It&#39;s making the architecture decisions that the original CMS setup never got right. That happens in phase two, before a single ETL script runs. If you skip it or rush it, the rest of the checklist doesn&#39;t matter.</p>
<hr>
<h2>Before You Start: Is Migration the Right Move?</h2>
<p>Migration is a meaningful investment of both capital and engineering time. It&#39;s worth doing when your current system has become a genuine bottleneck — not because someone on the team read about a new CMS and got excited.</p>
<p><strong>Signals that migration is the right move:</strong></p>
<p>Plugin debt that eats engineering capacity. If your team spends more time patching security vulnerabilities and fighting plugin conflicts than building features, the hidden cost of staying is higher than the migration cost.</p>
<p>SaaS API billing that scales faster than your traffic. Contentful and similar platforms charge per API record, per space, per environment. When those bills start forcing architectural compromises, you&#39;ve crossed the threshold.</p>
<p>AI integration that your current CMS can&#39;t support. If you&#39;re trying to build custom AI agents, RAG pipelines, or semantic search and the CMS&#39;s closed ecosystem makes every integration fragile, you&#39;re fighting the wrong battle.</p>
<p>Multi-tenant or multi-market requirements. Managing multiple brands on a system that charges per site or per space doesn&#39;t scale. A unified backend with proper access control changes the economics entirely.</p>
<p><strong>Signals that you should wait:</strong></p>
<p>No internal developers. Code-first CMSes require engineering involvement. If your team doesn&#39;t have that capacity, a migration will fail at the maintenance phase even if the initial build goes well.</p>
<p>A genuinely simple site. Five static pages that change quarterly don&#39;t need a full migration. The ROI isn&#39;t there.</p>
<p>Budget constraints below the minimum threshold. A migration done cheaply enough to cut corners on SEO preservation and data integrity will cost more to fix than a proper migration would have cost.</p>
<hr>
<h2>The Two Migration Strategies: Big-Bang vs. Incremental</h2>
<p>Every other checklist in this space skips this decision. It&#39;s the most important one you&#39;ll make before any technical work begins.</p>
<p><strong>Big-bang migration</strong> means you build the new system in parallel, run a full cutover on a fixed date, and redirect traffic all at once. The old system goes dark. The new system goes live. This is simpler to manage and creates a clean break, but it concentrates all the risk into a single go-live moment. If something breaks at cutover, everything is broken.</p>
<p><strong>Incremental migration</strong> means you run both systems simultaneously and route traffic progressively — one section, one content type, or one route at a time. You use nginx, middleware, or a reverse proxy to direct specific URLs to the new system while the rest still serve from the old one. The risk is distributed across multiple smaller cutovers. No single moment of maximum exposure.</p>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Risk profile</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>Big-bang</td>
<td>Concentrated at cutover</td>
<td>Smaller sites, clean content models, tight timelines</td>
</tr>
<tr>
<td>Incremental</td>
<td>Distributed across phases</td>
<td>Live commerce, complex integrations, operational sensitivity, teams with ongoing publishing</td>
</tr>
</tbody></table>
<p>Most companies with active publishing, e-commerce, or customer-facing systems should default to incremental. The parallel running period is longer and the routing logic adds complexity, but losing a week of revenue to a botched cutover costs more than the extra engineering time.</p>
<p>For incremental migrations, the checklist phases below still apply — they just repeat for each section of the site rather than running once for everything.</p>
<hr>
<h2>Phase 1: Content Audit and Inventory</h2>
<p>Before a line of code is written, you need to know exactly what you&#39;re migrating. This sounds obvious. It almost never gets done properly.</p>
<p>The deliverable is a content inventory: every content type, every field, every asset, and every piece of content that has ever been published. Most teams discover during this phase that their CMS holds years of accumulated content that nobody has looked at since it was published. Blog posts from 2014. Campaign landing pages for offers that ended three years ago. Product pages for items that are no longer sold.</p>
<p>The harder task in this phase isn&#39;t cataloging what exists — it&#39;s deciding what shouldn&#39;t migrate at all.</p>
<p>ROT analysis (Redundant, Obsolete, Trivial) is the right framework here. For each content type, you&#39;re asking: is this content still accurate, is it still relevant to the business, and does it have any meaningful traffic or conversion value? Content that fails all three criteria stays behind. The new system should start cleaner than the old one, not inherit its full archive indiscriminately.</p>
<p>A practical approach: export your full URL list, cross-reference it against Google Search Console for trailing 90-day click data, and flag anything with zero traffic and no strategic reason to exist. You&#39;ll typically eliminate 20–40% of the content inventory before migration even begins. Every page you don&#39;t migrate is a page you don&#39;t have to transform, test, redirect, and maintain.</p>
<p><strong>Phase 1 checklist:</strong></p>
<ul>
<li>Full content type inventory documented</li>
<li>Field-by-field catalog for each content type</li>
<li>Asset inventory (images, PDFs, video embeds, file attachments)</li>
<li>Internal link map (pages that reference other pages)</li>
<li>ROT analysis complete — &quot;do not migrate&quot; list finalized</li>
<li>Content that remains tagged by priority (must-have at launch vs. can migrate post-launch)</li>
</ul>
<hr>
<h2>Phase 2: Content Model Redesign</h2>
<p>This is the phase that determines whether the migration was worth doing. It&#39;s also the phase most teams underinvest in.</p>
<p>Your current CMS has a content model — even if nobody consciously designed it. WordPress stores content as post types with custom fields and metadata scattered across a dozen tables. Contentful has content types with reference fields pointing to other content types. Sanity has schemas with Portable Text blocks that have evolved through years of editorial decisions. The common thread is that all of them reflect the decisions (and compromises) made by whoever set up the CMS, often years ago, often without a clear architectural intent.</p>
<p>Migrating that model directly into a new system preserves every mistake.</p>
<p>The right approach is to treat the new system&#39;s content model as a greenfield design that happens to need to accommodate your existing content. Start with what the business actually needs the content to do — not what the current CMS happens to store. Define your collections, your fields, your relationship structure, and your access control model as if you were starting fresh. Then map your existing content to that new structure.</p>
<p>For teams moving to Payload CMS, this means designing typed TypeScript collections that reflect your actual content relationships. A product page isn&#39;t just a post with a custom field — it&#39;s a typed collection with specific fields, specific relationships to other collections (categories, variants, related products), and specific access control rules. The admin interface your editors use every day is generated directly from that TypeScript definition.</p>
<p>The hard work in this phase is handling the cases where old content doesn&#39;t map cleanly to the new model. WordPress often stores rich content as HTML blobs in the post content field — years of Gutenberg and Classic Editor output that needs to be parsed and converted to structured blocks. Contentful&#39;s Rich Text JSON format has a specific structure that needs to be converted to Payload&#39;s Lexical format. Sanity&#39;s Portable Text blocks map reasonably well to Lexical but still require a transformation pass.</p>
<p>Every edge case you identify in this phase — malformed HTML, inconsistent field usage, broken internal links, embedded shortcodes — needs a transformation rule before the scripting phase begins. Discovering them in phase three, mid-import, is expensive.</p>
<p><strong>Phase 2 checklist:</strong></p>
<ul>
<li>New content model designed independently of old CMS structure</li>
<li>Collections and globals defined in the target system</li>
<li>Field types selected for each piece of content (rich text, relationship, array, block, etc.)</li>
<li>Relationship mapping complete (which collections reference which)</li>
<li>Access control model defined</li>
<li>Content type mapping table: old content type → new collection, field by field</li>
<li>Edge cases and exceptions documented with transformation rules</li>
<li>Rich text conversion strategy defined (HTML/portable text/rich text JSON → target format)</li>
</ul>
<p>For platform-specific guidance on the content model mapping step, the <a href="/blog/wordpress-to-payload-migration-guide">WordPress to Payload migration guide</a>, <a href="/blog/contentful-to-payload-cms-migration-guide">Contentful to Payload migration guide</a>, <a href="/blog/sanity-to-payload-5-step-migration-guide">Sanity to Payload migration guide</a>, and <a href="/blog/strapi-to-payload-cms-migration-guide">Strapi to Payload migration guide</a> cover the specifics for each source platform.</p>
<hr>
<h2>Phase 3: Data Transformation and Scripting</h2>
<p>With the content model defined and every edge case documented, the technical extraction and import work can begin.</p>
<p>The approach is ETL: extract data from the source system, transform it to match the new schema, and load it into the target. For most CMS migrations, this means custom Node.js scripts that hit your source system&#39;s API (REST or GraphQL), apply transformation logic for each content type, and use the target system&#39;s API to create records in the correct structure.</p>
<p>For Payload CMS, the Local API is the right tool for the import phase. It bypasses HTTP overhead, runs in the same Node.js process, and gives you full access to hooks and validation — which means your imported content goes through the same data integrity checks as content created through the admin UI.</p>
<p>A few practical notes on running the import reliably:</p>
<p>Start with a subset. Run your first import script against 5–10 records of each content type, verify the output in the admin UI, and fix transformation issues before processing the full dataset. The edge cases you documented in phase two will show up immediately and are far cheaper to fix on 10 records than on 10,000.</p>
<p>Handle relationships in the right order. Collections that other collections reference need to exist before the referencing records are created. Map your dependency graph before you write the import scripts and run collection imports in the correct sequence.</p>
<p>Log everything. Every record that fails transformation, every field that falls back to a default, every relationship that can&#39;t be resolved — all of it needs a log entry. You&#39;ll need this log to verify completeness and to catch data integrity issues before cutover.</p>
<p>Assets are usually the most time-consuming part. Images, PDFs, and other media files need to be downloaded from the source, uploaded to your new storage layer, and their references updated in the transformed content. For large asset libraries this step dominates the total import time.</p>
<p><strong>Phase 3 checklist:</strong></p>
<ul>
<li>Extraction scripts written for each content type</li>
<li>Transformation logic covers all edge cases documented in Phase 2</li>
<li>Import scripts written using target system&#39;s API/local API</li>
<li>Dependency order for collections mapped and respected</li>
<li>Subset test import run and verified</li>
<li>Asset migration script written and tested</li>
<li>Full import run in staging environment</li>
<li>Import logs reviewed — all failures resolved or explicitly accepted</li>
<li>Record count reconciliation: source count vs. target count per content type</li>
</ul>
<hr>
<h2>Phase 4: SEO and URL Preservation</h2>
<p>The biggest risk in any CMS migration is losing search rankings. It&#39;s also the most predictable risk — which means it&#39;s entirely preventable with the right preparation.</p>
<p>The core requirement is 1:1 URL mapping. Every URL that existed in the old system and had any meaningful traffic needs to either exist in the new system at the same path or have a 301 redirect to its new location. A 301 redirect tells search engines that the content has permanently moved, passes the ranking equity of the old URL to the new one, and ensures that any bookmarks or external links don&#39;t result in 404 errors.</p>
<p>The mapping process starts from the content inventory you built in Phase 1. For every URL in the old system that you&#39;re migrating, document the old URL and the new URL it will resolve to. For content you&#39;ve decided not to migrate, decide whether to redirect to the nearest relevant page or let it return 404 — the latter is appropriate for content that was never indexed or has no ranking value.</p>
<p>The implementation depends on your infrastructure. For an incremental migration, you&#39;re adding redirects progressively as each section cuts over — routing specific URL patterns to the new system while others still hit the old one. For a big-bang migration, you&#39;re implementing the full redirect map at cutover. In both cases, the redirect map needs to be tested in staging before it touches production.</p>
<p>Beyond redirects, metadata preservation matters. Page titles, meta descriptions, canonical tags, and schema.org structured data should all be preserved or improved during migration. Most migrations that cause an SEO dip do so because content was migrated without its metadata — the content arrived, but the signals that told Google what it was didn&#39;t come with it.</p>
<p>For incremental migrations, there&#39;s an additional concern: duplicate content during the parallel running period. While both systems are live, you need to ensure that content served from the old system has canonical tags pointing to the final URL in the new system, or that the old system&#39;s pages are noindexed during the transition. Running two versions of the same content without a canonical signal is the fastest way to lose ranking equity you&#39;ve spent years building.</p>
<p><strong>Phase 4 checklist:</strong></p>
<ul>
<li>Full 301 redirect map built from content inventory</li>
<li>Old URLs with no migration path — decision documented (redirect or 404)</li>
<li>Redirect map tested in staging (no redirect chains, no loops)</li>
<li>Page titles and meta descriptions confirmed present in new system</li>
<li>Canonical tags configured correctly</li>
<li>Schema.org structured data preserved or improved</li>
<li>XML sitemap updated for new URL structure</li>
<li>For incremental migrations: canonical/noindex strategy for parallel period defined</li>
</ul>
<hr>
<h2>Phase 5: Staging, Cutover, and Stabilization</h2>
<p>Everything in phases one through four has been preparation. Phase five is execution — and the goal is to make it boring.</p>
<p>The parallel running period is the safety net. Before any production traffic changes, the new system runs in staging with a full copy of the migrated content, accessible to your team for verification. This is not user acceptance testing of the UI — it&#39;s data integrity verification. You&#39;re checking that every record migrated correctly, that every relationship resolved correctly, that every redirect works, and that every piece of metadata arrived intact.</p>
<p>The content freeze is the mechanism that makes cutover clean. At a fixed point before the final import — typically 48–72 hours before DNS change — the old system goes into read-only mode. No new content is published, no existing content is edited. This ensures that the final delta import (the pass that catches any content created after the initial bulk import) doesn&#39;t miss anything.</p>
<p>For incremental migrations, &quot;cutover&quot; is a series of smaller events rather than a single moment. Each section of the site cuts over independently — you update the routing configuration to send a specific URL pattern to the new system, verify it&#39;s working correctly, and move on to the next section. The content freeze applies per-section rather than site-wide.</p>
<p>The go-live sequence for a big-bang cutover:</p>
<ol>
<li>Final delta import from source system</li>
<li>Verify record counts and spot-check content in staging</li>
<li>DNS change (TTL should have been reduced to 5 minutes 48 hours prior)</li>
<li>Verify redirects are firing correctly on production</li>
<li>Check Google Search Console for crawl errors within 24 hours</li>
<li>Monitor Core Web Vitals and ranking positions for 2–4 weeks post-launch</li>
</ol>
<p>The stabilization period is 2–4 weeks of active monitoring. Most ranking fluctuations after a well-executed migration are temporary — Google re-crawls, reassesses, and typically restores positions within a few weeks. What you&#39;re watching for is anything that shouldn&#39;t fluctuate: a sudden drop in a previously stable high-traffic page, crawl errors that indicate broken redirects, or 404s that indicate URLs that weren&#39;t in your redirect map.</p>
<p><strong>Phase 5 checklist:</strong></p>
<ul>
<li>Staging environment fully populated with migrated content</li>
<li>Data integrity verification complete (record counts, spot checks, relationship checks)</li>
<li>All team members have verified their content sections in staging</li>
<li>DNS TTL reduced 48 hours before cutover</li>
<li>Content freeze date communicated to editorial team</li>
<li>Final delta import scripted and tested</li>
<li>Go-live sequence documented and rehearsed</li>
<li>Monitoring setup: GSC crawl errors, Core Web Vitals, ranking tracking</li>
<li>Rollback plan documented (how to revert DNS if critical issues emerge within first hour)</li>
</ul>
<hr>
<h2>The Full CMS Migration Checklist</h2>
<p>For reference, the complete checklist across all five phases:</p>
<p><strong>Phase 1 — Content Audit</strong></p>
<ul>
<li>Content type inventory documented</li>
<li>Field-by-field catalog complete</li>
<li>Asset inventory complete</li>
<li>Internal link map built</li>
<li>ROT analysis complete — do-not-migrate list finalized</li>
<li>Remaining content tagged by launch priority</li>
</ul>
<p><strong>Phase 2 — Content Model Redesign</strong></p>
<ul>
<li>New content model designed for target system</li>
<li>Collections and globals defined</li>
<li>Field types selected</li>
<li>Relationship mapping complete</li>
<li>Access control model defined</li>
<li>Content type mapping table built (old → new, field by field)</li>
<li>Edge cases documented with transformation rules</li>
<li>Rich text conversion strategy defined</li>
</ul>
<p><strong>Phase 3 — Data Transformation</strong></p>
<ul>
<li>Extraction scripts written per content type</li>
<li>Transformation logic covers all documented edge cases</li>
<li>Import scripts using target API/local API</li>
<li>Collection dependency order respected</li>
<li>Subset test import run and verified</li>
<li>Asset migration script tested</li>
<li>Full staging import complete</li>
<li>Import logs reviewed</li>
<li>Record count reconciliation complete</li>
</ul>
<p><strong>Phase 4 — SEO Preservation</strong></p>
<ul>
<li>301 redirect map built</li>
<li>Unmigrated URLs — redirect or 404 decision documented</li>
<li>Redirect map tested in staging</li>
<li>Metadata confirmed in new system</li>
<li>Canonical tags configured</li>
<li>Structured data preserved</li>
<li>XML sitemap updated</li>
<li>Parallel period canonical/noindex strategy (incremental only)</li>
</ul>
<p><strong>Phase 5 — Cutover</strong></p>
<ul>
<li>Staging data integrity verification complete</li>
<li>DNS TTL reduced 48h prior</li>
<li>Content freeze communicated</li>
<li>Delta import scripted and tested</li>
<li>Go-live sequence documented</li>
<li>Monitoring configured</li>
<li>Rollback plan documented</li>
</ul>
<hr>
<h2>FAQ</h2>
<p><strong>How long does a CMS migration take?</strong></p>
<p>For a mid-sized site (500–5,000 pages, standard content model), plan for 8–12 weeks end-to-end. The content audit and content model redesign in phases one and two typically take longer than teams expect — two to three weeks for a thorough job. The scripting and import phase depends heavily on how clean the source data is. Enterprise migrations with custom integrations, multi-tenant architecture, or high-volume datasets run 12–20 weeks. Timelines compress when phase two is rushed and expand when edge cases surface mid-import.</p>
<p><strong>Will we lose SEO rankings when we migrate?</strong></p>
<p>Not if phases four and five are executed correctly. A well-implemented 301 redirect map passes ranking equity to new URLs. Improving page speed and structured data during the migration often produces an SEO lift in the weeks after launch. The risk is concentrated in two areas: missing URLs in your redirect map (which produce 404s) and metadata that didn&#39;t migrate with the content (which weakens the ranking signals for individual pages). Both are preventable with the verification steps in the checklist.</p>
<p><strong>Can we migrate without rebuilding the frontend?</strong></p>
<p>Yes. If you already have a modern React or Next.js frontend consuming your old CMS via API, you can swap the data source from the old CMS to Payload without touching the frontend. The work is purely on the backend: new Payload installation, content model design, data import, and API endpoint mapping. Most teams choose to rebuild the frontend simultaneously to take advantage of Payload&#39;s Next.js integration, but it&#39;s not a requirement.</p>
<p><strong>What&#39;s the hardest part of migrating from WordPress?</strong></p>
<p>WordPress stores rich content as HTML in the post content field — years of Gutenberg blocks, Classic Editor output, shortcodes, and inline styles that need to be parsed and converted to structured blocks. The transformation scripts for this step are the most complex part of a WordPress migration. Custom post types with ACF fields are usually straightforward; the post content blob is where the edge cases multiply. See the <a href="/blog/wordpress-to-payload-migration-guide">WordPress to Payload migration guide</a> for the specifics.</p>
<p><strong>When does incremental migration make more sense than big-bang?</strong></p>
<p>When any of the following apply: the site has active e-commerce or transactional flows that can&#39;t have downtime, the editorial team publishes daily and can&#39;t freeze content for a week, the existing system has integrations with CRMs or marketing tools that need to stay live during transition, or the team has limited QA capacity and needs to verify each section before moving to the next. The routing complexity of incremental migration is a known cost. The risk of a botched big-bang cutover on a live business is an unknown cost with a potentially very high ceiling.</p>
<hr>
<h2>When to Hand This Off</h2>
<p>The content audit and SEO phases are largely systematic — given the right tools and enough time, a capable team can work through them. The content model redesign in phase two is where specialist knowledge makes the biggest difference. Getting the collection structure, relationship model, and access control right the first time prevents expensive re-architecture later. Getting it wrong means running the migration a second time or living with a system that accrues new technical debt from day one.</p>
<p>If your team is hitting uncertainty in phase two — how to model this content type, how to handle this relationship, how to structure access control for this workflow — that&#39;s the engagement point. The <a href="https://meet.buildwithmatija.com/discovery-call">$500 System Mapping Session</a> is a 60-minute architecture session that works through exactly these decisions. It applies toward any larger engagement if the project moves forward.</p>
<p>For teams that want the full migration handled end-to-end, the <a href="/payload-cms-migration">Payload CMS migration service</a> covers principal-led migrations from WordPress, Contentful, Sanity, Strapi, AEM, Wix, and Drupal — architecture-first, no handoffs.</p>
<hr>
<h2>Conclusion</h2>
<p>A CMS migration done well leaves you with a cleaner system, better-structured content, and an architecture that can actually support where the business is going. The checklist isn&#39;t long, but phase two — content model redesign — is the one that requires the most care and produces the most leverage. Every decision you get right there compounds positively through phases three, four, and five.</p>
<p>The platforms you can migrate to are ultimately secondary to the quality of the architecture decisions you make before the first import script runs.</p>
<p>Let me know in the comments if you have questions about any of the phases, and subscribe for more practical development guides.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/cms-migration-checklist-5-phase-guide-2026</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/cms-migration-checklist-5-phase-guide-2026</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Mon, 06 Apr 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;h1&gt;CMS Migration Checklist: The Complete 5-Phase Guide (2026)&lt;/h1&gt;
&lt;p&gt;A CMS migration has five phases: content audit, content model redesign, data transformation, SEO and URL preservation, and cutover. Most migrations fail in phase two — not phase five. This guide walks through every phase in the order you actually need to execute them, with the specific decisions that determine whether you end up with a cleaner system or just the same mess in a new container. If your team is handling the migration in-house, this is the checklist. If you&amp;#39;re evaluating whether to hand it off, the &lt;a href=&quot;/payload-cms-migration&quot;&gt;Payload CMS migration service&lt;/a&gt; page covers what that engagement looks like.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I&amp;#39;ve run enough CMS migrations to know exactly where they break. The pattern is consistent: a company treats migration as a data transfer problem, copies their existing content structure into the new system, and ships a site that inherits every piece of technical debt from the old one. Six months later they&amp;#39;re asking why the new CMS feels exactly as painful as the old one.&lt;/p&gt;
&lt;p&gt;The real work of a migration isn&amp;#39;t moving data. It&amp;#39;s making the architecture decisions that the original CMS setup never got right. That happens in phase two, before a single ETL script runs. If you skip it or rush it, the rest of the checklist doesn&amp;#39;t matter.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Before You Start: Is Migration the Right Move?&lt;/h2&gt;
&lt;p&gt;Migration is a meaningful investment of both capital and engineering time. It&amp;#39;s worth doing when your current system has become a genuine bottleneck — not because someone on the team read about a new CMS and got excited.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Signals that migration is the right move:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Plugin debt that eats engineering capacity. If your team spends more time patching security vulnerabilities and fighting plugin conflicts than building features, the hidden cost of staying is higher than the migration cost.&lt;/p&gt;
&lt;p&gt;SaaS API billing that scales faster than your traffic. Contentful and similar platforms charge per API record, per space, per environment. When those bills start forcing architectural compromises, you&amp;#39;ve crossed the threshold.&lt;/p&gt;
&lt;p&gt;AI integration that your current CMS can&amp;#39;t support. If you&amp;#39;re trying to build custom AI agents, RAG pipelines, or semantic search and the CMS&amp;#39;s closed ecosystem makes every integration fragile, you&amp;#39;re fighting the wrong battle.&lt;/p&gt;
&lt;p&gt;Multi-tenant or multi-market requirements. Managing multiple brands on a system that charges per site or per space doesn&amp;#39;t scale. A unified backend with proper access control changes the economics entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Signals that you should wait:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No internal developers. Code-first CMSes require engineering involvement. If your team doesn&amp;#39;t have that capacity, a migration will fail at the maintenance phase even if the initial build goes well.&lt;/p&gt;
&lt;p&gt;A genuinely simple site. Five static pages that change quarterly don&amp;#39;t need a full migration. The ROI isn&amp;#39;t there.&lt;/p&gt;
&lt;p&gt;Budget constraints below the minimum threshold. A migration done cheaply enough to cut corners on SEO preservation and data integrity will cost more to fix than a proper migration would have cost.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Two Migration Strategies: Big-Bang vs. Incremental&lt;/h2&gt;
&lt;p&gt;Every other checklist in this space skips this decision. It&amp;#39;s the most important one you&amp;#39;ll make before any technical work begins.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Big-bang migration&lt;/strong&gt; means you build the new system in parallel, run a full cutover on a fixed date, and redirect traffic all at once. The old system goes dark. The new system goes live. This is simpler to manage and creates a clean break, but it concentrates all the risk into a single go-live moment. If something breaks at cutover, everything is broken.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Incremental migration&lt;/strong&gt; means you run both systems simultaneously and route traffic progressively — one section, one content type, or one route at a time. You use nginx, middleware, or a reverse proxy to direct specific URLs to the new system while the rest still serve from the old one. The risk is distributed across multiple smaller cutovers. No single moment of maximum exposure.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Risk profile&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Big-bang&lt;/td&gt;
&lt;td&gt;Concentrated at cutover&lt;/td&gt;
&lt;td&gt;Smaller sites, clean content models, tight timelines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental&lt;/td&gt;
&lt;td&gt;Distributed across phases&lt;/td&gt;
&lt;td&gt;Live commerce, complex integrations, operational sensitivity, teams with ongoing publishing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Most companies with active publishing, e-commerce, or customer-facing systems should default to incremental. The parallel running period is longer and the routing logic adds complexity, but losing a week of revenue to a botched cutover costs more than the extra engineering time.&lt;/p&gt;
&lt;p&gt;For incremental migrations, the checklist phases below still apply — they just repeat for each section of the site rather than running once for everything.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Phase 1: Content Audit and Inventory&lt;/h2&gt;
&lt;p&gt;Before a line of code is written, you need to know exactly what you&amp;#39;re migrating. This sounds obvious. It almost never gets done properly.&lt;/p&gt;
&lt;p&gt;The deliverable is a content inventory: every content type, every field, every asset, and every piece of content that has ever been published. Most teams discover during this phase that their CMS holds years of accumulated content that nobody has looked at since it was published. Blog posts from 2014. Campaign landing pages for offers that ended three years ago. Product pages for items that are no longer sold.&lt;/p&gt;
&lt;p&gt;The harder task in this phase isn&amp;#39;t cataloging what exists — it&amp;#39;s deciding what shouldn&amp;#39;t migrate at all.&lt;/p&gt;
&lt;p&gt;ROT analysis (Redundant, Obsolete, Trivial) is the right framework here. For each content type, you&amp;#39;re asking: is this content still accurate, is it still relevant to the business, and does it have any meaningful traffic or conversion value? Content that fails all three criteria stays behind. The new system should start cleaner than the old one, not inherit its full archive indiscriminately.&lt;/p&gt;
&lt;p&gt;A practical approach: export your full URL list, cross-reference it against Google Search Console for trailing 90-day click data, and flag anything with zero traffic and no strategic reason to exist. You&amp;#39;ll typically eliminate 20–40% of the content inventory before migration even begins. Every page you don&amp;#39;t migrate is a page you don&amp;#39;t have to transform, test, redirect, and maintain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 checklist:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Full content type inventory documented&lt;/li&gt;
&lt;li&gt;Field-by-field catalog for each content type&lt;/li&gt;
&lt;li&gt;Asset inventory (images, PDFs, video embeds, file attachments)&lt;/li&gt;
&lt;li&gt;Internal link map (pages that reference other pages)&lt;/li&gt;
&lt;li&gt;ROT analysis complete — &amp;quot;do not migrate&amp;quot; list finalized&lt;/li&gt;
&lt;li&gt;Content that remains tagged by priority (must-have at launch vs. can migrate post-launch)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Phase 2: Content Model Redesign&lt;/h2&gt;
&lt;p&gt;This is the phase that determines whether the migration was worth doing. It&amp;#39;s also the phase most teams underinvest in.&lt;/p&gt;
&lt;p&gt;Your current CMS has a content model — even if nobody consciously designed it. WordPress stores content as post types with custom fields and metadata scattered across a dozen tables. Contentful has content types with reference fields pointing to other content types. Sanity has schemas with Portable Text blocks that have evolved through years of editorial decisions. The common thread is that all of them reflect the decisions (and compromises) made by whoever set up the CMS, often years ago, often without a clear architectural intent.&lt;/p&gt;
&lt;p&gt;Migrating that model directly into a new system preserves every mistake.&lt;/p&gt;
&lt;p&gt;The right approach is to treat the new system&amp;#39;s content model as a greenfield design that happens to need to accommodate your existing content. Start with what the business actually needs the content to do — not what the current CMS happens to store. Define your collections, your fields, your relationship structure, and your access control model as if you were starting fresh. Then map your existing content to that new structure.&lt;/p&gt;
&lt;p&gt;For teams moving to Payload CMS, this means designing typed TypeScript collections that reflect your actual content relationships. A product page isn&amp;#39;t just a post with a custom field — it&amp;#39;s a typed collection with specific fields, specific relationships to other collections (categories, variants, related products), and specific access control rules. The admin interface your editors use every day is generated directly from that TypeScript definition.&lt;/p&gt;
&lt;p&gt;The hard work in this phase is handling the cases where old content doesn&amp;#39;t map cleanly to the new model. WordPress often stores rich content as HTML blobs in the post content field — years of Gutenberg and Classic Editor output that needs to be parsed and converted to structured blocks. Contentful&amp;#39;s Rich Text JSON format has a specific structure that needs to be converted to Payload&amp;#39;s Lexical format. Sanity&amp;#39;s Portable Text blocks map reasonably well to Lexical but still require a transformation pass.&lt;/p&gt;
&lt;p&gt;Every edge case you identify in this phase — malformed HTML, inconsistent field usage, broken internal links, embedded shortcodes — needs a transformation rule before the scripting phase begins. Discovering them in phase three, mid-import, is expensive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 checklist:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New content model designed independently of old CMS structure&lt;/li&gt;
&lt;li&gt;Collections and globals defined in the target system&lt;/li&gt;
&lt;li&gt;Field types selected for each piece of content (rich text, relationship, array, block, etc.)&lt;/li&gt;
&lt;li&gt;Relationship mapping complete (which collections reference which)&lt;/li&gt;
&lt;li&gt;Access control model defined&lt;/li&gt;
&lt;li&gt;Content type mapping table: old content type → new collection, field by field&lt;/li&gt;
&lt;li&gt;Edge cases and exceptions documented with transformation rules&lt;/li&gt;
&lt;li&gt;Rich text conversion strategy defined (HTML/portable text/rich text JSON → target format)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For platform-specific guidance on the content model mapping step, the &lt;a href=&quot;/blog/wordpress-to-payload-migration-guide&quot;&gt;WordPress to Payload migration guide&lt;/a&gt;, &lt;a href=&quot;/blog/contentful-to-payload-cms-migration-guide&quot;&gt;Contentful to Payload migration guide&lt;/a&gt;, &lt;a href=&quot;/blog/sanity-to-payload-5-step-migration-guide&quot;&gt;Sanity to Payload migration guide&lt;/a&gt;, and &lt;a href=&quot;/blog/strapi-to-payload-cms-migration-guide&quot;&gt;Strapi to Payload migration guide&lt;/a&gt; cover the specifics for each source platform.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Phase 3: Data Transformation and Scripting&lt;/h2&gt;
&lt;p&gt;With the content model defined and every edge case documented, the technical extraction and import work can begin.&lt;/p&gt;
&lt;p&gt;The approach is ETL: extract data from the source system, transform it to match the new schema, and load it into the target. For most CMS migrations, this means custom Node.js scripts that hit your source system&amp;#39;s API (REST or GraphQL), apply transformation logic for each content type, and use the target system&amp;#39;s API to create records in the correct structure.&lt;/p&gt;
&lt;p&gt;For Payload CMS, the Local API is the right tool for the import phase. It bypasses HTTP overhead, runs in the same Node.js process, and gives you full access to hooks and validation — which means your imported content goes through the same data integrity checks as content created through the admin UI.&lt;/p&gt;
&lt;p&gt;A few practical notes on running the import reliably:&lt;/p&gt;
&lt;p&gt;Start with a subset. Run your first import script against 5–10 records of each content type, verify the output in the admin UI, and fix transformation issues before processing the full dataset. The edge cases you documented in phase two will show up immediately and are far cheaper to fix on 10 records than on 10,000.&lt;/p&gt;
&lt;p&gt;Handle relationships in the right order. Collections that other collections reference need to exist before the referencing records are created. Map your dependency graph before you write the import scripts and run collection imports in the correct sequence.&lt;/p&gt;
&lt;p&gt;Log everything. Every record that fails transformation, every field that falls back to a default, every relationship that can&amp;#39;t be resolved — all of it needs a log entry. You&amp;#39;ll need this log to verify completeness and to catch data integrity issues before cutover.&lt;/p&gt;
&lt;p&gt;Assets are usually the most time-consuming part. Images, PDFs, and other media files need to be downloaded from the source, uploaded to your new storage layer, and their references updated in the transformed content. For large asset libraries this step dominates the total import time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 3 checklist:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Extraction scripts written for each content type&lt;/li&gt;
&lt;li&gt;Transformation logic covers all edge cases documented in Phase 2&lt;/li&gt;
&lt;li&gt;Import scripts written using target system&amp;#39;s API/local API&lt;/li&gt;
&lt;li&gt;Dependency order for collections mapped and respected&lt;/li&gt;
&lt;li&gt;Subset test import run and verified&lt;/li&gt;
&lt;li&gt;Asset migration script written and tested&lt;/li&gt;
&lt;li&gt;Full import run in staging environment&lt;/li&gt;
&lt;li&gt;Import logs reviewed — all failures resolved or explicitly accepted&lt;/li&gt;
&lt;li&gt;Record count reconciliation: source count vs. target count per content type&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Phase 4: SEO and URL Preservation&lt;/h2&gt;
&lt;p&gt;The biggest risk in any CMS migration is losing search rankings. It&amp;#39;s also the most predictable risk — which means it&amp;#39;s entirely preventable with the right preparation.&lt;/p&gt;
&lt;p&gt;The core requirement is 1:1 URL mapping. Every URL that existed in the old system and had any meaningful traffic needs to either exist in the new system at the same path or have a 301 redirect to its new location. A 301 redirect tells search engines that the content has permanently moved, passes the ranking equity of the old URL to the new one, and ensures that any bookmarks or external links don&amp;#39;t result in 404 errors.&lt;/p&gt;
&lt;p&gt;The mapping process starts from the content inventory you built in Phase 1. For every URL in the old system that you&amp;#39;re migrating, document the old URL and the new URL it will resolve to. For content you&amp;#39;ve decided not to migrate, decide whether to redirect to the nearest relevant page or let it return 404 — the latter is appropriate for content that was never indexed or has no ranking value.&lt;/p&gt;
&lt;p&gt;The implementation depends on your infrastructure. For an incremental migration, you&amp;#39;re adding redirects progressively as each section cuts over — routing specific URL patterns to the new system while others still hit the old one. For a big-bang migration, you&amp;#39;re implementing the full redirect map at cutover. In both cases, the redirect map needs to be tested in staging before it touches production.&lt;/p&gt;
&lt;p&gt;Beyond redirects, metadata preservation matters. Page titles, meta descriptions, canonical tags, and schema.org structured data should all be preserved or improved during migration. Most migrations that cause an SEO dip do so because content was migrated without its metadata — the content arrived, but the signals that told Google what it was didn&amp;#39;t come with it.&lt;/p&gt;
&lt;p&gt;For incremental migrations, there&amp;#39;s an additional concern: duplicate content during the parallel running period. While both systems are live, you need to ensure that content served from the old system has canonical tags pointing to the final URL in the new system, or that the old system&amp;#39;s pages are noindexed during the transition. Running two versions of the same content without a canonical signal is the fastest way to lose ranking equity you&amp;#39;ve spent years building.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 4 checklist:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Full 301 redirect map built from content inventory&lt;/li&gt;
&lt;li&gt;Old URLs with no migration path — decision documented (redirect or 404)&lt;/li&gt;
&lt;li&gt;Redirect map tested in staging (no redirect chains, no loops)&lt;/li&gt;
&lt;li&gt;Page titles and meta descriptions confirmed present in new system&lt;/li&gt;
&lt;li&gt;Canonical tags configured correctly&lt;/li&gt;
&lt;li&gt;Schema.org structured data preserved or improved&lt;/li&gt;
&lt;li&gt;XML sitemap updated for new URL structure&lt;/li&gt;
&lt;li&gt;For incremental migrations: canonical/noindex strategy for parallel period defined&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Phase 5: Staging, Cutover, and Stabilization&lt;/h2&gt;
&lt;p&gt;Everything in phases one through four has been preparation. Phase five is execution — and the goal is to make it boring.&lt;/p&gt;
&lt;p&gt;The parallel running period is the safety net. Before any production traffic changes, the new system runs in staging with a full copy of the migrated content, accessible to your team for verification. This is not user acceptance testing of the UI — it&amp;#39;s data integrity verification. You&amp;#39;re checking that every record migrated correctly, that every relationship resolved correctly, that every redirect works, and that every piece of metadata arrived intact.&lt;/p&gt;
&lt;p&gt;The content freeze is the mechanism that makes cutover clean. At a fixed point before the final import — typically 48–72 hours before DNS change — the old system goes into read-only mode. No new content is published, no existing content is edited. This ensures that the final delta import (the pass that catches any content created after the initial bulk import) doesn&amp;#39;t miss anything.&lt;/p&gt;
&lt;p&gt;For incremental migrations, &amp;quot;cutover&amp;quot; is a series of smaller events rather than a single moment. Each section of the site cuts over independently — you update the routing configuration to send a specific URL pattern to the new system, verify it&amp;#39;s working correctly, and move on to the next section. The content freeze applies per-section rather than site-wide.&lt;/p&gt;
&lt;p&gt;The go-live sequence for a big-bang cutover:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Final delta import from source system&lt;/li&gt;
&lt;li&gt;Verify record counts and spot-check content in staging&lt;/li&gt;
&lt;li&gt;DNS change (TTL should have been reduced to 5 minutes 48 hours prior)&lt;/li&gt;
&lt;li&gt;Verify redirects are firing correctly on production&lt;/li&gt;
&lt;li&gt;Check Google Search Console for crawl errors within 24 hours&lt;/li&gt;
&lt;li&gt;Monitor Core Web Vitals and ranking positions for 2–4 weeks post-launch&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The stabilization period is 2–4 weeks of active monitoring. Most ranking fluctuations after a well-executed migration are temporary — Google re-crawls, reassesses, and typically restores positions within a few weeks. What you&amp;#39;re watching for is anything that shouldn&amp;#39;t fluctuate: a sudden drop in a previously stable high-traffic page, crawl errors that indicate broken redirects, or 404s that indicate URLs that weren&amp;#39;t in your redirect map.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 5 checklist:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Staging environment fully populated with migrated content&lt;/li&gt;
&lt;li&gt;Data integrity verification complete (record counts, spot checks, relationship checks)&lt;/li&gt;
&lt;li&gt;All team members have verified their content sections in staging&lt;/li&gt;
&lt;li&gt;DNS TTL reduced 48 hours before cutover&lt;/li&gt;
&lt;li&gt;Content freeze date communicated to editorial team&lt;/li&gt;
&lt;li&gt;Final delta import scripted and tested&lt;/li&gt;
&lt;li&gt;Go-live sequence documented and rehearsed&lt;/li&gt;
&lt;li&gt;Monitoring setup: GSC crawl errors, Core Web Vitals, ranking tracking&lt;/li&gt;
&lt;li&gt;Rollback plan documented (how to revert DNS if critical issues emerge within first hour)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;The Full CMS Migration Checklist&lt;/h2&gt;
&lt;p&gt;For reference, the complete checklist across all five phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — Content Audit&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Content type inventory documented&lt;/li&gt;
&lt;li&gt;Field-by-field catalog complete&lt;/li&gt;
&lt;li&gt;Asset inventory complete&lt;/li&gt;
&lt;li&gt;Internal link map built&lt;/li&gt;
&lt;li&gt;ROT analysis complete — do-not-migrate list finalized&lt;/li&gt;
&lt;li&gt;Remaining content tagged by launch priority&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — Content Model Redesign&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New content model designed for target system&lt;/li&gt;
&lt;li&gt;Collections and globals defined&lt;/li&gt;
&lt;li&gt;Field types selected&lt;/li&gt;
&lt;li&gt;Relationship mapping complete&lt;/li&gt;
&lt;li&gt;Access control model defined&lt;/li&gt;
&lt;li&gt;Content type mapping table built (old → new, field by field)&lt;/li&gt;
&lt;li&gt;Edge cases documented with transformation rules&lt;/li&gt;
&lt;li&gt;Rich text conversion strategy defined&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 3 — Data Transformation&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Extraction scripts written per content type&lt;/li&gt;
&lt;li&gt;Transformation logic covers all documented edge cases&lt;/li&gt;
&lt;li&gt;Import scripts using target API/local API&lt;/li&gt;
&lt;li&gt;Collection dependency order respected&lt;/li&gt;
&lt;li&gt;Subset test import run and verified&lt;/li&gt;
&lt;li&gt;Asset migration script tested&lt;/li&gt;
&lt;li&gt;Full staging import complete&lt;/li&gt;
&lt;li&gt;Import logs reviewed&lt;/li&gt;
&lt;li&gt;Record count reconciliation complete&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 4 — SEO Preservation&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;301 redirect map built&lt;/li&gt;
&lt;li&gt;Unmigrated URLs — redirect or 404 decision documented&lt;/li&gt;
&lt;li&gt;Redirect map tested in staging&lt;/li&gt;
&lt;li&gt;Metadata confirmed in new system&lt;/li&gt;
&lt;li&gt;Canonical tags configured&lt;/li&gt;
&lt;li&gt;Structured data preserved&lt;/li&gt;
&lt;li&gt;XML sitemap updated&lt;/li&gt;
&lt;li&gt;Parallel period canonical/noindex strategy (incremental only)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 5 — Cutover&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Staging data integrity verification complete&lt;/li&gt;
&lt;li&gt;DNS TTL reduced 48h prior&lt;/li&gt;
&lt;li&gt;Content freeze communicated&lt;/li&gt;
&lt;li&gt;Delta import scripted and tested&lt;/li&gt;
&lt;li&gt;Go-live sequence documented&lt;/li&gt;
&lt;li&gt;Monitoring configured&lt;/li&gt;
&lt;li&gt;Rollback plan documented&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How long does a CMS migration take?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For a mid-sized site (500–5,000 pages, standard content model), plan for 8–12 weeks end-to-end. The content audit and content model redesign in phases one and two typically take longer than teams expect — two to three weeks for a thorough job. The scripting and import phase depends heavily on how clean the source data is. Enterprise migrations with custom integrations, multi-tenant architecture, or high-volume datasets run 12–20 weeks. Timelines compress when phase two is rushed and expand when edge cases surface mid-import.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Will we lose SEO rankings when we migrate?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not if phases four and five are executed correctly. A well-implemented 301 redirect map passes ranking equity to new URLs. Improving page speed and structured data during the migration often produces an SEO lift in the weeks after launch. The risk is concentrated in two areas: missing URLs in your redirect map (which produce 404s) and metadata that didn&amp;#39;t migrate with the content (which weakens the ranking signals for individual pages). Both are preventable with the verification steps in the checklist.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can we migrate without rebuilding the frontend?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes. If you already have a modern React or Next.js frontend consuming your old CMS via API, you can swap the data source from the old CMS to Payload without touching the frontend. The work is purely on the backend: new Payload installation, content model design, data import, and API endpoint mapping. Most teams choose to rebuild the frontend simultaneously to take advantage of Payload&amp;#39;s Next.js integration, but it&amp;#39;s not a requirement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s the hardest part of migrating from WordPress?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;WordPress stores rich content as HTML in the post content field — years of Gutenberg blocks, Classic Editor output, shortcodes, and inline styles that need to be parsed and converted to structured blocks. The transformation scripts for this step are the most complex part of a WordPress migration. Custom post types with ACF fields are usually straightforward; the post content blob is where the edge cases multiply. See the &lt;a href=&quot;/blog/wordpress-to-payload-migration-guide&quot;&gt;WordPress to Payload migration guide&lt;/a&gt; for the specifics.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When does incremental migration make more sense than big-bang?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When any of the following apply: the site has active e-commerce or transactional flows that can&amp;#39;t have downtime, the editorial team publishes daily and can&amp;#39;t freeze content for a week, the existing system has integrations with CRMs or marketing tools that need to stay live during transition, or the team has limited QA capacity and needs to verify each section before moving to the next. The routing complexity of incremental migration is a known cost. The risk of a botched big-bang cutover on a live business is an unknown cost with a potentially very high ceiling.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;When to Hand This Off&lt;/h2&gt;
&lt;p&gt;The content audit and SEO phases are largely systematic — given the right tools and enough time, a capable team can work through them. The content model redesign in phase two is where specialist knowledge makes the biggest difference. Getting the collection structure, relationship model, and access control right the first time prevents expensive re-architecture later. Getting it wrong means running the migration a second time or living with a system that accrues new technical debt from day one.&lt;/p&gt;
&lt;p&gt;If your team is hitting uncertainty in phase two — how to model this content type, how to handle this relationship, how to structure access control for this workflow — that&amp;#39;s the engagement point. The &lt;a href=&quot;https://meet.buildwithmatija.com/discovery-call&quot;&gt;$500 System Mapping Session&lt;/a&gt; is a 60-minute architecture session that works through exactly these decisions. It applies toward any larger engagement if the project moves forward.&lt;/p&gt;
&lt;p&gt;For teams that want the full migration handled end-to-end, the &lt;a href=&quot;/payload-cms-migration&quot;&gt;Payload CMS migration service&lt;/a&gt; covers principal-led migrations from WordPress, Contentful, Sanity, Strapi, AEM, Wix, and Drupal — architecture-first, no handoffs.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;A CMS migration done well leaves you with a cleaner system, better-structured content, and an architecture that can actually support where the business is going. The checklist isn&amp;#39;t long, but phase two — content model redesign — is the one that requires the most care and produces the most leverage. Every decision you get right there compounds positively through phases three, four, and five.&lt;/p&gt;
&lt;p&gt;The platforms you can migrate to are ultimately secondary to the quality of the architecture decisions you make before the first import script runs.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions about any of the phases, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/cms-migration-checklist-5-phase-guide-2026"/>
        </item>
        <item>
            <title><![CDATA[Complete Strapi to Payload CMS Migration Guide — 7 Steps]]></title>
            <description><![CDATA[<p>If you landed here after hitting a wall with the Strapi v4 to v5 upgrade, you&#39;re not alone. The Entity Service deprecation, the <code>documentId</code> rewrite, plugin breakage after the build tooling switch to Vite — for a lot of teams, the upgrade became the moment they started seriously asking whether to finish it or use the disruption as a reason to move.</p>
<p>Strapi and Payload are the two closest tools in the headless CMS space. Both are Node.js. Both are self-hosted. Both organise content into collections. The migration is structurally simpler than anything involving WordPress or Contentful because you&#39;re not crossing architectural paradigms — you&#39;re translating between two systems that share the same DNA.</p>
<p>This guide covers the migration end-to-end: content model mapping, schema rebuild in TypeScript, data export and import, rich text conversion from Slate to Lexical, ID remapping, and admin customisation differences. There&#39;s also an honest section at the end on when the migration isn&#39;t worth doing. If you&#39;re still evaluating whether to switch at all, start with the <a href="/payload-cms-vs-strapi">Payload CMS vs Strapi comparison</a> first.</p>
<hr>
<h2>Mapping Your Strapi Content Model to Payload</h2>
<p>The good news is that Strapi and Payload use the same fundamental content primitives. The translation is mostly 1:1.</p>
<table>
<thead>
<tr>
<th>Strapi</th>
<th>Payload</th>
</tr>
</thead>
<tbody><tr>
<td>Collection Type</td>
<td>Collection</td>
</tr>
<tr>
<td>Single Type</td>
<td>Global</td>
</tr>
<tr>
<td>Component (repeatable)</td>
<td>Array of Blocks</td>
</tr>
<tr>
<td>Component (non-repeatable)</td>
<td>Named Group field</td>
</tr>
<tr>
<td>Dynamic Zone</td>
<td>Blocks field (polymorphic)</td>
</tr>
<tr>
<td>Relation</td>
<td>Relationship field</td>
</tr>
<tr>
<td>Media</td>
<td>Upload collection</td>
</tr>
</tbody></table>
<p>The one area that requires judgment is the component-to-Blocks translation. Repeatable components that drive variable page layouts — the kind where editors stack and reorder sections — map cleanly to Payload&#39;s Blocks field. Components that are structurally fixed and always present (SEO metadata, address objects) are better modelled as named groups or inline embedded objects. Make this decision before touching any code; it affects how your admin UI looks and how editors work with content.</p>
<p>Single Types in Strapi become Globals in Payload. The concept is identical: a single document per type, used for site-wide settings, navigation, or homepage content. The translation is mechanical.</p>
<hr>
<h2>Recreating Your Strapi Schema as Payload Collections</h2>
<p>Let&#39;s work through a concrete example: an <code>articles</code> collection with a <code>tags</code> array, an <code>author</code> relationship to a <code>users</code> collection, and a rich text body. Here&#39;s what that looks like in Strapi&#39;s content-type JSON:</p>
<pre><code class="language-json">// File: src/api/article/content-types/article/schema.json
{
  &quot;kind&quot;: &quot;collectionType&quot;,
  &quot;collectionName&quot;: &quot;articles&quot;,
  &quot;attributes&quot;: {
    &quot;title&quot;: { &quot;type&quot;: &quot;string&quot;, &quot;required&quot;: true },
    &quot;body&quot;: { &quot;type&quot;: &quot;richtext&quot; },
    &quot;tags&quot;: { &quot;type&quot;: &quot;json&quot; },
    &quot;author&quot;: {
      &quot;type&quot;: &quot;relation&quot;,
      &quot;relation&quot;: &quot;manyToOne&quot;,
      &quot;target&quot;: &quot;plugin::users-permissions.user&quot;
    }
  }
}
</code></pre>
<p>In Payload, the same collection is a TypeScript file in your codebase:</p>
<pre><code class="language-typescript">// File: src/collections/Articles.ts
import type { CollectionConfig } from &#39;payload&#39;

export const Articles: CollectionConfig = {
  slug: &#39;articles&#39;,
  fields: [
    {
      name: &#39;title&#39;,
      type: &#39;text&#39;,
      required: true,
    },
    {
      name: &#39;body&#39;,
      type: &#39;richText&#39;,
    },
    {
      name: &#39;tags&#39;,
      type: &#39;array&#39;,
      fields: [
        {
          name: &#39;tag&#39;,
          type: &#39;text&#39;,
        },
      ],
    },
    {
      name: &#39;author&#39;,
      type: &#39;relationship&#39;,
      relationTo: &#39;users&#39;,
      hasMany: false,
    },
  ],
}
</code></pre>
<p>The translation is mostly mechanical. Field types map cleanly: <code>string</code> becomes <code>text</code>, <code>richtext</code> becomes <code>richText</code>, relations become <code>relationship</code> fields pointing to the target collection&#39;s slug.</p>
<p>One decision to make early: when using the PostgreSQL adapter, the <code>idType</code> option controls whether Payload uses serial integers or UUIDs as primary keys.</p>
<pre><code class="language-typescript">// File: payload.config.ts
import { postgresAdapter } from &#39;@payloadcms/db-postgres&#39;

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URI },
    idType: &#39;uuid&#39;, // or &#39;serial&#39; — this affects the ID remapping step
  }),
  // ...
})
</code></pre>
<p>If you choose <code>uuid</code>, your Payload IDs will be UUIDs from the start, which makes the remapping table (covered in the relationships section) slightly cleaner to manage. If you stay with <code>serial</code>, IDs are auto-increment integers — the same format Strapi v4 used, which can reduce confusion during migration but makes cross-system ID matching trickier.</p>
<p>Once your collections are defined, follow the <a href="/blog/payloadcms-postgres-push-to-migrations">production schema migration workflow</a> before running any import scripts — <code>push: false</code> and migration files only in production.</p>
<hr>
<h2>Exporting from Strapi and Importing into Payload</h2>
<h3>Step 1 — Export from Strapi</h3>
<p>Strapi&#39;s Data Management feature (available from v4.6.0+) provides a CLI export command that produces a compressed archive of your content:</p>
<pre><code class="language-bash">strapi export --no-encrypt --no-compress -f strapi-export
</code></pre>
<p>The <code>--no-encrypt --no-compress</code> flags give you an unencrypted tar you can inspect directly. Unpack it and you&#39;ll find:</p>
<pre><code>strapi-export/
  entities/
    entities_00001.jsonl   # one JSON object per line, one file per chunk
    entities_00002.jsonl
  links/
    links_00001.jsonl      # relationship data
  schemas/
    schemas.jsonl          # your content type definitions
  metadata.json
</code></pre>
<p>Each line in the entity files is a JSON object representing one document. In Strapi v4, the identifier is a numeric <code>id</code>. In Strapi v5, you&#39;ll also find a <code>documentId</code> field — a stable string identifier introduced with the Document Service API.</p>
<h3>Step 2 — Transform and Import</h3>
<p>Here&#39;s a TypeScript script that reads the JSONL export, transforms the article documents, and imports them into Payload via the Local API:</p>
<pre><code class="language-typescript">// File: scripts/import-articles.ts
import payload from &#39;payload&#39;
import config from &#39;../payload.config&#39;
import { createReadStream } from &#39;fs&#39;
import { createInterface } from &#39;readline&#39;
import path from &#39;path&#39;

// Maps Strapi numeric IDs to newly created Payload IDs
export const articleIdMap = new Map&lt;number, string&gt;()

async function importArticles() {
  await payload.init({ config })

  const exportPath = path.resolve(&#39;./strapi-export/entities/entities_00001.jsonl&#39;)
  const rl = createInterface({
    input: createReadStream(exportPath),
    crlfDelay: Infinity,
  })

  for await (const line of rl) {
    const record = JSON.parse(line)

    // Filter to the articles collection only
    if (record.__type !== &#39;api::article.article&#39;) continue

    const doc = await payload.create({
      collection: &#39;articles&#39;,
      data: {
        title: record.title,
        body: record.body, // rich text handled separately — see next section
        tags: (record.tags ?? []).map((t: string) =&gt; ({ tag: t })),
        // relationships resolved in second pass
      },
    })

    // Store the mapping: Strapi ID → Payload ID
    articleIdMap.set(record.id, doc.id as string)
    console.log(`Imported article ${record.id} → ${doc.id}`)
  }

  console.log(`Done. Imported ${articleIdMap.size} articles.`)
  process.exit(0)
}

importArticles()
</code></pre>
<blockquote>
<p>[!NOTE]
This script uses Payload&#39;s Local API (<code>payload.create</code>), which runs directly in Node without an HTTP layer. For CLI-based imports against a running Payload instance, the <a href="/blog/payload-cms-sdk-cli-toolkit"><code>@payloadcms/sdk</code></a> is the cleaner option — it handles auth and gives you the same <code>find</code>, <code>create</code>, <code>update</code>, and <code>delete</code> methods over HTTP.</p>
</blockquote>
<p>Relationships are intentionally left out of the first pass. They&#39;re handled separately in the ID remapping step after all documents exist.</p>
<hr>
<h2>Converting Strapi&#39;s Rich Text to Payload&#39;s Lexical Format</h2>
<p>This is the most technically involved part of the migration, and the part that every other guide skips entirely.</p>
<p>Strapi v4 and v5 both ship a Slate-based rich text editor as the default. Payload 3.x uses Lexical (<code>@payloadcms/richtext-lexical</code>). The node schemas are different, and data stored in one format cannot be read directly by the other.</p>
<p>Here&#39;s the structural difference for a heading node. In Strapi&#39;s Slate output:</p>
<pre><code class="language-json">{
  &quot;type&quot;: &quot;heading&quot;,
  &quot;level&quot;: 2,
  &quot;children&quot;: [{ &quot;text&quot;: &quot;Section title&quot; }]
}
</code></pre>
<p>In Payload&#39;s Lexical format (<code>SerializedHeadingNode</code>):</p>
<pre><code class="language-json">{
  &quot;type&quot;: &quot;heading&quot;,
  &quot;tag&quot;: &quot;h2&quot;,
  &quot;children&quot;: [{ &quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;Section title&quot;, &quot;version&quot;: 1 }],
  &quot;direction&quot;: &quot;ltr&quot;,
  &quot;format&quot;: &quot;&quot;,
  &quot;indent&quot;: 0,
  &quot;version&quot;: 1
}
</code></pre>
<p>The structure is similar enough to convert programmatically. Here&#39;s a converter that handles the common node types:</p>
<pre><code class="language-typescript">// File: scripts/slate-to-lexical.ts

type SlateNode = {
  type?: string
  level?: number
  url?: string
  text?: string
  bold?: boolean
  italic?: boolean
  underline?: boolean
  children?: SlateNode[]
}

type LexicalNode = Record&lt;string, unknown&gt;

export function convertSlateToLexical(slateNodes: SlateNode[]): LexicalNode {
  return {
    root: {
      type: &#39;root&#39;,
      children: slateNodes.map(convertNode),
      direction: &#39;ltr&#39;,
      format: &#39;&#39;,
      indent: 0,
      version: 1,
    },
  }
}

function convertNode(node: SlateNode): LexicalNode {
  // Text leaf node
  if (node.text !== undefined) {
    return {
      type: &#39;text&#39;,
      text: node.text,
      format: getTextFormat(node),
      version: 1,
    }
  }

  const children = (node.children ?? []).map(convertNode)

  switch (node.type) {
    case &#39;paragraph&#39;:
      return { type: &#39;paragraph&#39;, children, direction: &#39;ltr&#39;, format: &#39;&#39;, indent: 0, version: 1 }

    case &#39;heading&#39;:
      return {
        type: &#39;heading&#39;,
        tag: `h${node.level ?? 2}`,
        children,
        direction: &#39;ltr&#39;,
        format: &#39;&#39;,
        indent: 0,
        version: 1,
      }

    case &#39;list&#39;:
      return {
        type: &#39;list&#39;,
        listType: &#39;bullet&#39;,
        children,
        direction: &#39;ltr&#39;,
        format: &#39;&#39;,
        indent: 0,
        version: 1,
        start: 1,
        tag: &#39;ul&#39;,
      }

    case &#39;list-item&#39;:
      return {
        type: &#39;listitem&#39;,
        children,
        direction: &#39;ltr&#39;,
        format: &#39;&#39;,
        indent: 0,
        version: 1,
        value: 1,
      }

    case &#39;link&#39;:
      return {
        type: &#39;link&#39;,
        url: node.url ?? &#39;&#39;,
        children,
        direction: &#39;ltr&#39;,
        format: &#39;&#39;,
        indent: 0,
        version: 1,
        fields: { url: node.url ?? &#39;&#39;, newTab: false },
        rel: &#39;noreferrer&#39;,
        target: null,
        title: null,
      }

    default:
      // Fall back to paragraph for unknown types
      return { type: &#39;paragraph&#39;, children, direction: &#39;ltr&#39;, format: &#39;&#39;, indent: 0, version: 1 }
  }
}

function getTextFormat(node: SlateNode): number {
  let format = 0
  if (node.bold) format |= 1
  if (node.italic) format |= 2
  if (node.underline) format |= 8
  return format
}
</code></pre>
<blockquote>
<p>[!WARNING]
Payload 3.79.0 upgraded the internal Lexical dependency from 0.35.0 to 0.41.0. The release notes confirm all breaking changes are handled internally — no action required for standard usage. Custom Lexical node converters should still be validated after a Payload version upgrade.</p>
</blockquote>
<p>Use the converter in the import script by replacing the <code>body</code> field:</p>
<pre><code class="language-typescript">// In import-articles.ts
import { convertSlateToLexical } from &#39;./slate-to-lexical&#39;

// Inside the import loop:
data: {
  title: record.title,
  body: record.body ? convertSlateToLexical(record.body) : undefined,
  // ...
}
</code></pre>
<p>Test a handful of documents manually before running the full import. Edge cases like nested lists or Strapi-specific custom blocks will need their own <code>case</code> branches added to the converter.</p>
<hr>
<h2>Remapping Strapi IDs to Payload IDs</h2>
<p>Strapi v4 uses auto-increment integers as primary keys. Strapi v5 adds <code>documentId</code> — a stable string — but the underlying <code>id</code> is still a numeric integer. Payload&#39;s PostgreSQL adapter uses either serial integers or UUIDs depending on your <code>idType</code> config. MongoDB Payload stores stringified ObjectIds.</p>
<p>The problem: you can&#39;t insert a Strapi ID into a Payload relationship field and expect it to resolve. The ID formats are incompatible, and even if they happened to match numerically, Payload&#39;s relationship fields store Payload document IDs.</p>
<p>The solution is a two-pass import. Pass one creates all documents (articles, authors, tags — any collection involved in a relationship) and builds a mapping table. Pass two resolves relationships using that table.</p>
<p>Here&#39;s the second pass for the articles collection, linking the <code>author</code> relationship:</p>
<pre><code class="language-typescript">// File: scripts/import-article-relations.ts
import payload from &#39;payload&#39;
import config from &#39;../payload.config&#39;
import { articleIdMap } from &#39;./import-articles&#39;
import { authorIdMap } from &#39;./import-authors&#39; // built in a separate first-pass script
import { createReadStream } from &#39;fs&#39;
import { createInterface } from &#39;readline&#39;
import path from &#39;path&#39;

async function importArticleRelations() {
  await payload.init({ config })

  const linksPath = path.resolve(&#39;./strapi-export/links/links_00001.jsonl&#39;)
  const rl = createInterface({
    input: createReadStream(linksPath),
    crlfDelay: Infinity,
  })

  for await (const line of rl) {
    const link = JSON.parse(line)

    // Filter to article → author relations only
    if (
      link.__type !== &#39;api::article.article&#39; ||
      link.field !== &#39;author&#39;
    ) continue

    const payloadArticleId = articleIdMap.get(link.entity_id)
    const payloadAuthorId = authorIdMap.get(link.related_id)

    if (!payloadArticleId || !payloadAuthorId) continue

    await payload.update({
      collection: &#39;articles&#39;,
      id: payloadArticleId,
      data: {
        author: payloadAuthorId,
      },
    })
  }

  console.log(&#39;Relations resolved.&#39;)
  process.exit(0)
}

importArticleRelations()
</code></pre>
<p>Run all first-pass scripts (one per collection) before running any second-pass relationship scripts. The order matters: a relationship target must exist in Payload before you can reference it.</p>
<blockquote>
<p>[!TIP]
Export your <code>articleIdMap</code>, <code>authorIdMap</code>, and any other mapping tables to JSON files on disk between passes. If a second-pass script fails midway, you won&#39;t need to re-run the full first pass to recover the mapping state.</p>
</blockquote>
<hr>
<h2>What Happens to Your Strapi Admin Customisations?</h2>
<p>If your team has built Strapi admin customisations, the mental model shifts significantly.</p>
<p>In Strapi, admin customisation uses named injection zones: you register components via <code>bootstrap</code> and <code>register</code> hooks in <code>src/admin/app.tsx</code>, targeting specific named zones in the admin UI (<code>contentManager.editView.informations</code>, for example). The layout is fixed; you&#39;re injecting into predefined slots.</p>
<p>In Payload, customisation is declared directly in the collection config via the <code>admin.components</code> field. You own the component slots — header, beforeList, afterList, Description, and others — and render your own React components there. There&#39;s no injection zone API to learn; it&#39;s just React props in TypeScript.</p>
<pre><code class="language-typescript">// File: src/collections/Articles.ts (admin customisation)
export const Articles: CollectionConfig = {
  slug: &#39;articles&#39;,
  admin: {
    components: {
      beforeList: [() =&gt; import(&#39;./components/ArticleStats&#39;)],
      Description: () =&gt; import(&#39;./components/ArticleDescription&#39;),
    },
  },
  fields: [...],
}
</code></pre>
<p>If you&#39;ve built Strapi plugins using <code>@strapi/sdk-plugin</code> — particularly anything that hooks into plugin content type lifecycles — those will need a complete rewrite as Payload hooks or custom admin components. The good news is the resulting code is simpler: everything lives in your repository, typed, no plugin registration ceremony.</p>
<p>For a full walkthrough of what&#39;s available in Payload&#39;s admin component system, the <a href="/blog/payload-cms-custom-admin-ui-components-guide">Payload CMS Admin UI custom components guide</a> covers the patterns in detail.</p>
<hr>
<h2>When the Migration Isn&#39;t Worth It</h2>
<p>This section exists because not every Strapi project should move to Payload. Three situations where the ROI isn&#39;t there:</p>
<p><strong>Your content team manages their own schema.</strong> Strapi&#39;s visual content type builder lets editors and non-technical team members add fields, create new content types, and modify schemas without touching code or triggering a deployment. Payload removes this entirely — every schema change is a TypeScript edit and a code deploy. If your organisation has editors managing their own content model, this isn&#39;t a DX improvement, it&#39;s a regression.</p>
<p><strong>You&#39;re running three or more Strapi plugins for core functionality.</strong> Strapi&#39;s plugin ecosystem has over 400 community packages. Payload&#39;s is curated and growing, but materially smaller. If your project depends on Strapi plugins for search integration, payment processing, localisation workflows, or enterprise SSO, check whether Payload has equivalents before starting the migration. In some cases you&#39;ll be rebuilding features, not migrating content.</p>
<p><strong>Your team doesn&#39;t work in TypeScript.</strong> Payload&#39;s configuration, schema definitions, access control, and hooks are all TypeScript throughout. There&#39;s no escape hatch. A team without TypeScript fluency will struggle to maintain a Payload codebase and won&#39;t get the benefits that make the migration worthwhile in the first place.</p>
<p>If none of these apply — you have an engineering-led team, a manageable plugin footprint, and TypeScript as a baseline — the migration is straightforward and the long-term DX improvement is real. For a broader view of what Payload offers compared to the headless CMS landscape, the <a href="/blog/best-headless-cms-nextjs-payload-2026">best headless CMS for Next.js guide</a> is worth reading before committing.</p>
<hr>
<h2>Ready to Migrate?</h2>
<p>This guide covered the full migration path: content model mapping, TypeScript schema rebuild, Strapi export and Payload import, Slate to Lexical rich text conversion, ID remapping with a two-pass approach, and admin customisation differences.</p>
<p>If your team would rather hand the migration off than run it in-house, the <a href="/payload-cms-migration">Payload CMS migration service</a> covers end-to-end migrations from Strapi, Contentful, WordPress, and Sanity.</p>
<p>Let me know in the comments if you run into edge cases not covered here — particularly around Strapi dynamic zones or custom field types — and subscribe for more practical Payload guides.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/strapi-to-payload-cms-migration-guide</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/strapi-to-payload-cms-migration-guide</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sun, 05 Apr 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you landed here after hitting a wall with the Strapi v4 to v5 upgrade, you&amp;#39;re not alone. The Entity Service deprecation, the &lt;code&gt;documentId&lt;/code&gt; rewrite, plugin breakage after the build tooling switch to Vite — for a lot of teams, the upgrade became the moment they started seriously asking whether to finish it or use the disruption as a reason to move.&lt;/p&gt;
&lt;p&gt;Strapi and Payload are the two closest tools in the headless CMS space. Both are Node.js. Both are self-hosted. Both organise content into collections. The migration is structurally simpler than anything involving WordPress or Contentful because you&amp;#39;re not crossing architectural paradigms — you&amp;#39;re translating between two systems that share the same DNA.&lt;/p&gt;
&lt;p&gt;This guide covers the migration end-to-end: content model mapping, schema rebuild in TypeScript, data export and import, rich text conversion from Slate to Lexical, ID remapping, and admin customisation differences. There&amp;#39;s also an honest section at the end on when the migration isn&amp;#39;t worth doing. If you&amp;#39;re still evaluating whether to switch at all, start with the &lt;a href=&quot;/payload-cms-vs-strapi&quot;&gt;Payload CMS vs Strapi comparison&lt;/a&gt; first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Mapping Your Strapi Content Model to Payload&lt;/h2&gt;
&lt;p&gt;The good news is that Strapi and Payload use the same fundamental content primitives. The translation is mostly 1:1.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strapi&lt;/th&gt;
&lt;th&gt;Payload&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Collection Type&lt;/td&gt;
&lt;td&gt;Collection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single Type&lt;/td&gt;
&lt;td&gt;Global&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component (repeatable)&lt;/td&gt;
&lt;td&gt;Array of Blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component (non-repeatable)&lt;/td&gt;
&lt;td&gt;Named Group field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic Zone&lt;/td&gt;
&lt;td&gt;Blocks field (polymorphic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relation&lt;/td&gt;
&lt;td&gt;Relationship field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Media&lt;/td&gt;
&lt;td&gt;Upload collection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The one area that requires judgment is the component-to-Blocks translation. Repeatable components that drive variable page layouts — the kind where editors stack and reorder sections — map cleanly to Payload&amp;#39;s Blocks field. Components that are structurally fixed and always present (SEO metadata, address objects) are better modelled as named groups or inline embedded objects. Make this decision before touching any code; it affects how your admin UI looks and how editors work with content.&lt;/p&gt;
&lt;p&gt;Single Types in Strapi become Globals in Payload. The concept is identical: a single document per type, used for site-wide settings, navigation, or homepage content. The translation is mechanical.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Recreating Your Strapi Schema as Payload Collections&lt;/h2&gt;
&lt;p&gt;Let&amp;#39;s work through a concrete example: an &lt;code&gt;articles&lt;/code&gt; collection with a &lt;code&gt;tags&lt;/code&gt; array, an &lt;code&gt;author&lt;/code&gt; relationship to a &lt;code&gt;users&lt;/code&gt; collection, and a rich text body. Here&amp;#39;s what that looks like in Strapi&amp;#39;s content-type JSON:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// File: src/api/article/content-types/article/schema.json
{
  &amp;quot;kind&amp;quot;: &amp;quot;collectionType&amp;quot;,
  &amp;quot;collectionName&amp;quot;: &amp;quot;articles&amp;quot;,
  &amp;quot;attributes&amp;quot;: {
    &amp;quot;title&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot;, &amp;quot;required&amp;quot;: true },
    &amp;quot;body&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;richtext&amp;quot; },
    &amp;quot;tags&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;json&amp;quot; },
    &amp;quot;author&amp;quot;: {
      &amp;quot;type&amp;quot;: &amp;quot;relation&amp;quot;,
      &amp;quot;relation&amp;quot;: &amp;quot;manyToOne&amp;quot;,
      &amp;quot;target&amp;quot;: &amp;quot;plugin::users-permissions.user&amp;quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Payload, the same collection is a TypeScript file in your codebase:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: src/collections/Articles.ts
import type { CollectionConfig } from &amp;#39;payload&amp;#39;

export const Articles: CollectionConfig = {
  slug: &amp;#39;articles&amp;#39;,
  fields: [
    {
      name: &amp;#39;title&amp;#39;,
      type: &amp;#39;text&amp;#39;,
      required: true,
    },
    {
      name: &amp;#39;body&amp;#39;,
      type: &amp;#39;richText&amp;#39;,
    },
    {
      name: &amp;#39;tags&amp;#39;,
      type: &amp;#39;array&amp;#39;,
      fields: [
        {
          name: &amp;#39;tag&amp;#39;,
          type: &amp;#39;text&amp;#39;,
        },
      ],
    },
    {
      name: &amp;#39;author&amp;#39;,
      type: &amp;#39;relationship&amp;#39;,
      relationTo: &amp;#39;users&amp;#39;,
      hasMany: false,
    },
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The translation is mostly mechanical. Field types map cleanly: &lt;code&gt;string&lt;/code&gt; becomes &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;richtext&lt;/code&gt; becomes &lt;code&gt;richText&lt;/code&gt;, relations become &lt;code&gt;relationship&lt;/code&gt; fields pointing to the target collection&amp;#39;s slug.&lt;/p&gt;
&lt;p&gt;One decision to make early: when using the PostgreSQL adapter, the &lt;code&gt;idType&lt;/code&gt; option controls whether Payload uses serial integers or UUIDs as primary keys.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: payload.config.ts
import { postgresAdapter } from &amp;#39;@payloadcms/db-postgres&amp;#39;

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URI },
    idType: &amp;#39;uuid&amp;#39;, // or &amp;#39;serial&amp;#39; — this affects the ID remapping step
  }),
  // ...
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you choose &lt;code&gt;uuid&lt;/code&gt;, your Payload IDs will be UUIDs from the start, which makes the remapping table (covered in the relationships section) slightly cleaner to manage. If you stay with &lt;code&gt;serial&lt;/code&gt;, IDs are auto-increment integers — the same format Strapi v4 used, which can reduce confusion during migration but makes cross-system ID matching trickier.&lt;/p&gt;
&lt;p&gt;Once your collections are defined, follow the &lt;a href=&quot;/blog/payloadcms-postgres-push-to-migrations&quot;&gt;production schema migration workflow&lt;/a&gt; before running any import scripts — &lt;code&gt;push: false&lt;/code&gt; and migration files only in production.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Exporting from Strapi and Importing into Payload&lt;/h2&gt;
&lt;h3&gt;Step 1 — Export from Strapi&lt;/h3&gt;
&lt;p&gt;Strapi&amp;#39;s Data Management feature (available from v4.6.0+) provides a CLI export command that produces a compressed archive of your content:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;strapi export --no-encrypt --no-compress -f strapi-export
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--no-encrypt --no-compress&lt;/code&gt; flags give you an unencrypted tar you can inspect directly. Unpack it and you&amp;#39;ll find:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;strapi-export/
  entities/
    entities_00001.jsonl   # one JSON object per line, one file per chunk
    entities_00002.jsonl
  links/
    links_00001.jsonl      # relationship data
  schemas/
    schemas.jsonl          # your content type definitions
  metadata.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each line in the entity files is a JSON object representing one document. In Strapi v4, the identifier is a numeric &lt;code&gt;id&lt;/code&gt;. In Strapi v5, you&amp;#39;ll also find a &lt;code&gt;documentId&lt;/code&gt; field — a stable string identifier introduced with the Document Service API.&lt;/p&gt;
&lt;h3&gt;Step 2 — Transform and Import&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s a TypeScript script that reads the JSONL export, transforms the article documents, and imports them into Payload via the Local API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/import-articles.ts
import payload from &amp;#39;payload&amp;#39;
import config from &amp;#39;../payload.config&amp;#39;
import { createReadStream } from &amp;#39;fs&amp;#39;
import { createInterface } from &amp;#39;readline&amp;#39;
import path from &amp;#39;path&amp;#39;

// Maps Strapi numeric IDs to newly created Payload IDs
export const articleIdMap = new Map&amp;lt;number, string&amp;gt;()

async function importArticles() {
  await payload.init({ config })

  const exportPath = path.resolve(&amp;#39;./strapi-export/entities/entities_00001.jsonl&amp;#39;)
  const rl = createInterface({
    input: createReadStream(exportPath),
    crlfDelay: Infinity,
  })

  for await (const line of rl) {
    const record = JSON.parse(line)

    // Filter to the articles collection only
    if (record.__type !== &amp;#39;api::article.article&amp;#39;) continue

    const doc = await payload.create({
      collection: &amp;#39;articles&amp;#39;,
      data: {
        title: record.title,
        body: record.body, // rich text handled separately — see next section
        tags: (record.tags ?? []).map((t: string) =&amp;gt; ({ tag: t })),
        // relationships resolved in second pass
      },
    })

    // Store the mapping: Strapi ID → Payload ID
    articleIdMap.set(record.id, doc.id as string)
    console.log(`Imported article ${record.id} → ${doc.id}`)
  }

  console.log(`Done. Imported ${articleIdMap.size} articles.`)
  process.exit(0)
}

importArticles()
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
This script uses Payload&amp;#39;s Local API (&lt;code&gt;payload.create&lt;/code&gt;), which runs directly in Node without an HTTP layer. For CLI-based imports against a running Payload instance, the &lt;a href=&quot;/blog/payload-cms-sdk-cli-toolkit&quot;&gt;&lt;code&gt;@payloadcms/sdk&lt;/code&gt;&lt;/a&gt; is the cleaner option — it handles auth and gives you the same &lt;code&gt;find&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; methods over HTTP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Relationships are intentionally left out of the first pass. They&amp;#39;re handled separately in the ID remapping step after all documents exist.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Converting Strapi&amp;#39;s Rich Text to Payload&amp;#39;s Lexical Format&lt;/h2&gt;
&lt;p&gt;This is the most technically involved part of the migration, and the part that every other guide skips entirely.&lt;/p&gt;
&lt;p&gt;Strapi v4 and v5 both ship a Slate-based rich text editor as the default. Payload 3.x uses Lexical (&lt;code&gt;@payloadcms/richtext-lexical&lt;/code&gt;). The node schemas are different, and data stored in one format cannot be read directly by the other.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s the structural difference for a heading node. In Strapi&amp;#39;s Slate output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;heading&amp;quot;,
  &amp;quot;level&amp;quot;: 2,
  &amp;quot;children&amp;quot;: [{ &amp;quot;text&amp;quot;: &amp;quot;Section title&amp;quot; }]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Payload&amp;#39;s Lexical format (&lt;code&gt;SerializedHeadingNode&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;heading&amp;quot;,
  &amp;quot;tag&amp;quot;: &amp;quot;h2&amp;quot;,
  &amp;quot;children&amp;quot;: [{ &amp;quot;type&amp;quot;: &amp;quot;text&amp;quot;, &amp;quot;text&amp;quot;: &amp;quot;Section title&amp;quot;, &amp;quot;version&amp;quot;: 1 }],
  &amp;quot;direction&amp;quot;: &amp;quot;ltr&amp;quot;,
  &amp;quot;format&amp;quot;: &amp;quot;&amp;quot;,
  &amp;quot;indent&amp;quot;: 0,
  &amp;quot;version&amp;quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The structure is similar enough to convert programmatically. Here&amp;#39;s a converter that handles the common node types:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/slate-to-lexical.ts

type SlateNode = {
  type?: string
  level?: number
  url?: string
  text?: string
  bold?: boolean
  italic?: boolean
  underline?: boolean
  children?: SlateNode[]
}

type LexicalNode = Record&amp;lt;string, unknown&amp;gt;

export function convertSlateToLexical(slateNodes: SlateNode[]): LexicalNode {
  return {
    root: {
      type: &amp;#39;root&amp;#39;,
      children: slateNodes.map(convertNode),
      direction: &amp;#39;ltr&amp;#39;,
      format: &amp;#39;&amp;#39;,
      indent: 0,
      version: 1,
    },
  }
}

function convertNode(node: SlateNode): LexicalNode {
  // Text leaf node
  if (node.text !== undefined) {
    return {
      type: &amp;#39;text&amp;#39;,
      text: node.text,
      format: getTextFormat(node),
      version: 1,
    }
  }

  const children = (node.children ?? []).map(convertNode)

  switch (node.type) {
    case &amp;#39;paragraph&amp;#39;:
      return { type: &amp;#39;paragraph&amp;#39;, children, direction: &amp;#39;ltr&amp;#39;, format: &amp;#39;&amp;#39;, indent: 0, version: 1 }

    case &amp;#39;heading&amp;#39;:
      return {
        type: &amp;#39;heading&amp;#39;,
        tag: `h${node.level ?? 2}`,
        children,
        direction: &amp;#39;ltr&amp;#39;,
        format: &amp;#39;&amp;#39;,
        indent: 0,
        version: 1,
      }

    case &amp;#39;list&amp;#39;:
      return {
        type: &amp;#39;list&amp;#39;,
        listType: &amp;#39;bullet&amp;#39;,
        children,
        direction: &amp;#39;ltr&amp;#39;,
        format: &amp;#39;&amp;#39;,
        indent: 0,
        version: 1,
        start: 1,
        tag: &amp;#39;ul&amp;#39;,
      }

    case &amp;#39;list-item&amp;#39;:
      return {
        type: &amp;#39;listitem&amp;#39;,
        children,
        direction: &amp;#39;ltr&amp;#39;,
        format: &amp;#39;&amp;#39;,
        indent: 0,
        version: 1,
        value: 1,
      }

    case &amp;#39;link&amp;#39;:
      return {
        type: &amp;#39;link&amp;#39;,
        url: node.url ?? &amp;#39;&amp;#39;,
        children,
        direction: &amp;#39;ltr&amp;#39;,
        format: &amp;#39;&amp;#39;,
        indent: 0,
        version: 1,
        fields: { url: node.url ?? &amp;#39;&amp;#39;, newTab: false },
        rel: &amp;#39;noreferrer&amp;#39;,
        target: null,
        title: null,
      }

    default:
      // Fall back to paragraph for unknown types
      return { type: &amp;#39;paragraph&amp;#39;, children, direction: &amp;#39;ltr&amp;#39;, format: &amp;#39;&amp;#39;, indent: 0, version: 1 }
  }
}

function getTextFormat(node: SlateNode): number {
  let format = 0
  if (node.bold) format |= 1
  if (node.italic) format |= 2
  if (node.underline) format |= 8
  return format
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
Payload 3.79.0 upgraded the internal Lexical dependency from 0.35.0 to 0.41.0. The release notes confirm all breaking changes are handled internally — no action required for standard usage. Custom Lexical node converters should still be validated after a Payload version upgrade.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Use the converter in the import script by replacing the &lt;code&gt;body&lt;/code&gt; field:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// In import-articles.ts
import { convertSlateToLexical } from &amp;#39;./slate-to-lexical&amp;#39;

// Inside the import loop:
data: {
  title: record.title,
  body: record.body ? convertSlateToLexical(record.body) : undefined,
  // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Test a handful of documents manually before running the full import. Edge cases like nested lists or Strapi-specific custom blocks will need their own &lt;code&gt;case&lt;/code&gt; branches added to the converter.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Remapping Strapi IDs to Payload IDs&lt;/h2&gt;
&lt;p&gt;Strapi v4 uses auto-increment integers as primary keys. Strapi v5 adds &lt;code&gt;documentId&lt;/code&gt; — a stable string — but the underlying &lt;code&gt;id&lt;/code&gt; is still a numeric integer. Payload&amp;#39;s PostgreSQL adapter uses either serial integers or UUIDs depending on your &lt;code&gt;idType&lt;/code&gt; config. MongoDB Payload stores stringified ObjectIds.&lt;/p&gt;
&lt;p&gt;The problem: you can&amp;#39;t insert a Strapi ID into a Payload relationship field and expect it to resolve. The ID formats are incompatible, and even if they happened to match numerically, Payload&amp;#39;s relationship fields store Payload document IDs.&lt;/p&gt;
&lt;p&gt;The solution is a two-pass import. Pass one creates all documents (articles, authors, tags — any collection involved in a relationship) and builds a mapping table. Pass two resolves relationships using that table.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s the second pass for the articles collection, linking the &lt;code&gt;author&lt;/code&gt; relationship:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: scripts/import-article-relations.ts
import payload from &amp;#39;payload&amp;#39;
import config from &amp;#39;../payload.config&amp;#39;
import { articleIdMap } from &amp;#39;./import-articles&amp;#39;
import { authorIdMap } from &amp;#39;./import-authors&amp;#39; // built in a separate first-pass script
import { createReadStream } from &amp;#39;fs&amp;#39;
import { createInterface } from &amp;#39;readline&amp;#39;
import path from &amp;#39;path&amp;#39;

async function importArticleRelations() {
  await payload.init({ config })

  const linksPath = path.resolve(&amp;#39;./strapi-export/links/links_00001.jsonl&amp;#39;)
  const rl = createInterface({
    input: createReadStream(linksPath),
    crlfDelay: Infinity,
  })

  for await (const line of rl) {
    const link = JSON.parse(line)

    // Filter to article → author relations only
    if (
      link.__type !== &amp;#39;api::article.article&amp;#39; ||
      link.field !== &amp;#39;author&amp;#39;
    ) continue

    const payloadArticleId = articleIdMap.get(link.entity_id)
    const payloadAuthorId = authorIdMap.get(link.related_id)

    if (!payloadArticleId || !payloadAuthorId) continue

    await payload.update({
      collection: &amp;#39;articles&amp;#39;,
      id: payloadArticleId,
      data: {
        author: payloadAuthorId,
      },
    })
  }

  console.log(&amp;#39;Relations resolved.&amp;#39;)
  process.exit(0)
}

importArticleRelations()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run all first-pass scripts (one per collection) before running any second-pass relationship scripts. The order matters: a relationship target must exist in Payload before you can reference it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP]
Export your &lt;code&gt;articleIdMap&lt;/code&gt;, &lt;code&gt;authorIdMap&lt;/code&gt;, and any other mapping tables to JSON files on disk between passes. If a second-pass script fails midway, you won&amp;#39;t need to re-run the full first pass to recover the mapping state.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;What Happens to Your Strapi Admin Customisations?&lt;/h2&gt;
&lt;p&gt;If your team has built Strapi admin customisations, the mental model shifts significantly.&lt;/p&gt;
&lt;p&gt;In Strapi, admin customisation uses named injection zones: you register components via &lt;code&gt;bootstrap&lt;/code&gt; and &lt;code&gt;register&lt;/code&gt; hooks in &lt;code&gt;src/admin/app.tsx&lt;/code&gt;, targeting specific named zones in the admin UI (&lt;code&gt;contentManager.editView.informations&lt;/code&gt;, for example). The layout is fixed; you&amp;#39;re injecting into predefined slots.&lt;/p&gt;
&lt;p&gt;In Payload, customisation is declared directly in the collection config via the &lt;code&gt;admin.components&lt;/code&gt; field. You own the component slots — header, beforeList, afterList, Description, and others — and render your own React components there. There&amp;#39;s no injection zone API to learn; it&amp;#39;s just React props in TypeScript.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// File: src/collections/Articles.ts (admin customisation)
export const Articles: CollectionConfig = {
  slug: &amp;#39;articles&amp;#39;,
  admin: {
    components: {
      beforeList: [() =&amp;gt; import(&amp;#39;./components/ArticleStats&amp;#39;)],
      Description: () =&amp;gt; import(&amp;#39;./components/ArticleDescription&amp;#39;),
    },
  },
  fields: [...],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&amp;#39;ve built Strapi plugins using &lt;code&gt;@strapi/sdk-plugin&lt;/code&gt; — particularly anything that hooks into plugin content type lifecycles — those will need a complete rewrite as Payload hooks or custom admin components. The good news is the resulting code is simpler: everything lives in your repository, typed, no plugin registration ceremony.&lt;/p&gt;
&lt;p&gt;For a full walkthrough of what&amp;#39;s available in Payload&amp;#39;s admin component system, the &lt;a href=&quot;/blog/payload-cms-custom-admin-ui-components-guide&quot;&gt;Payload CMS Admin UI custom components guide&lt;/a&gt; covers the patterns in detail.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;When the Migration Isn&amp;#39;t Worth It&lt;/h2&gt;
&lt;p&gt;This section exists because not every Strapi project should move to Payload. Three situations where the ROI isn&amp;#39;t there:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your content team manages their own schema.&lt;/strong&gt; Strapi&amp;#39;s visual content type builder lets editors and non-technical team members add fields, create new content types, and modify schemas without touching code or triggering a deployment. Payload removes this entirely — every schema change is a TypeScript edit and a code deploy. If your organisation has editors managing their own content model, this isn&amp;#39;t a DX improvement, it&amp;#39;s a regression.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You&amp;#39;re running three or more Strapi plugins for core functionality.&lt;/strong&gt; Strapi&amp;#39;s plugin ecosystem has over 400 community packages. Payload&amp;#39;s is curated and growing, but materially smaller. If your project depends on Strapi plugins for search integration, payment processing, localisation workflows, or enterprise SSO, check whether Payload has equivalents before starting the migration. In some cases you&amp;#39;ll be rebuilding features, not migrating content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your team doesn&amp;#39;t work in TypeScript.&lt;/strong&gt; Payload&amp;#39;s configuration, schema definitions, access control, and hooks are all TypeScript throughout. There&amp;#39;s no escape hatch. A team without TypeScript fluency will struggle to maintain a Payload codebase and won&amp;#39;t get the benefits that make the migration worthwhile in the first place.&lt;/p&gt;
&lt;p&gt;If none of these apply — you have an engineering-led team, a manageable plugin footprint, and TypeScript as a baseline — the migration is straightforward and the long-term DX improvement is real. For a broader view of what Payload offers compared to the headless CMS landscape, the &lt;a href=&quot;/blog/best-headless-cms-nextjs-payload-2026&quot;&gt;best headless CMS for Next.js guide&lt;/a&gt; is worth reading before committing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Ready to Migrate?&lt;/h2&gt;
&lt;p&gt;This guide covered the full migration path: content model mapping, TypeScript schema rebuild, Strapi export and Payload import, Slate to Lexical rich text conversion, ID remapping with a two-pass approach, and admin customisation differences.&lt;/p&gt;
&lt;p&gt;If your team would rather hand the migration off than run it in-house, the &lt;a href=&quot;/payload-cms-migration&quot;&gt;Payload CMS migration service&lt;/a&gt; covers end-to-end migrations from Strapi, Contentful, WordPress, and Sanity.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you run into edge cases not covered here — particularly around Strapi dynamic zones or custom field types — and subscribe for more practical Payload guides.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/strapi-to-payload-cms-migration-guide"/>
        </item>
        <item>
            <title><![CDATA[Best Construction Company Websites 2026: What Works]]></title>
            <description><![CDATA[<p>The best construction company websites share one characteristic above everything else: the owner can run them. A portfolio that gets updated after every completed project, service pages that reflect current capabilities, and a contact form that a visitor can find in under three seconds. That is what separates a high-performing construction website from one that was last touched two years ago. Design matters, but it comes second. CMS architecture, portfolio structure, and mobile performance are the decisions that determine whether a construction website generates leads or just occupies a domain.</p>
<h2>Why Most Construction Website Roundups Are Useless</h2>
<p>Search &quot;best construction websites&quot; and you will get roundups featuring Turner Construction, Bechtel, and PCL — companies with dedicated marketing departments, six-figure design budgets, and global brand recognition. That is not useful context for a 20-person general contractor in the Midwest deciding what to build.</p>
<p>This article looks at construction websites that work for real businesses: companies between 10 and 50 employees that compete on referrals, regional reputation, and the quality of a portfolio page rather than brand equity alone. The examples here were selected for one reason — they make specific, defensible decisions that a similar-sized firm can learn from and replicate.</p>
<hr>
<h2>Section 1: What Makes a Construction Website &quot;The Best&quot;</h2>
<p>Before listing examples, it is worth defining the criteria. Without a framework, any list of websites is just a collection of opinions about aesthetics.</p>
<p><strong>A portfolio that stays current.</strong> This is the single most important technical decision in a construction website build. If the owner needs to call a developer to add a new project, the portfolio will fall behind — and a stale portfolio communicates to every visitor that the company is either slow, inactive, or indifferent. The right architecture gives the owner a CMS interface where they can add a project with photos, a title, a description, and a category in under ten minutes. That is a structural decision made at the build stage, not something that can be retrofitted easily.</p>
<p><strong>Mobile performance.</strong> Construction buyers frequently evaluate contractors on-site — meaning on a phone, with a mediocre data connection, between meetings or site walks. A website that loads slowly or displays a broken layout on mobile loses those evaluations silently. No one sends an email to explain why they left.</p>
<p><strong>A clear inquiry path.</strong> How many clicks does it take from the homepage to a submitted quote request? On strong construction websites, the answer is one or two. The contact form is not buried in a footer — it is accessible from the navigation and often from project pages directly.</p>
<p><strong>Specific service pages.</strong> &quot;We build great things&quot; is copy that applies to every construction company on the planet. The websites that win clients describe real capabilities: specific project types, typical project scales, sectors served, geographic coverage. A visitor should be able to read a services page and know within 60 seconds whether this company can do what they need.</p>
<p><strong>Real trust signals.</strong> Team photos, years in business, actual project photography — these convert better than stock imagery or generic credibility claims. A photo of the actual crew on an actual site communicates more trust per pixel than a stock photo of a hard hat on a clean desk.</p>
<p>If you want a full breakdown of the page structure that covers these elements, see <a href="/blog/what-should-construction-company-website-include">what a construction company website should include</a>.</p>
<hr>
<h2>Section 2: Real Examples — What They Do Well and Why It Works</h2>
<h3>Lankes.si — Content Ownership at the Foundation</h3>
<p>Lankes is a Slovenian construction company that came to me with a familiar problem: a website that looked fine but required developer involvement every time something changed. The owner wanted to add new projects, update service descriptions as their work evolved, and do both without filing a ticket or waiting for a callback.</p>
<p>The build used Next.js on the frontend for fast load times and <a href="/payload-cms-pricing">Payload CMS</a> on the backend for content management. The architecture decision was deliberate: Payload gives a non-technical user a clean admin interface where adding a new project means filling in a form — project name, location, scope, photo upload — and hitting publish. No developer. No delays. No portfolio that quietly goes stale over the following 18 months.</p>
<p>The result is a construction website where content ownership sits with the business, not with a vendor. The owner can document a project the week it&#39;s completed, which means the portfolio reflects current work rather than the projects that happened to get added during the last developer engagement. That freshness is visible to both visitors and search engines.</p>
<p>This is the kind of decision that determines the long-term value of a construction website. The frontend design can always be updated; if the CMS is wrong from the start, the portfolio will always be a point of friction.</p>
<hr>
<h3>Boneks — When the Website Connects to Operations</h3>
<p>Boneks is a construction-adjacent company built around a fundamentally different question: what happens when a website connects to operational tooling instead of just serving as marketing?</p>
<p>Most construction websites are passive — they present information and wait for someone to reach out. The Boneks approach wires the website into the actual business workflow. Quote requests flow into a structured pipeline. Project documentation is accessible through the site rather than scattered across email threads. The website becomes a tool that the business uses internally as much as clients use it externally.</p>
<p>This matters for construction firms because the inquiry-to-proposal process is where most of the friction lives. A visitor submits a contact form, it lands in a general inbox, someone follows up days later with a generic reply. The alternative is a site built with a clear operational model: inquiry capture with structured fields, automatic routing, and a documented follow-up path. The website is not separate from how the business works — it is part of it.</p>
<hr>
<h3>ARheko — Specificity as a Competitive Signal</h3>
<p>ARheko takes a similar framing to Boneks. The website architecture here prioritizes specificity at every level: services are described with the precision of a company that has done the work hundreds of times, project pages carry enough detail that a potential client can evaluate fit before picking up the phone, and the inquiry path is structured around the specific information needed to start a conversation.</p>
<p>What this communicates to a visitor is competence. A services page that uses industry-specific language — that describes real equipment, real processes, real project types — reads differently from a page that says &quot;we handle all aspects of construction with quality and professionalism.&quot; The latter describes every contractor who has ever existed. The former describes this one.</p>
<p>The structural lesson is that specificity is a design decision, not just a copywriting decision. It requires a CMS that lets the owner maintain detailed service descriptions over time as the business evolves, not one where updating a page involves a developer and a project scope.</p>
<hr>
<h3>Tuckman-Barbee Construction — 75 Years of Evidence, Not Claims</h3>
<p>Tuckman-Barbee is a Maryland general contractor founded in 1946. Their website at tuckman.com makes one decision brilliantly: it leads with proof, not assertions.</p>
<p>Where many construction websites open with generic taglines, Tuckman-Barbee opens with client names — the US Army Corps of Engineers, Johns Hopkins Health System, Georgetown University, the United States Naval Academy. These are not testimonial quotes in a scrolling carousel. They are listed as part of the site&#39;s core content, treated as evidence of capability rather than social proof afterthought.</p>
<p>The portfolio structure follows the same logic. Projects are documented with enough specificity that a visitor working in healthcare construction, for example, can immediately find relevant work. The navigation is clean and the mobile experience is functional — nothing revolutionary, but it doesn&#39;t need to be. The credentials do the work.</p>
<p>The lesson here is about where trust signals live on the page. Burying client names in a footer or a separate testimonials section means most visitors never see them. When those names appear in the primary content hierarchy, they function as qualification signals rather than decoration.</p>
<hr>
<h3>Greiner Construction — Sector Pages That Earn Organic Traffic</h3>
<p>Greiner is a commercial general contractor in Minneapolis. Their website at greinerconstruction.com demonstrates a specific SEO and conversion architecture that mid-market construction firms consistently underuse: dedicated pages for each market sector they serve.</p>
<p>Rather than a single services page listing everything Greiner can build, the site has individual pages for healthcare, multi-family, office/workplace, retail, and other sectors. Each page describes specific work within that sector, links to relevant completed projects, and gives a potential healthcare client — for example — enough context to determine fit without calling first.</p>
<p>This structure serves two functions simultaneously. For search, it creates targeted landing pages that can rank for terms like &quot;healthcare construction Minneapolis&quot; rather than competing for the generic &quot;construction company Minneapolis&quot; term against every firm in the market. For conversion, it removes the qualification burden from the phone call. A visitor who has already read the healthcare sector page and seen three relevant completed projects is a different kind of inquiry than one who found a generic homepage.</p>
<p>The decision to build sector pages is a content architecture decision made at the sitemap level. It requires a CMS where adding a sector page and linking it to relevant project portfolio entries is a natural workflow — not a custom development task.</p>
<hr>
<h2>Section 3: What the Best Construction Websites Have in Common</h2>
<p>Looking across these examples, several patterns appear consistently.</p>
<p><strong>The CMS matches the owner&#39;s workflow.</strong> Every construction company has a point of friction where the website falls behind reality. Projects get completed and don&#39;t get added. Services evolve and the page doesn&#39;t. The firms with strong websites have solved this structurally — the CMS makes content updates the path of least resistance, not a task that requires scheduling a developer.</p>
<p><strong>Portfolio pages carry real detail.</strong> Strong construction portfolio entries include project name, location, scope, scale, and actual photography. Weak ones are photo galleries with no labels. The detail serves two purposes: it helps visitors self-qualify, and it gives search engines something to index beyond image metadata.</p>
<p><strong>The inquiry path is visible from every page.</strong> On every strong construction website reviewed here, a visitor can get to a contact form from wherever they are on the site. Navigation includes a contact link. Project pages include a call-to-action. The friction between &quot;interested&quot; and &quot;submitted an inquiry&quot; is minimal.</p>
<p><strong>Trust signals are content, not decoration.</strong> Client names, years in business, team photos, and project photography appear in the main content hierarchy — not relegated to a sidebar, a footer, or a testimonials section that most visitors never scroll to.</p>
<p><strong>Mobile performance is treated as a primary constraint.</strong> Fast load times on mobile are not a bonus feature — they are the baseline. Construction buyers are often evaluating on phones, and a site that takes four seconds to load on a 4G connection loses those evaluations to a competitor whose site loads in one.</p>
<hr>
<h2>Section 4: What to Avoid</h2>
<p>The failure modes in construction websites tend to be consistent. These are the decisions that quietly cost leads.</p>
<p><strong>A portfolio gallery with no project details.</strong> A grid of photos without titles, descriptions, or project context looks abandoned. It also fails to communicate what type of work the company does, what scale they operate at, or who their past clients are. A visitor cannot self-qualify on photos alone.</p>
<p><strong>No CMS — the owner cannot update without calling a developer.</strong> This is the most common problem in construction websites built more than two years ago. The site looks fine initially, and then slowly becomes an inaccurate representation of the business as work evolves and no one updates the content.</p>
<p><strong>Slow load times from unoptimized project photos.</strong> Construction portfolios involve large, high-quality images. Without optimization — proper compression, modern image formats, lazy loading — a portfolio page with 20 project photos can easily take 8–10 seconds to load on mobile. That kills the experience for the visitors most likely to be evaluating on-site.</p>
<p><strong>A contact form buried in the footer.</strong> If the only path to an inquiry is through a footer link, many visitors will leave without contacting. The contact form should appear in the navigation, and construction websites with strong conversion rates typically include a CTA on project pages and the services page as well.</p>
<p><strong>Generic services copy.</strong> &quot;We deliver quality construction projects on time and on budget&quot; describes no company in particular. Services pages that describe specific project types, typical scales, materials, methods, and sectors attract qualified visitors and convert better because they demonstrate familiarity with the work — not just a willingness to do it.</p>
<hr>
<h2>FAQ</h2>
<h3>What makes a good construction company website?</h3>
<p>A good construction company website does three things well: it shows completed work in enough detail that visitors can evaluate fit, it describes services with enough specificity that generic contractors can be eliminated, and it makes it easy to request a quote or start a conversation. CMS architecture — meaning whether the owner can maintain the site without developer involvement — determines whether the website stays accurate over time.</p>
<h3>How do I choose a CMS for a construction company website?</h3>
<p>The right CMS for a construction website is one that the business owner can use without technical training. For custom-built sites, <a href="/payload-cms-pricing">Payload CMS</a> offers a clean admin interface that can be configured to match a construction company&#39;s exact content model — project types, service categories, team members. For simpler builds, WordPress with a well-configured theme works if the project budget doesn&#39;t support custom development. The key question is: can the owner add a new project and update a services page without calling a developer?</p>
<h3>How often should a construction company update its website portfolio?</h3>
<p>After every completed project, ideally within the same week. Portfolio recency signals to visitors that the business is active, and to search engines that the site is maintained. A construction website with no portfolio additions in 12 months reads as inactive regardless of how good the design is. The reason most portfolios fall behind is a structural one — the CMS makes updates difficult. Fix that first.</p>
<h3>What pages should a construction company website have?</h3>
<p>At minimum: a homepage, a services page or sector-specific service pages, a portfolio or projects section, an about page with team information, and a contact page. Construction companies serving multiple industries benefit from dedicated sector landing pages (healthcare construction, commercial construction, etc.) rather than a single services page. Each sector page can rank independently in search and helps visitors self-qualify before contacting.</p>
<h3>How much does a good construction website cost?</h3>
<p>A functional construction website built on a standard CMS can cost between €2,000 and €6,000 depending on the scope of content, number of pages, and level of custom design. A custom-built site on Next.js and Payload CMS — with a properly configured project portfolio, sector pages, and a content model the owner can maintain independently — typically runs €6,000 to €15,000. The right question isn&#39;t the upfront cost but the total cost over three years: a site that requires a developer for every content update will cost more over time than one built with content ownership in mind from the start.</p>
<hr>
<p>If you&#39;re evaluating web partners for a construction company website, our <a href="/industries/construction">construction website design services</a> page covers what we build and how we approach the work.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/best-construction-company-websites-2026</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/best-construction-company-websites-2026</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sat, 04 Apr 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;The best construction company websites share one characteristic above everything else: the owner can run them. A portfolio that gets updated after every completed project, service pages that reflect current capabilities, and a contact form that a visitor can find in under three seconds. That is what separates a high-performing construction website from one that was last touched two years ago. Design matters, but it comes second. CMS architecture, portfolio structure, and mobile performance are the decisions that determine whether a construction website generates leads or just occupies a domain.&lt;/p&gt;
&lt;h2&gt;Why Most Construction Website Roundups Are Useless&lt;/h2&gt;
&lt;p&gt;Search &amp;quot;best construction websites&amp;quot; and you will get roundups featuring Turner Construction, Bechtel, and PCL — companies with dedicated marketing departments, six-figure design budgets, and global brand recognition. That is not useful context for a 20-person general contractor in the Midwest deciding what to build.&lt;/p&gt;
&lt;p&gt;This article looks at construction websites that work for real businesses: companies between 10 and 50 employees that compete on referrals, regional reputation, and the quality of a portfolio page rather than brand equity alone. The examples here were selected for one reason — they make specific, defensible decisions that a similar-sized firm can learn from and replicate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Section 1: What Makes a Construction Website &amp;quot;The Best&amp;quot;&lt;/h2&gt;
&lt;p&gt;Before listing examples, it is worth defining the criteria. Without a framework, any list of websites is just a collection of opinions about aesthetics.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A portfolio that stays current.&lt;/strong&gt; This is the single most important technical decision in a construction website build. If the owner needs to call a developer to add a new project, the portfolio will fall behind — and a stale portfolio communicates to every visitor that the company is either slow, inactive, or indifferent. The right architecture gives the owner a CMS interface where they can add a project with photos, a title, a description, and a category in under ten minutes. That is a structural decision made at the build stage, not something that can be retrofitted easily.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mobile performance.&lt;/strong&gt; Construction buyers frequently evaluate contractors on-site — meaning on a phone, with a mediocre data connection, between meetings or site walks. A website that loads slowly or displays a broken layout on mobile loses those evaluations silently. No one sends an email to explain why they left.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A clear inquiry path.&lt;/strong&gt; How many clicks does it take from the homepage to a submitted quote request? On strong construction websites, the answer is one or two. The contact form is not buried in a footer — it is accessible from the navigation and often from project pages directly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Specific service pages.&lt;/strong&gt; &amp;quot;We build great things&amp;quot; is copy that applies to every construction company on the planet. The websites that win clients describe real capabilities: specific project types, typical project scales, sectors served, geographic coverage. A visitor should be able to read a services page and know within 60 seconds whether this company can do what they need.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real trust signals.&lt;/strong&gt; Team photos, years in business, actual project photography — these convert better than stock imagery or generic credibility claims. A photo of the actual crew on an actual site communicates more trust per pixel than a stock photo of a hard hat on a clean desk.&lt;/p&gt;
&lt;p&gt;If you want a full breakdown of the page structure that covers these elements, see &lt;a href=&quot;/blog/what-should-construction-company-website-include&quot;&gt;what a construction company website should include&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Section 2: Real Examples — What They Do Well and Why It Works&lt;/h2&gt;
&lt;h3&gt;Lankes.si — Content Ownership at the Foundation&lt;/h3&gt;
&lt;p&gt;Lankes is a Slovenian construction company that came to me with a familiar problem: a website that looked fine but required developer involvement every time something changed. The owner wanted to add new projects, update service descriptions as their work evolved, and do both without filing a ticket or waiting for a callback.&lt;/p&gt;
&lt;p&gt;The build used Next.js on the frontend for fast load times and &lt;a href=&quot;/payload-cms-pricing&quot;&gt;Payload CMS&lt;/a&gt; on the backend for content management. The architecture decision was deliberate: Payload gives a non-technical user a clean admin interface where adding a new project means filling in a form — project name, location, scope, photo upload — and hitting publish. No developer. No delays. No portfolio that quietly goes stale over the following 18 months.&lt;/p&gt;
&lt;p&gt;The result is a construction website where content ownership sits with the business, not with a vendor. The owner can document a project the week it&amp;#39;s completed, which means the portfolio reflects current work rather than the projects that happened to get added during the last developer engagement. That freshness is visible to both visitors and search engines.&lt;/p&gt;
&lt;p&gt;This is the kind of decision that determines the long-term value of a construction website. The frontend design can always be updated; if the CMS is wrong from the start, the portfolio will always be a point of friction.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Boneks — When the Website Connects to Operations&lt;/h3&gt;
&lt;p&gt;Boneks is a construction-adjacent company built around a fundamentally different question: what happens when a website connects to operational tooling instead of just serving as marketing?&lt;/p&gt;
&lt;p&gt;Most construction websites are passive — they present information and wait for someone to reach out. The Boneks approach wires the website into the actual business workflow. Quote requests flow into a structured pipeline. Project documentation is accessible through the site rather than scattered across email threads. The website becomes a tool that the business uses internally as much as clients use it externally.&lt;/p&gt;
&lt;p&gt;This matters for construction firms because the inquiry-to-proposal process is where most of the friction lives. A visitor submits a contact form, it lands in a general inbox, someone follows up days later with a generic reply. The alternative is a site built with a clear operational model: inquiry capture with structured fields, automatic routing, and a documented follow-up path. The website is not separate from how the business works — it is part of it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;ARheko — Specificity as a Competitive Signal&lt;/h3&gt;
&lt;p&gt;ARheko takes a similar framing to Boneks. The website architecture here prioritizes specificity at every level: services are described with the precision of a company that has done the work hundreds of times, project pages carry enough detail that a potential client can evaluate fit before picking up the phone, and the inquiry path is structured around the specific information needed to start a conversation.&lt;/p&gt;
&lt;p&gt;What this communicates to a visitor is competence. A services page that uses industry-specific language — that describes real equipment, real processes, real project types — reads differently from a page that says &amp;quot;we handle all aspects of construction with quality and professionalism.&amp;quot; The latter describes every contractor who has ever existed. The former describes this one.&lt;/p&gt;
&lt;p&gt;The structural lesson is that specificity is a design decision, not just a copywriting decision. It requires a CMS that lets the owner maintain detailed service descriptions over time as the business evolves, not one where updating a page involves a developer and a project scope.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Tuckman-Barbee Construction — 75 Years of Evidence, Not Claims&lt;/h3&gt;
&lt;p&gt;Tuckman-Barbee is a Maryland general contractor founded in 1946. Their website at tuckman.com makes one decision brilliantly: it leads with proof, not assertions.&lt;/p&gt;
&lt;p&gt;Where many construction websites open with generic taglines, Tuckman-Barbee opens with client names — the US Army Corps of Engineers, Johns Hopkins Health System, Georgetown University, the United States Naval Academy. These are not testimonial quotes in a scrolling carousel. They are listed as part of the site&amp;#39;s core content, treated as evidence of capability rather than social proof afterthought.&lt;/p&gt;
&lt;p&gt;The portfolio structure follows the same logic. Projects are documented with enough specificity that a visitor working in healthcare construction, for example, can immediately find relevant work. The navigation is clean and the mobile experience is functional — nothing revolutionary, but it doesn&amp;#39;t need to be. The credentials do the work.&lt;/p&gt;
&lt;p&gt;The lesson here is about where trust signals live on the page. Burying client names in a footer or a separate testimonials section means most visitors never see them. When those names appear in the primary content hierarchy, they function as qualification signals rather than decoration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Greiner Construction — Sector Pages That Earn Organic Traffic&lt;/h3&gt;
&lt;p&gt;Greiner is a commercial general contractor in Minneapolis. Their website at greinerconstruction.com demonstrates a specific SEO and conversion architecture that mid-market construction firms consistently underuse: dedicated pages for each market sector they serve.&lt;/p&gt;
&lt;p&gt;Rather than a single services page listing everything Greiner can build, the site has individual pages for healthcare, multi-family, office/workplace, retail, and other sectors. Each page describes specific work within that sector, links to relevant completed projects, and gives a potential healthcare client — for example — enough context to determine fit without calling first.&lt;/p&gt;
&lt;p&gt;This structure serves two functions simultaneously. For search, it creates targeted landing pages that can rank for terms like &amp;quot;healthcare construction Minneapolis&amp;quot; rather than competing for the generic &amp;quot;construction company Minneapolis&amp;quot; term against every firm in the market. For conversion, it removes the qualification burden from the phone call. A visitor who has already read the healthcare sector page and seen three relevant completed projects is a different kind of inquiry than one who found a generic homepage.&lt;/p&gt;
&lt;p&gt;The decision to build sector pages is a content architecture decision made at the sitemap level. It requires a CMS where adding a sector page and linking it to relevant project portfolio entries is a natural workflow — not a custom development task.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Section 3: What the Best Construction Websites Have in Common&lt;/h2&gt;
&lt;p&gt;Looking across these examples, several patterns appear consistently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The CMS matches the owner&amp;#39;s workflow.&lt;/strong&gt; Every construction company has a point of friction where the website falls behind reality. Projects get completed and don&amp;#39;t get added. Services evolve and the page doesn&amp;#39;t. The firms with strong websites have solved this structurally — the CMS makes content updates the path of least resistance, not a task that requires scheduling a developer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Portfolio pages carry real detail.&lt;/strong&gt; Strong construction portfolio entries include project name, location, scope, scale, and actual photography. Weak ones are photo galleries with no labels. The detail serves two purposes: it helps visitors self-qualify, and it gives search engines something to index beyond image metadata.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The inquiry path is visible from every page.&lt;/strong&gt; On every strong construction website reviewed here, a visitor can get to a contact form from wherever they are on the site. Navigation includes a contact link. Project pages include a call-to-action. The friction between &amp;quot;interested&amp;quot; and &amp;quot;submitted an inquiry&amp;quot; is minimal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Trust signals are content, not decoration.&lt;/strong&gt; Client names, years in business, team photos, and project photography appear in the main content hierarchy — not relegated to a sidebar, a footer, or a testimonials section that most visitors never scroll to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mobile performance is treated as a primary constraint.&lt;/strong&gt; Fast load times on mobile are not a bonus feature — they are the baseline. Construction buyers are often evaluating on phones, and a site that takes four seconds to load on a 4G connection loses those evaluations to a competitor whose site loads in one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Section 4: What to Avoid&lt;/h2&gt;
&lt;p&gt;The failure modes in construction websites tend to be consistent. These are the decisions that quietly cost leads.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A portfolio gallery with no project details.&lt;/strong&gt; A grid of photos without titles, descriptions, or project context looks abandoned. It also fails to communicate what type of work the company does, what scale they operate at, or who their past clients are. A visitor cannot self-qualify on photos alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No CMS — the owner cannot update without calling a developer.&lt;/strong&gt; This is the most common problem in construction websites built more than two years ago. The site looks fine initially, and then slowly becomes an inaccurate representation of the business as work evolves and no one updates the content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Slow load times from unoptimized project photos.&lt;/strong&gt; Construction portfolios involve large, high-quality images. Without optimization — proper compression, modern image formats, lazy loading — a portfolio page with 20 project photos can easily take 8–10 seconds to load on mobile. That kills the experience for the visitors most likely to be evaluating on-site.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A contact form buried in the footer.&lt;/strong&gt; If the only path to an inquiry is through a footer link, many visitors will leave without contacting. The contact form should appear in the navigation, and construction websites with strong conversion rates typically include a CTA on project pages and the services page as well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generic services copy.&lt;/strong&gt; &amp;quot;We deliver quality construction projects on time and on budget&amp;quot; describes no company in particular. Services pages that describe specific project types, typical scales, materials, methods, and sectors attract qualified visitors and convert better because they demonstrate familiarity with the work — not just a willingness to do it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;What makes a good construction company website?&lt;/h3&gt;
&lt;p&gt;A good construction company website does three things well: it shows completed work in enough detail that visitors can evaluate fit, it describes services with enough specificity that generic contractors can be eliminated, and it makes it easy to request a quote or start a conversation. CMS architecture — meaning whether the owner can maintain the site without developer involvement — determines whether the website stays accurate over time.&lt;/p&gt;
&lt;h3&gt;How do I choose a CMS for a construction company website?&lt;/h3&gt;
&lt;p&gt;The right CMS for a construction website is one that the business owner can use without technical training. For custom-built sites, &lt;a href=&quot;/payload-cms-pricing&quot;&gt;Payload CMS&lt;/a&gt; offers a clean admin interface that can be configured to match a construction company&amp;#39;s exact content model — project types, service categories, team members. For simpler builds, WordPress with a well-configured theme works if the project budget doesn&amp;#39;t support custom development. The key question is: can the owner add a new project and update a services page without calling a developer?&lt;/p&gt;
&lt;h3&gt;How often should a construction company update its website portfolio?&lt;/h3&gt;
&lt;p&gt;After every completed project, ideally within the same week. Portfolio recency signals to visitors that the business is active, and to search engines that the site is maintained. A construction website with no portfolio additions in 12 months reads as inactive regardless of how good the design is. The reason most portfolios fall behind is a structural one — the CMS makes updates difficult. Fix that first.&lt;/p&gt;
&lt;h3&gt;What pages should a construction company website have?&lt;/h3&gt;
&lt;p&gt;At minimum: a homepage, a services page or sector-specific service pages, a portfolio or projects section, an about page with team information, and a contact page. Construction companies serving multiple industries benefit from dedicated sector landing pages (healthcare construction, commercial construction, etc.) rather than a single services page. Each sector page can rank independently in search and helps visitors self-qualify before contacting.&lt;/p&gt;
&lt;h3&gt;How much does a good construction website cost?&lt;/h3&gt;
&lt;p&gt;A functional construction website built on a standard CMS can cost between €2,000 and €6,000 depending on the scope of content, number of pages, and level of custom design. A custom-built site on Next.js and Payload CMS — with a properly configured project portfolio, sector pages, and a content model the owner can maintain independently — typically runs €6,000 to €15,000. The right question isn&amp;#39;t the upfront cost but the total cost over three years: a site that requires a developer for every content update will cost more over time than one built with content ownership in mind from the start.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you&amp;#39;re evaluating web partners for a construction company website, our &lt;a href=&quot;/industries/construction&quot;&gt;construction website design services&lt;/a&gt; page covers what we build and how we approach the work.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/best-construction-company-websites-2026"/>
        </item>
    </channel>
</rss>