<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mahdi JabinPour</title>
    <description>The latest articles on DEV Community by Mahdi JabinPour (@jabinpou).</description>
    <link>https://dev.to/jabinpou</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3885364%2Fdbdf3579-ee19-4c69-9087-62aeb3e3b6dc.jpg</url>
      <title>DEV Community: Mahdi JabinPour</title>
      <link>https://dev.to/jabinpou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jabinpou"/>
    <language>en</language>
    <item>
      <title>WordPress Media Offloading to FTP: 4 Failure Modes to Fix</title>
      <dc:creator>Mahdi JabinPour</dc:creator>
      <pubDate>Sun, 30 Aug 2026 22:33:19 +0000</pubDate>
      <link>https://dev.to/jabinpou/offload-the-wordpress-media-library-to-ftp-and-what-breaks-when-you-do-it-by-hand-521a</link>
      <guid>https://dev.to/jabinpou/offload-the-wordpress-media-library-to-ftp-and-what-breaks-when-you-do-it-by-hand-521a</guid>
      <description>&lt;p&gt;Moving WordPress media to external storage takes more than copying the uploads folder. You also need working attachment URLs, generated image sizes, responsive images and a migration plan for the existing library.&lt;/p&gt;

&lt;p&gt;This guide walks through four failure modes in a minimal FTP offload implementation, then explains where storage savings end and CDN performance begins.&lt;/p&gt;

&lt;p&gt;I found this problem the way most people find it: on an invoice.&lt;/p&gt;

&lt;p&gt;A client site; nothing exotic, a WooCommerce store with a blog bolted on; had crept past its disk quota, and the host's answer was the next plan up. Double the money, for storage we only needed because nobody had ever deleted a product photo. I opened &lt;code&gt;wp-content/uploads&lt;/code&gt; over FTP and found 60-something gigabytes of images. Roughly a third of it was generated thumbnail sizes for images nobody had linked to since 2021.&lt;/p&gt;

&lt;p&gt;That was the annoying part. The part that actually mattered was quieter: all of it was sitting on the same disk, behind the same connection limit, as the store.&lt;/p&gt;

&lt;p&gt;One thing up front. We build WordPress plugins, and this article ends with one of ours. We are not affiliated with Automattic or WordPress.org. Everything below is what we hit on our own client work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your uploads folder is the most expensive disk you will ever rent
&lt;/h2&gt;

&lt;p&gt;Managed WordPress hosting prices every gigabyte as if it were database. It is not. Most of what you are paying premium NVMe rates for is JPEGs that never change.&lt;/p&gt;

&lt;p&gt;An FTP or object-storage box does one job; hand back a file; and it is priced accordingly. Same gigabyte, a fraction of the cost. That alone is usually the whole business case.&lt;/p&gt;

&lt;p&gt;But the money is the boring half. On a shared or entry-level VPS host, &lt;code&gt;uploads&lt;/code&gt; costs you in ways that do not show up on the invoice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backups.&lt;/strong&gt; A 60 GB uploads folder means every nightly backup drags 60 GB of files that have not changed since last year. Some hosts silently skip large folders, which is worse; you think you have backups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I/O and inodes.&lt;/strong&gt; Hundreds of thousands of small files compete for the same disk queue your database is using, and inode limits are a real ceiling on cheap cPanel plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connections.&lt;/strong&gt; Static files do not run PHP, but they do occupy the same origin, the same connection pool, and the same bandwidth allowance as the requests that do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Moving media off the origin is not a speed trick. It is taking a job away from a server that was never good at it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.lumowp.com%2F2026%2F08%2FWordPress-has-no-concept-of-media-living-somewhere-else.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.lumowp.com%2F2026%2F08%2FWordPress-has-no-concept-of-media-living-somewhere-else.webp" alt="Media files moved to FTP while WordPress still generates local URLs, causing 404 errors" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  WordPress has no concept of media living somewhere else
&lt;/h2&gt;

&lt;p&gt;Here is the thing people get wrong, and I got wrong for a while too.&lt;/p&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; upload files to &lt;code&gt;wp-content/uploads&lt;/code&gt; over FTP. Nothing stops you. What you cannot do is make WordPress care. A file with no attachment post is not in the Media Library, has no alt text, generates no &lt;code&gt;srcset&lt;/code&gt;, and cannot be picked in Gutenberg or Elementor. It is just bytes on a disk that WordPress will never mention.&lt;/p&gt;

&lt;p&gt;And the reverse is just as stuck. Once a file &lt;em&gt;is&lt;/em&gt; registered, its URL is not stored as a URL; it is assembled at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp_get_attachment_url() ends up here&lt;/span&gt;
&lt;span class="nv"&gt;$uploads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_upload_dir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$uploads&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'baseurl'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// baseurl derives from your site URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So even if you move every byte to &lt;code&gt;cdn.example.com&lt;/code&gt;, WordPress keeps printing your own domain in front of the filename. The files are gone. The links still point home. Every image on the site 404s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offloading media is two separate problems; moving the bytes, and rewriting the URLs; and doing only the first one takes your site down.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The version you can write yourself
&lt;/h2&gt;

&lt;p&gt;You should see the code, because it explains why the code is not enough.&lt;/p&gt;

&lt;p&gt;Uploading on the way in is a &lt;code&gt;wp_handle_upload&lt;/code&gt; filter and PHP's &lt;a href="https://www.php.net/manual/en/book.ftp.php" rel="noopener noreferrer"&gt;FTP extension&lt;/a&gt; (check &lt;code&gt;ext-ftp&lt;/code&gt; is actually compiled in; on plenty of managed hosts it is not):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_handle_upload'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$upload&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;ftp_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ftp.example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$conn&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;ftp_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FTP_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FTP_PASS&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fail open: keep it local&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nb"&gt;ftp_pasv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$relative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_get_upload_dir&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s1"&gt;'basedir'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. 2026/08/photo.jpg&lt;/span&gt;

    &lt;span class="nb"&gt;ftp_put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/media/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$relative&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="no"&gt;FTP_BINARY&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;ftp_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$conn&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rewriting on the way out is a second filter, documented under &lt;a href="https://developer.wordpress.org/reference/hooks/wp_get_attachment_url/" rel="noopener noreferrer"&gt;&lt;code&gt;wp_get_attachment_url&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_get_attachment_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;wp_get_upload_dir&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s1"&gt;'baseurl'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'https://cdn.example.com/media'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$url&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty minutes, and it works. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ftp_put()&lt;/code&gt; runs inside the visitor's request.&lt;/strong&gt; The person uploading waits for your FTP server. If it is slow, the admin is slow. If it times out, the upload half-exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One image is not one file.&lt;/strong&gt; WordPress generates every registered size, and &lt;code&gt;wp_handle_upload&lt;/code&gt; fires before they exist. You need &lt;code&gt;wp_generate_attachment_metadata&lt;/code&gt; too, and you need to walk the whole &lt;code&gt;sizes&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wp_get_attachment_url&lt;/code&gt; is not the only place a URL appears.&lt;/strong&gt; Responsive images come from &lt;code&gt;wp_calculate_image_srcset&lt;/code&gt;. Page builders write absolute URLs straight into &lt;code&gt;post_content&lt;/code&gt;. Miss those and half your images are on the CDN and half are 404s; the worst possible state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nothing above touches the 40,000 files already in your library.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of those is fixable. Together they are not a snippet in &lt;code&gt;functions.php&lt;/code&gt; any more. They are a plugin with a queue, a retry policy, and a log.&lt;/p&gt;

&lt;h2&gt;
  
  
  An FTP box is not a CDN, and "Google prefers FTP" is not a thing
&lt;/h2&gt;

&lt;p&gt;I want to be straight about this, because the offload-media pitch usually gets it wrong.&lt;/p&gt;

&lt;p&gt;Google does not know or care what protocol you used to put a file on a server. There is no ranking signal for FTP. What Google measures is what the visitor experiences, and &lt;a href="https://web.dev/articles/lcp/" rel="noopener noreferrer"&gt;Largest Contentful Paint&lt;/a&gt;; usually your hero image or first product photo; is the metric that offloading actually moves. It moves because the image is no longer queued behind your PHP requests, not because it travelled by FTP.&lt;/p&gt;

&lt;p&gt;And the second correction: &lt;strong&gt;a single FTP box is storage, not a CDN.&lt;/strong&gt; One machine, one location. If it sits in Frankfurt, a visitor in São Paulo gets Frankfurt latency, and you may have made their LCP &lt;em&gt;worse&lt;/em&gt; than serving from your own origin. A CDN means edge nodes with anycast routing.&lt;/p&gt;

&lt;p&gt;The useful shape is: &lt;strong&gt;storage that is cheap, with a CDN in front of it.&lt;/strong&gt; Point &lt;code&gt;cdn.example.com&lt;/code&gt; at the storage box, put Cloudflare or Bunny on that hostname, and now the offload is genuinely a performance win instead of a hosting-cost win with a latency tax attached.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.lumowp.com%2F2026%2F08%2FAn-FTP-box-is-not-a-CDN.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.lumowp.com%2F2026%2F08%2FAn-FTP-box-is-not-a-CDN.webp" alt="Media files moved to FTP while WordPress still generates local URLs, causing 404 errors" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ended up building
&lt;/h2&gt;

&lt;p&gt;We rebuilt three-quarters of the above on two client projects before turning it into a product. It is called &lt;strong&gt;Lumo FTP Media Library&lt;/strong&gt;, and the setup is deliberately dull:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter the FTP host or IP, username, password, and port, then hit &lt;strong&gt;test connection&lt;/strong&gt;. It fails loudly and specifically; wrong port, passive mode, bad path; instead of silently keeping files local.&lt;/li&gt;
&lt;li&gt;Set the remote root folder and the &lt;strong&gt;CDN Base URL&lt;/strong&gt; (&lt;code&gt;https://cdn.example.com/media&lt;/code&gt;). WordPress keeps building the &lt;code&gt;2026/08/&lt;/code&gt; structure underneath.&lt;/li&gt;
&lt;li&gt;Turn on URL rewriting. New uploads go straight out and come back through the CDN hostname.&lt;/li&gt;
&lt;li&gt;For the existing library, click &lt;strong&gt;Offload library&lt;/strong&gt;. It queues every file and uploads in small background batches you can pause, resume, or stop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you would rather migrate the bulk yourself; zip &lt;code&gt;wp-content/uploads&lt;/code&gt;, drop it on the FTP, set the same path in settings; you can, and the plugin verifies the files are reachable before it rewrites anything.&lt;/p&gt;

&lt;p&gt;Once you have confirmed images are loading from the CDN hostname, you can switch from &lt;strong&gt;Copy to FTP and keep local&lt;/strong&gt; to &lt;strong&gt;Move to FTP and delete local&lt;/strong&gt;, and finally reclaim the disk. It speaks FTP, SFTP, and S3-compatible endpoints, so BunnyCDN Storage, Wasabi, Backblaze B2 and a plain vsftpd box are all the same to it. It also handles WooCommerce product, variation and gallery images, which is where a lot of generic offload setups quietly break.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.lumowp.com%2F2026%2F08%2Flumo-wp-ftp-mdeia.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fcdn.lumowp.com%2F2026%2F08%2Flumo-wp-ftp-mdeia.webp" alt="Media files moved to FTP while WordPress still generates local URLs, causing 404 errors" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats; when you should not buy this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If your uploads folder is 2 GB, do nothing.&lt;/strong&gt; You will not feel this. Compress your images, turn off two or three image sizes you never use, and go build something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are already all-in on AWS&lt;/strong&gt;, use the S3-specific tooling. It is built around one provider's API and does that one thing very well. This plugin exists for people whose storage is an FTP or SFTP account, which is what most non-AWS hosting actually hands you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are comfortable in PHP and only have new uploads to worry about&lt;/strong&gt;; a fresh site, no legacy library; the two filters above genuinely may be enough. Take them. Just know you are also signing up for the &lt;code&gt;srcset&lt;/code&gt; case and the day the FTP server is down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And do not expect offloading to fix a slow site on its own.&lt;/strong&gt; If your Time to First Byte is two seconds because of an unindexed query or 40 active plugins, moving images changes nothing a visitor will notice. &lt;strong&gt;Offloading media fixes a media problem. It is not a performance strategy.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If this is your situation
&lt;/h2&gt;

&lt;p&gt;Where this earns its place is the exact case I opened with: a library that has outgrown the plan it lives on, thousands of existing URLs you cannot afford to break, and no appetite for owning a queue-and-retry system.&lt;/p&gt;

&lt;p&gt;The plugin is &lt;a href="https://lumowp.com/product/lumo-ftp-media-library-plugin-offload-wordpress-media-library-to-external-ftp/" rel="noopener noreferrer"&gt;Lumo FTP Media Library&lt;/a&gt;. Before you read another feature list; including this one; watch the demo on the product page. The connection test and the bulk-offload progress screen are the two things worth judging, and thirty seconds of video tells you more about whether this fits your setup than I can.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover illustration generated with AI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>WordPress User Sync: IDs, Roles, Passwords and SSO Explained</title>
      <dc:creator>Mahdi JabinPour</dc:creator>
      <pubDate>Sat, 29 Aug 2026 08:45:32 +0000</pubDate>
      <link>https://dev.to/jabinpou/sync-users-between-two-wordpress-sites-what-actually-breaks-4eod</link>
      <guid>https://dev.to/jabinpou/sync-users-between-two-wordpress-sites-what-actually-breaks-4eod</guid>
      <description>&lt;p&gt;A customer account can exist on two WordPress sites and still fail to work as one account. User IDs differ, roles use site-specific metadata, and copying credentials does not create a shared login session.&lt;/p&gt;

&lt;p&gt;This guide breaks down those boundaries, plus WooCommerce address sync and background retries, so you can plan the integration before writing the first REST endpoint.&lt;/p&gt;

&lt;p&gt;This started with a database that was getting fat.&lt;/p&gt;

&lt;p&gt;We had a store. One WordPress install doing everything: the catalog, the marketing pages, the customers, the orders. And if you have run a WooCommerce site for more than a year you know exactly where this goes. &lt;code&gt;wp_usermeta&lt;/code&gt; grows. Order meta grows. Sessions, carts, coupon usage, abandoned checkout junk from whatever plugin you installed and forgot about. The product side of the site; the part that has to be fast, the part Google actually crawls; was sharing tables with the part that processes orders.&lt;/p&gt;

&lt;p&gt;So I made a call: split it. Products and content on the main domain, the transactional side on its own install, on a subdomain like &lt;code&gt;shop.domain.com&lt;/code&gt;. Separate database, separate load, separate blast radius when something goes wrong.&lt;/p&gt;

&lt;p&gt;Splitting the store was the easy part. It took an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The users were the problem.&lt;/strong&gt; Because the moment you have two installs, you have two &lt;code&gt;wp_users&lt;/code&gt; tables, two sets of IDs, two password hashes, and a customer who created an account on one site and is now staring at a login form on the other one wondering why their password doesn't work.&lt;/p&gt;

&lt;p&gt;Quick disclosure before I go further: I work on plugins at LumoWP, and one of them solves this. It shows up near the end of this post because that is where it belongs. We are not affiliated with Automattic or WooCommerce; everything below is our own experience running this setup, not an official position from anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing that breaks: the same person is two different users
&lt;/h2&gt;

&lt;p&gt;Your instinct is that this is a small job. Hook &lt;code&gt;user_register&lt;/code&gt;, POST the user to the other site, done. Something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'user_register'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_userdata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'https://shop.example.com/wp-json/mysync/v1/user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'login'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'pass'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_pass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// already a hash, do not re-hash it&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is about forty minutes of work and it looks like it works. Register on site A, the user appears on site B. Ship it.&lt;/p&gt;

&lt;p&gt;Then you notice the first crack. &lt;code&gt;$user_id&lt;/code&gt; on site A is 4102. On site B, &lt;code&gt;wp_insert_user()&lt;/code&gt; gave that same human being ID 87. Nothing in WordPress says those two rows are the same person. So every subsequent update; the customer changes their email, you change their role, they update their shipping address; needs a mapping table you now have to build and maintain yourself, keyed on something stable. Email is the obvious candidate until someone changes their email.&lt;/p&gt;

&lt;p&gt;The second crack is that hash. You are moving a password hash over the wire. If that endpoint is not authenticated with a real shared key and running over TLS, you have built a credential leak with a REST route in front of it. This is the point where "small job" stops being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copying &lt;code&gt;usermeta&lt;/code&gt; does not copy the role
&lt;/h2&gt;

&lt;p&gt;This one cost me an actual evening, and it is my favourite piece of WordPress trivia now.&lt;/p&gt;

&lt;p&gt;A user's role is not a column. It is a row in &lt;code&gt;wp_usermeta&lt;/code&gt;, and the meta key is &lt;strong&gt;prefixed with the table prefix of the site it belongs to&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;meta_key&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_usermeta&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4102&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;meta_key&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%capabilities'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- wp_capabilities      ← main site, prefix "wp_"&lt;/span&gt;
&lt;span class="c1"&gt;-- shop_capabilities    ← store install, prefix "shop_"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In code it is &lt;code&gt;$wpdb-&amp;gt;prefix . 'capabilities'&lt;/code&gt;, which is why &lt;a href="https://developer.wordpress.org/plugins/users/roles-and-capabilities/" rel="noopener noreferrer"&gt;the roles and capabilities API&lt;/a&gt; never makes you think about it; right up until you sync two installs with different prefixes.&lt;/p&gt;

&lt;p&gt;So if you take the honest, obvious approach and copy every &lt;code&gt;usermeta&lt;/code&gt; row across, the user arrives on the second site with a &lt;code&gt;wp_capabilities&lt;/code&gt; row that the second site does not read, and therefore &lt;strong&gt;no role at all&lt;/strong&gt;. They can log in. They can see nothing. Same for &lt;code&gt;wp_user_level&lt;/code&gt;. Same for &lt;code&gt;wp_dashboard_quick_press_last_post_id&lt;/code&gt; and every other prefixed key you just dragged along for no reason.&lt;/p&gt;

&lt;p&gt;Roles have to be &lt;strong&gt;mapped, not copied&lt;/strong&gt;. And once you accept that, you realize you actually want mapping anyway: a &lt;code&gt;shop_manager&lt;/code&gt; on the store should probably be a plain &lt;code&gt;subscriber&lt;/code&gt; on the marketing site. Blindly mirroring roles across installs is how an editor on one site quietly becomes an editor on a site they were never meant to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  One login, or the customer gives up
&lt;/h2&gt;

&lt;p&gt;Syncing accounts gets you identical credentials on both sites. It does not get you a session.&lt;/p&gt;

&lt;p&gt;You have seen how this is supposed to feel. You log into Gmail and you are logged into every Google property. Nobody asks you again. That is the bar now, and if your customer has to type their password a second time to move from your catalog to your checkout, a percentage of them just leave.&lt;/p&gt;

&lt;p&gt;The tempting fix, if both sites are on the same root domain, is cookie sharing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-config.php, identical on both installs&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'AUTH_KEY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="s1"&gt;'...same...'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'LOGGED_IN_KEY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'...same...'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'LOGGED_IN_SALT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'...same...'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'COOKIE_DOMAIN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'.example.com'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is why that is not enough. The WordPress logged-in cookie is roughly &lt;code&gt;username|expiration|token|hmac&lt;/code&gt;, and when a request comes in, the site validates that &lt;code&gt;token&lt;/code&gt; against &lt;strong&gt;a session token stored in that install's own &lt;code&gt;usermeta&lt;/code&gt;&lt;/strong&gt;. Site B has never issued that session. It has no matching token. The cookie is presented, checked, and rejected.&lt;/p&gt;

&lt;p&gt;And that is the optimistic case. If your two sites are on genuinely different domains rather than subdomains, the browser will not even send the cookie. There is nothing to reject.&lt;/p&gt;

&lt;p&gt;Real cross-site login is a &lt;strong&gt;token handoff&lt;/strong&gt;: site A signs a short-lived, single-use token, redirects the user to site B with it, site B verifies the signature against the shared key, matches the identity to its own local user, and calls &lt;code&gt;wp_set_auth_cookie()&lt;/code&gt; for that local user ID. That is SSO. It is a protocol, not a config constant, and building it correctly; nonce, expiry, replay protection, clean failure when the user does not exist yet; is a real project.&lt;/p&gt;

&lt;h2&gt;
  
  
  WooCommerce addresses live in &lt;code&gt;usermeta&lt;/code&gt; and they drift
&lt;/h2&gt;

&lt;p&gt;Assume you got all of the above working. Accounts match, roles map, login carries over. You are still not done, because a WooCommerce customer is not just a login.&lt;/p&gt;

&lt;p&gt;The saved billing and shipping address a returning customer expects to see pre-filled at checkout is stored as flat user meta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$customer_fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'billing_first_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'billing_last_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'billing_company'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'billing_address_1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'billing_address_2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'billing_city'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'billing_state'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'billing_postcode'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'billing_country'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'billing_phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'billing_email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'shipping_first_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipping_last_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipping_address_1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...and the rest of the shipping set&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HPOS moved &lt;em&gt;orders&lt;/em&gt; into dedicated tables, but the customer's saved address did not move with them; it is still meta hanging off the user.&lt;/p&gt;

&lt;p&gt;Which means if you only sync the account, your customer registers on the main site, gets bounced to the store to check out, and finds an empty address form. They fill it in there. Now the two sites disagree about where this person lives, and the next time they update it, they update one of them. &lt;strong&gt;The version you email an invoice from is a coin flip.&lt;/strong&gt; This is the failure nobody catches in testing, because in testing you have three users and you made all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sync that fires on registration is the sync that times out
&lt;/h2&gt;

&lt;p&gt;Last one, and it is the one that turns a working prototype into a 3 a.m. problem.&lt;/p&gt;

&lt;p&gt;Every hook I have shown you fires &lt;strong&gt;inside the user's request&lt;/strong&gt;. &lt;code&gt;user_register&lt;/code&gt; runs while a real person is waiting on a spinner. So does &lt;code&gt;profile_update&lt;/code&gt;. If you fire a blocking &lt;code&gt;wp_remote_post()&lt;/code&gt; there and the other site is slow, your registration is slow. If the other site is down, your registration hangs until the HTTP timeout; and then the user is created locally and never syncs, silently, forever.&lt;/p&gt;

&lt;p&gt;The correct shape is obvious once you have been burned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'profile_update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_schedule_single_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'lumo_push_user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except &lt;code&gt;wp_cron&lt;/code&gt; only runs when someone visits the site, so on a low-traffic install your "immediate" sync can sit there for an hour. And a queue without &lt;strong&gt;retry with backoff&lt;/strong&gt; is just a slower way to lose the same event. And a retry system without a &lt;strong&gt;log&lt;/strong&gt; means you cannot answer the only question that matters when a customer complains: did this user actually sync, when, and what came back?&lt;/p&gt;

&lt;p&gt;So the real list is: a queue, a scheduler you trust more than default cron, exponential backoff, a dead-letter case, a log you can read, and a way to force a full re-push when you inevitably discover a batch that never made it. That is not a snippet in &lt;code&gt;functions.php&lt;/code&gt;. That is a plugin.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F984tp17ypfz62e49x1ev.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F984tp17ypfz62e49x1ev.webp" alt="LumoWP user synchronization workflow" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ended up building
&lt;/h2&gt;

&lt;p&gt;We wrote it as a product, because we had rebuilt three-quarters of it on two separate client projects already. It is called &lt;strong&gt;Lumo User Sync&lt;/strong&gt;, and the architecture is deliberately boring: &lt;strong&gt;one master site, as many sub-sites as your setup needs.&lt;/strong&gt; Master is the source of truth. You connect a sub to the master with a generated key, and user data moves over the REST API, encrypted.&lt;/p&gt;

&lt;p&gt;What it does, concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Master-to-sub real-time sync&lt;/strong&gt; for user accounts; creation, profile changes, password changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role mapping per connection&lt;/strong&gt;, so &lt;code&gt;shop_manager&lt;/code&gt; on one site can land as &lt;code&gt;subscriber&lt;/code&gt; on another. Per connection, not one global rule, because the store and the community site rarely want the same mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSO across every connected site.&lt;/strong&gt; One login, and the session follows the user across domains and subdomains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce customer sync&lt;/strong&gt;; name, company, address, city, state, postal code, country, phone and billing email, so checkout is pre-filled wherever the customer lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A background queue with automatic retry&lt;/strong&gt;, instead of blocking the registration request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detailed sync logs&lt;/strong&gt;, so "did this user sync?" is a question with an answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk push from the master&lt;/strong&gt; for the day you connect a new sub-site and need to backfill twelve thousand existing accounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not care whether your sites are on one domain, several subdomains, or completely unrelated domains. That was the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats; who should not buy this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you are on real WordPress Multisite, you probably do not need it.&lt;/strong&gt; &lt;a href="https://developer.wordpress.org/advanced-administration/multisite/create-network/" rel="noopener noreferrer"&gt;Multisite&lt;/a&gt; already shares one &lt;code&gt;wp_users&lt;/code&gt; table across the whole network. Your users are, by definition, already synced. Our plugin is for people who deliberately run &lt;em&gt;separate installs&lt;/em&gt; with &lt;em&gt;separate databases&lt;/em&gt;; which is the whole reason I split that store in the first place, but it is not everyone's situation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you have exactly two sites and neither of them is WooCommerce&lt;/strong&gt;, a custom REST endpoint and a couple of hooks might genuinely be enough. It is a weekend of work and you will own it. Just go in knowing about the prefix trap and the session token thing, because those are the two that will eat your weekend twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your requirement is enterprise identity&lt;/strong&gt;; logging WordPress in against Okta, Azure AD, or any SAML/OIDC identity provider; this is the wrong tool. We do WordPress-to-WordPress. Go get a dedicated SAML plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And if your database is not actually hurting&lt;/strong&gt;, do not split the site at all. Splitting a store across two installs is a real architectural decision with real ongoing cost, and syncing users is only the first bill. One well-indexed install with a cleanup routine beats two installs held together with a sync layer, every time. Split when the pain is measurable, not because it sounds cleaner.&lt;/p&gt;

&lt;p&gt;Where it earns its place is the setup I described at the top: separate installs on purpose, shared customers, and a login experience that has to feel like one company instead of three.&lt;/p&gt;

&lt;p&gt;If that is your situation, the plugin is &lt;a href="https://lumowp.com/product/lumo-user-sync-wordpress-sso-and-user-sync-plugin-for-multi-site-networks/" rel="noopener noreferrer"&gt;Lumo User Sync&lt;/a&gt;. Watch the demo video on the product page before anything else; the master/sub connection flow is much easier to judge in ninety seconds of video than in any amount of feature list, including the one I just wrote.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you have run a split WordPress setup and hit something I did not list here, I want to hear it in the comments. Half the retry logic in this plugin exists because of a failure mode we did not predict.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover illustration generated with AI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>woocommerce</category>
    </item>
    <item>
      <title>LearnDash + WooCommerce: 5 Checks Before Selling Courses</title>
      <dc:creator>Mahdi JabinPour</dc:creator>
      <pubDate>Sat, 29 Aug 2026 08:45:14 +0000</pubDate>
      <link>https://dev.to/jabinpou/selling-learndash-courses-through-woocommerce-the-gaps-nobody-warns-you-about-b87</link>
      <guid>https://dev.to/jabinpou/selling-learndash-courses-through-woocommerce-the-gaps-nobody-warns-you-about-b87</guid>
      <description>&lt;p&gt;Selling a course is only the start. Can the course card show the right price? Does a refund remove access? What happens when the last seat sells?&lt;/p&gt;

&lt;p&gt;Here are five areas to check when combining LearnDash, WooCommerce and Elementor, with examples of how our LumoWP add-on approaches them. Features vary with your installed versions and extensions, so test the full purchase and refund flow on your own setup.&lt;/p&gt;

&lt;p&gt;If you have built more than two or three e-learning sites on WordPress, you already know the pattern. The client wants a course site. You reach for LearnDash because it is the mature, reliable choice for the actual learning part; courses, lessons, topics, quizzes, progress, certificates. That part works.&lt;/p&gt;

&lt;p&gt;Then you start building the &lt;em&gt;storefront&lt;/em&gt; around it, and you hit a wall. Then another one. And after the fourth or fifth project you realize you are writing the same custom code every single time, for the same missing pieces.&lt;/p&gt;

&lt;p&gt;That is roughly the story of how we ended up building an add-on. This post is less a product pitch and more a list of the specific gaps we kept falling into; because if you are building a LearnDash site right now, you are going to fall into them too, and it is worth knowing where the holes are before you quote a client a timeline.&lt;/p&gt;

&lt;p&gt;One thing up front so there is no confusion: we are not LearnDash, and we have no affiliation with the company behind it. What we built is a third-party add-on that sits on top. Everything below is our own experience with the platform, not an official position from anyone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ps1ij5nzy0mf3z5que4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ps1ij5nzy0mf3z5que4.webp" alt="LumoWP course integration example" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #1: your course grid can't show a price
&lt;/h2&gt;

&lt;p&gt;This is the one that surprises people first, and it is the most visual.&lt;/p&gt;

&lt;p&gt;Say you are designing the site in Elementor; which, realistically, most people are. You build a beautiful course archive: a grid of cards, each one showing a course. And you go looking for the widget that displays the course price on that card.&lt;/p&gt;

&lt;p&gt;It isn't there.&lt;/p&gt;

&lt;p&gt;You can show the title. You can show the featured image. You can show the excerpt. But the price; the single most important number on a commerce page; is not something you can just drop onto a template. Same story for a sale price, a "was $99, now $49" strike-through, a discount badge, or a seat counter. LearnDash stores a price on the course, but it does not hand you the building blocks to &lt;em&gt;design with it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So every project turned into the same thing: custom shortcodes, a bit of PHP, some &lt;code&gt;get_post_meta()&lt;/code&gt; calls, and a template partial that only that one site would ever use. Rewritten from scratch next time, because of course it was.&lt;/p&gt;

&lt;p&gt;The fix we settled on was a set of Elementor widgets that treat course commerce data as first-class design material; course grid, price, regular price, sale price, discount badge, enrollment button, stock counter, countdown timer. Eight widgets, plus shortcodes for the people who are not using Elementor. Nothing clever. Just the pieces that should have existed already, so you can lay out a course card the way you would lay out any WooCommerce product card.&lt;/p&gt;

&lt;p&gt;And the sale price is set where you would expect it; right inside the course edit screen, next to the regular price. Not in a separate settings panel three menus away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5i83zs3yyzf93hleehw6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5i83zs3yyzf93hleehw6.webp" alt="LumoWP course integration example" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #2: there is no seat limit
&lt;/h2&gt;

&lt;p&gt;Here is a scenario that comes up constantly and has no native answer: a live cohort with twenty seats. A workshop with a room that fits thirty people. A mentorship program the instructor can only handle eight of at a time.&lt;/p&gt;

&lt;p&gt;LearnDash, as far as we have found, has no concept of enrollment capacity. A course is infinitely purchasable. If two hundred people buy your twenty-seat cohort, that is two hundred refunds and one very unhappy client phone call.&lt;/p&gt;

&lt;p&gt;WooCommerce solves this for products; it is called stock. So the sensible move is to let a course behave like a stocked product: set a maximum number of enrollments, show the remaining count on the front end ("6 seats left" does more for conversion than any amount of copywriting), and stop sales when it fills.&lt;/p&gt;

&lt;p&gt;The other half of that is duplicate purchase blocking. Without it, an already-enrolled student can buy the same course again, which quietly eats a seat and generates a support ticket.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitjo4ypbui7ogmwuzi3p.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitjo4ypbui7ogmwuzi3p.webp" alt="LumoWP course integration example" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #3: your sales data lives in the wrong place
&lt;/h2&gt;

&lt;p&gt;This one is the least visible and the most annoying six months into a project.&lt;/p&gt;

&lt;p&gt;LearnDash can take payments. But once you are selling seriously, you want your orders where the rest of your store's orders are: in WooCommerce. That is where the refund flow lives, where the reports live, where the tax handling lives, where every accounting and analytics plugin in the ecosystem is already looking. Splitting course sales into a separate silo means you have two sources of truth about your revenue and no single place to reconcile them.&lt;/p&gt;

&lt;p&gt;So the integration side of what we built works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A single variable product is created on your site. That product is the bridge; it is what makes course sales real WooCommerce orders.&lt;/li&gt;
&lt;li&gt;When a user goes to buy a course, a variation for that course is generated automatically. The user does not see any of this; they see a normal checkout.&lt;/li&gt;
&lt;li&gt;They pay through your existing WooCommerce gateway; whatever you already have configured; and come back to the site.&lt;/li&gt;
&lt;li&gt;Order status changes drive enrollment. Order completes, access is granted. Order is refunded or cancelled, access is revoked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part that matters is step 4. Enrollment follows order status automatically, in both directions. That is the difference between "we sell courses" and "we manually check the orders list every morning."&lt;/p&gt;

&lt;p&gt;The payoff is that you now get proper sales reporting, refunds through the normal WooCommerce refund button, and every WooCommerce extension you already know how to use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00nkm0y441fxgk9scgxh.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00nkm0y441fxgk9scgxh.webp" alt="LumoWP course integration example" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #4: discounts, or the lack of them
&lt;/h2&gt;

&lt;p&gt;LearnDash's built-in discounting is thin. If a client asks for a coupon code, a limited-time launch price, a bundle rule, or "20% off everything in the design category until Friday"; you are improvising.&lt;/p&gt;

&lt;p&gt;Once course sales are real WooCommerce orders, coupons work the way coupons work. But we also wanted rule-based discounting on top: global rules that apply across courses, and course-specific rules for one-off promotions. Combined with a countdown timer widget, that covers most of what a launch actually needs without a second plugin and a compatibility gamble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #5: self-hosted video that LearnDash won't recognize
&lt;/h2&gt;

&lt;p&gt;This is the most technical of the five, and the one that costs the most money if you get it wrong.&lt;/p&gt;

&lt;p&gt;Video hosting for a course site is a real decision. YouTube is free but you lose control. Vimeo and Wistia are excellent and expensive at scale. A lot of people land on the middle path: a download host, or an FTP box on a subdomain; something like &lt;code&gt;dl.yourdomain.com&lt;/code&gt;; where you upload the video files yourself and pay for bandwidth instead of a per-seat platform.&lt;/p&gt;

&lt;p&gt;The problem is that LearnDash historically would not recognize those files as lesson video. You had a perfectly valid MP4 sitting on your own server, a direct URL to it, and no clean way to hand it to the lesson video field.&lt;/p&gt;

&lt;p&gt;So we added it. You point the lesson at your own URL; MP4, WebM, OGG, or a streaming manifest for HLS or DASH; and it plays as the lesson video, with LearnDash still tracking completion the way it normally does. Your files, your subdomain, your bandwidth bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes about how you build
&lt;/h2&gt;

&lt;p&gt;Step back from the individual features and the shift is this: your course site stops being limited to what LearnDash's own templates can express.&lt;/p&gt;

&lt;p&gt;You design course listings and grids in Elementor with real commerce data on them. You sell through WooCommerce, so your reporting, refunds, coupons, tax setup, and gateway are all the ones you already use. You host video wherever the economics make sense. LearnDash goes back to doing what it is genuinely great at; being the learning engine; and stops being the bottleneck on everything wrapped around it.&lt;/p&gt;

&lt;p&gt;We also made the boring parts boring on purpose: HPOS compatible, translation-ready, low-stock alerts, automatic access revocation. Those are the things you do not think about until the project has been live for a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some honest caveats
&lt;/h2&gt;

&lt;p&gt;This is not for everyone. If you are selling three courses at a flat price and never running a promotion, LearnDash's built-in checkout is fine and you should not add a layer of complexity you do not need.&lt;/p&gt;

&lt;p&gt;Where it earns its place is the moment any of these become true: you are designing custom course templates in Elementor, you need seats to be finite, you want course revenue in the same reports as everything else you sell, you run promotions, or you host your own video.&lt;/p&gt;

&lt;p&gt;If you have been fighting any of those five walls, it is worth a look. The plugin is &lt;a href="https://lumowp.com/product/learndash-to-woocommerce-integration-course-selling-plugin-for-wordpress/" rel="noopener noreferrer"&gt;LumoWP's LearnDash to WooCommerce Integration&lt;/a&gt;; and if you want to see the flow rather than read about it, watch the tutorial video on the product page first. It is a much faster way to tell whether this solves your problem than any blog post, including this one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fri1oykx9yxh2jrcmi7vo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fri1oykx9yxh2jrcmi7vo.webp" alt="LumoWP course integration example" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you have built LearnDash sites and hit gaps I did not list here, I would like to hear about them in the comments; a couple of features in this add-on exist purely because someone described a problem we had not run into yet.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover illustration generated with AI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>webdev</category>
      <category>elearning</category>
    </item>
    <item>
      <title>Connect Cursor to WordPress with MCP: A Site Pilot Walkthrough</title>
      <dc:creator>Mahdi JabinPour</dc:creator>
      <pubDate>Sat, 22 Aug 2026 11:35:12 +0000</pubDate>
      <link>https://dev.to/jabinpou/how-to-connect-cursor-to-wordpress-no-ssh-no-local-setup-1378</link>
      <guid>https://dev.to/jabinpou/how-to-connect-cursor-to-wordpress-no-ssh-no-local-setup-1378</guid>
      <description>&lt;p&gt;Want Cursor to work with a hosted WordPress site without maintaining a local copy? The important part is giving it a real connection to the site, then checking what that connection allows.&lt;/p&gt;

&lt;p&gt;This walkthrough uses &lt;strong&gt;Lumo Site Pilot&lt;/strong&gt;, our WordPress plugin. It exposes an MCP endpoint that compatible clients such as Cursor can connect to. This is an independent LumoWP product, not an official Cursor or WordPress integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Connect the site, not just a rule file
&lt;/h2&gt;

&lt;p&gt;Install Site Pilot on the WordPress site you intend to manage. Use the endpoint and connection key supplied by your installed version, and add that connection to your client's MCP configuration. Follow the plugin's current connection instructions rather than copying someone else's domain or key.&lt;/p&gt;

&lt;p&gt;A rule file describes how you want the assistant to work. It is not the transport and cannot, by itself, authenticate a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Start with a read-only check
&lt;/h2&gt;

&lt;p&gt;Before requesting changes, try a small inspection task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Check the connection and list the available tools. Then summarize the
WordPress version, active plugins and current site status. Do not change anything.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the connection fails, check the endpoint, key and hosting restrictions before proceeding. If an expected tool is absent, do not assume the assistant has that capability.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep working instructions separate from credentials
&lt;/h2&gt;

&lt;p&gt;Keep keys out of committed rules, public screenshots and chat transcripts. A useful project instruction is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read the current state before editing. Explain the proposed change.
Back up before a write. Make one change at a time, verify it, and report
what changed. Do not send customer messages or expose customer data.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start on a staging site where possible. A successful tool response is not proof that checkout, forms or page layouts still work.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Use specific prompts
&lt;/h2&gt;

&lt;p&gt;Instead of asking the assistant to fix everything, give it a narrow task:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inspect the error log and explain the likely cause before editing files.&lt;/li&gt;
&lt;li&gt;Identify plugins with available updates and propose a staged update plan.&lt;/li&gt;
&lt;li&gt;Review one custom plugin, propose a patch and explain how to test it.&lt;/li&gt;
&lt;li&gt;Check a setting and report its current value without changing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Review the result in WordPress and in the browser. Test the user journey affected by the change, such as submitting a form or completing a test checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this the right approach for your site?
&lt;/h2&gt;

&lt;p&gt;It can fit a remote administration workflow when you want an MCP client to inspect or manage WordPress. It does not replace backups, version control or review, and available tools depend on the plugin version and configuration.&lt;/p&gt;

&lt;p&gt;The current connection instructions and demo are on the &lt;a href="https://lumowp.com/product/lumo-site-pilot/" rel="noopener noreferrer"&gt;Lumo Site Pilot product page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What is the first task you would want to inspect from Cursor: plugin errors, site settings or a custom feature?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover illustration generated with AI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>cursor</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Best AI Internal Linking Plugin for WordPress in 2026 (Full Review &amp; Setup Guide)</title>
      <dc:creator>Mahdi JabinPour</dc:creator>
      <pubDate>Sat, 18 Apr 2026 02:19:05 +0000</pubDate>
      <link>https://dev.to/jabinpou/best-ai-internal-linking-plugin-for-wordpress-in-2026-full-review-setup-guide-17l1</link>
      <guid>https://dev.to/jabinpou/best-ai-internal-linking-plugin-for-wordpress-in-2026-full-review-setup-guide-17l1</guid>
      <description>&lt;p&gt;Most WordPress sites have dozens of "invisible" pages that Google can't properly reach. The fix isn't more content — it's a smarter internal linking strategy. This article breaks down the problem, the science behind it, and how Nexu Link Brain automates the whole thing without touching your editorial voice.&lt;/p&gt;

&lt;p&gt;THE SILENT SEO KILLER NOBODY TALKS ABOUT&lt;/p&gt;

&lt;p&gt;You've written 200 blog posts. You've obsessed over keywords, meta descriptions, and backlinks. You've paid for SEO audits. But your traffic has plateaued — or worse, it's slowly declining.&lt;/p&gt;

&lt;p&gt;Here's what most SEO guides won't tell you: the problem probably isn't your content. It's how your content connects to itself.&lt;/p&gt;

&lt;p&gt;Internal linking is the unglamorous backbone of site architecture. Every internal link is a vote of authority from one page to another. It's a signal to crawlers saying "this page matters, go find it." It's a guide for visitors saying "here's something else relevant to you."&lt;/p&gt;

&lt;p&gt;When &lt;a href="https://nexuwp.com/product/nexu-automated-ai-internal-linker-smart-seo-interlinking-plugin-for-wordpress/" rel="noopener noreferrer"&gt;internal linking&lt;/a&gt; is broken — or simply non-existent — even your best content becomes invisible. Pages rank below their potential. Visitors bounce. Authority stays trapped on a handful of lucky pages instead of flowing across your whole site.&lt;/p&gt;

&lt;p&gt;The statistics are stark:&lt;/p&gt;

&lt;p&gt;About 29% of pages on the average WordPress site have zero internal links pointing to them. These are called orphan pages. Sites with strong internal link structures see 40 to 60 percent better crawl coverage. Pages receiving internal links from high-authority sources rank measurably higher — even with identical external backlinks.&lt;/p&gt;

&lt;p&gt;The problem is scale. If you have 500 posts, the number of possible internal links is mathematically enormous. Doing it manually — even with the best intentions — just doesn't work.&lt;/p&gt;

&lt;p&gt;That's exactly the problem Nexu Link Brain was built to solve.&lt;/p&gt;

&lt;p&gt;WHAT ACTUALLY MAKES INTERNAL LINKING HARD&lt;/p&gt;

&lt;p&gt;Before diving into the tool, it's worth understanding why internal linking is harder than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: The Orphan Page Problem
&lt;/h2&gt;

&lt;p&gt;An orphan page is published, live, and possibly excellent. But if no other page on your site links to it, crawlers might never find it in a crawl cycle. It accumulates no internal authority. It ranks far below its potential.&lt;/p&gt;

&lt;p&gt;On most sites, orphan pages aren't an exception — they're the norm. Especially for older content. You published something two years ago, it had a moment, and then the site grew around it without ever coming back to link things together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Keyword Matching is Dumb
&lt;/h2&gt;

&lt;p&gt;Most older internal linking plugins work with keyword rules. "Whenever the phrase email marketing appears, link to /email-marketing-guide."&lt;/p&gt;

&lt;p&gt;This sounds sensible. It breaks down fast. It misses related content that uses different words for the same topic. It creates repetitive anchor text patterns, which is an over-optimization red flag. It doesn't understand context — linking to the same page from a completely unrelated article just because a phrase matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Manual Doesn't Scale
&lt;/h2&gt;

&lt;p&gt;The only real alternative to automated linking is doing it by hand. Opening each post, thinking about what it relates to, adding links manually. On a 50-post site, that's annoying. On a 500-post site, it's a full-time job. On a 2,000-post site, it's simply not possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: You Can't See the Problem
&lt;/h2&gt;

&lt;p&gt;Even if you wanted to fix your internal linking manually, you probably can't visualize it. Which pages are orphaned? Which posts are link magnets absorbing all the internal authority? Where are the clusters and gaps? Without a visual map, you're working blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  ENTER: AI-POWERED SEMANTIC INTERLINKING
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1rabwzooi5hsp0lp6gt2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1rabwzooi5hsp0lp6gt2.webp" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nexuwp.com/product/nexu-automated-ai-internal-linker-smart-seo-interlinking-plugin-for-wordpress/" rel="noopener noreferrer"&gt;Nexu Link Brain&lt;/a&gt; attacks all four problems with a single, cohesive system. Let's break down how it actually works.&lt;/p&gt;

&lt;p&gt;Semantic Understanding, Not Keyword Matching&lt;/p&gt;

&lt;p&gt;The fundamental shift is from lexical to semantic understanding.&lt;/p&gt;

&lt;p&gt;Old tools ask: "Do these two pages share the same keywords?"&lt;/p&gt;

&lt;p&gt;Nexu asks: "Are these two pages actually about related topics?"&lt;/p&gt;

&lt;p&gt;This distinction matters enormously. A post about "running shoes" and a post about "jogging footwear for beginners" don't share many exact keywords — but they're clearly related. A semantic AI model understands this. A keyword matcher doesn't.&lt;/p&gt;

&lt;p&gt;Nexu integrates with OpenAI, Claude, Gemini, and DeepSeek to generate content embeddings — mathematical representations of meaning. Every indexed post becomes a vector in high-dimensional semantic space. Finding related content is a matter of finding nearby vectors, not matching strings.&lt;/p&gt;

&lt;p&gt;The result: link suggestions that feel like they were written by someone who actually read your content. Because the AI did.&lt;/p&gt;

&lt;p&gt;The Indexing Layer&lt;/p&gt;

&lt;p&gt;Before any suggestions happen, the plugin builds an index. It processes every piece of content you've enabled — posts, pages, products, custom post types, your choice — extracts the semantic meaning, and stores embeddings in your database.&lt;/p&gt;

&lt;p&gt;This indexing runs in the background. You can close your browser and come back later. The plugin chains requests server-side and doesn't hammer your site's performance. Once the index is built, suggestions are instant.&lt;/p&gt;

&lt;p&gt;When you change your AI provider or switch embedding models, the plugin warns you that your index was built with a different model and recommends a rebuild. That kind of guardrail separates a thoughtful tool from a naive one.&lt;/p&gt;

&lt;p&gt;The Multi-Signal Scoring System&lt;/p&gt;

&lt;p&gt;Every potential link isn't just scored on semantic similarity. Nexu uses a multi-signal composite score that factors in several things: semantic similarity (the embedding distance between source and target), taxonomy overlap (shared categories and tags), authority weight (how many incoming links the target already has), content need (how underlinked the target currently is), anchor safety (whether the proposed anchor follows diversity rules), and reciprocity (whether a back-link from the target would also make sense).&lt;/p&gt;

&lt;p&gt;This composite score is what you see on each suggestion card. You're not just seeing a number — you're seeing the reasoning. Every suggestion comes with a plain-English explanation of why the AI thinks this link is valuable. That transparency is a design choice, not an accident. It respects your editorial judgment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffxz0dfym06geka78mn6p.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffxz0dfym06geka78mn6p.webp" alt=" " width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A TOUR OF THE INTERFACE
&lt;/h2&gt;

&lt;p&gt;The Dashboard&lt;/p&gt;

&lt;p&gt;The main dashboard gives you four key metrics at a glance: indexed content (how many posts and pages are in the semantic index), auto-linked posts (how many have received AI-applied links), average links per post (your current internal link density), and orphan page count (the number of pages with zero incoming links).&lt;/p&gt;

&lt;p&gt;A visual health score ring summarizes your overall internal linking health in a single number — green for healthy, yellow for needs improvement, red for critical. New users get a guided setup checklist that walks through configuration step by step.&lt;/p&gt;

&lt;p&gt;The Visual Link Graph&lt;/p&gt;

&lt;p&gt;This is the feature that genuinely surprises people.&lt;/p&gt;

&lt;p&gt;Every post and page is a node. Every internal link is an edge. The entire graph is rendered as an interactive, animated visualization. You can rotate it, zoom in, and filter by content type.&lt;/p&gt;

&lt;p&gt;Orphan pages float at the periphery with no connections. Well-linked content forms dense clusters at the center. Link equity flows visually through the graph — you can actually see which pages are absorbing authority and which are starved.&lt;/p&gt;

&lt;p&gt;For most site owners, this is the first time they've ever seen their site structure. It's usually a wake-up call.&lt;/p&gt;

&lt;p&gt;The Bulk Linker&lt;/p&gt;

&lt;p&gt;For sites with large archives, this is the killer feature.&lt;/p&gt;

&lt;p&gt;Start a bulk analysis. The plugin queues every eligible piece of content, processes it in the background, and returns a complete list of suggested links across your entire site. You can filter by content type, prioritize orphans first, review the visual graph of suggested connections, apply suggestions in bulk with one click, and undo the entire batch if you change your mind.&lt;/p&gt;

&lt;p&gt;Every bulk action is recorded in the Batch History with full undo capability. Nothing is permanent until you decide it is.&lt;/p&gt;

&lt;p&gt;The cinematic apply mode is a nice touch — instead of just a progress bar, you watch each link apply in sequence with the source, target, anchor, and relevance score visible. It turns a background operation into something you can actually audit in real time.&lt;/p&gt;

&lt;p&gt;The Post Editor Panel&lt;/p&gt;

&lt;p&gt;When you're working inside any individual post, a dedicated metabox shows you a mini link graph for that specific post, a Generate Suggestions button, and a ranked list of suggestions with scores, anchor text, and reasoning.&lt;/p&gt;

&lt;p&gt;You can apply suggestions one by one or hit Apply All. You can edit the anchor text inline before applying. The AI does the research; you keep the editorial control.&lt;/p&gt;

&lt;p&gt;THE ORPHAN RESCUE WORKFLOW&lt;/p&gt;

&lt;p&gt;Standard internal linking asks: "What should this post link to?"&lt;/p&gt;

&lt;p&gt;Orphan Rescue inverts the question: "Who should link to this isolated page?"&lt;/p&gt;

&lt;p&gt;The algorithm finds your highest-authority source pages that are topically related to the orphan, then creates connections flowing inward. The orphan doesn't get a random link from a low-quality source — it gets a link from a page that already carries real SEO weight.&lt;/p&gt;

&lt;p&gt;Orphan Rescue runs automatically at the end of every bulk analysis session. You don't need to remember to do it. The plugin treats it as a default part of the workflow.&lt;/p&gt;

&lt;p&gt;ANCHOR TEXT POLICY AND OVER-OPTIMIZATION PROTECTION&lt;/p&gt;

&lt;p&gt;One of the sneakiest ways internal linking can hurt you is anchor text repetition. If every link pointing to your "email marketing guide" uses the anchor text "email marketing guide," search engines flag that as a pattern — a sign of manipulative linking.&lt;/p&gt;

&lt;p&gt;Nexu's anchor text policy system handles this automatically with diversity limits (caps on how many times the same anchor can point to the same target), generic anchor blocking (filters out "click here," "read more," and other useless anchors), word count rules (ensures anchors are substantive but not spammy), and AI-generated variation (the language model generates contextually natural anchor text that varies by placement).&lt;/p&gt;

&lt;p&gt;There's also a Smart Injection Style system for cases where no natural anchor exists in the content. The AI can skip the link, append a natural sentence to a paragraph, show a subtle callout box, or render a mini related-content card. You pick the style that matches your site's design language.&lt;/p&gt;

&lt;h2&gt;
  
  
  MULTI-AI PROVIDER SUPPORT
&lt;/h2&gt;

&lt;p&gt;One architectural decision worth highlighting: you're not locked into a single AI provider.&lt;/p&gt;

&lt;p&gt;The plugin currently supports OpenAI (GPT-4o, text-embedding-3-large, and others), Anthropic Claude (Sonnet and Haiku), Google Gemini (Flash and Pro), and DeepSeek (V3 and R1).&lt;/p&gt;

&lt;p&gt;You use two types of models independently. An embedding model to build your content index for semantic understanding, and a chat model to generate anchor text and reason about link quality. This separation lets you optimize for cost and quality independently. Use a cheap, fast embedding model for the index since you only rebuild it occasionally. Use a high-quality chat model for anchor text generation since it runs per-suggestion, not per-post.&lt;/p&gt;

&lt;p&gt;API keys are stored encrypted. You can test any connection before committing. When you switch providers, the plugin flags your existing index as stale and recommends a rebuild.&lt;/p&gt;

&lt;p&gt;WOOCOMMERCE AND MULTILINGUAL SUPPORT&lt;/p&gt;

&lt;p&gt;Two edge cases that usually break internal linking tools:&lt;/p&gt;

&lt;p&gt;WooCommerce: Product pages, category archives, and blog posts all need to interlink. The Cross Post-Type Linking Matrix lets you configure exactly which content types can link to which. Products link to guides. Guides link to products. Categories link to cornerstone posts. You define the rules; the AI follows them.&lt;/p&gt;

&lt;p&gt;WPML and Polylang: The plugin includes a language guard that prevents cross-language linking. A French post won't get linked to an English post. Links stay within the correct language version of the site. For multilingual sites, this isn't optional — it's essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  SETTINGS THAT MATTER
&lt;/h2&gt;

&lt;p&gt;A few settings worth highlighting for developers and advanced users:&lt;/p&gt;

&lt;p&gt;Max links per post prevents over-linking. Min relevance score acts as a quality gate on suggestions. Min word distance prevents link clustering within paragraphs. Protected areas let you skip headings, blockquotes, and code blocks. Pillar page priority gives a scoring boost to your money pages. Auto-apply threshold enables fully automated linking above a certain score. Editorial access controls role-based access to suggestion panels.&lt;/p&gt;

&lt;p&gt;The cross post-type matrix is a visual grid — rows are source types, columns are targets. Check the intersection to allow that combination. Uncheck to prevent it. Simple, powerful, immediately understandable.&lt;/p&gt;

&lt;h2&gt;
  
  
  REPORTING: CLOSING THE LOOP
&lt;/h2&gt;

&lt;p&gt;Building links is half the workflow. Knowing whether they're working is the other half.&lt;/p&gt;

&lt;p&gt;The Reports tab includes an Orphan Pages report with one-click suggestion generation per orphan, a Most and Least Linked report that identifies imbalanced authority distribution, a Link Distribution report showing spread across your content inventory, a Broken Internal Links report finding links pointing to 404s or unpublished pages, and a Low Anchor Diversity report that flags over-optimized anchor patterns before they become a penalty risk.&lt;/p&gt;

&lt;p&gt;All reports export to CSV. The SEO Health Dashboard gives you six headline metrics at a glance. The graph visualization on each report shows the wider context of each flagged page.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHO THIS IS ACTUALLY FOR
&lt;/h2&gt;

&lt;p&gt;Content sites with 100 or more posts — the bulk linker pays for itself in time saved within the first session.&lt;/p&gt;

&lt;p&gt;WooCommerce stores — product discovery through internal linking is chronically underinvested in most stores.&lt;/p&gt;

&lt;p&gt;Agencies managing multiple client sites — the 20-site license and role-based editorial access make this practical at scale.&lt;/p&gt;

&lt;p&gt;Multilingual WordPress sites — the WPML and Polylang integration is rare among linking tools.&lt;/p&gt;

&lt;p&gt;Anyone who's never audited their internal linking — the visual graph alone will change how you think about your site architecture.&lt;/p&gt;

&lt;p&gt;Brand new sites with under 20 posts might find the ROI lower until the archive grows. The AI needs enough content to find meaningful connections.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk8qb2pz7opjoky508r8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk8qb2pz7opjoky508r8.png" alt=" " width="800" height="887"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  THE BOTTOM LINE
&lt;/h2&gt;

&lt;p&gt;Internal linking has always been important. What's changed is that it's now tractable — you can actually do it well without a team of editors manually connecting hundreds of posts.&lt;/p&gt;

&lt;p&gt;Nexu Link Brain's approach — semantic embeddings, multi-signal scoring, visual graph, batch processing with full undo, orphan rescue, anchor diversity enforcement — is the most complete internal linking system currently available for WordPress.&lt;/p&gt;

&lt;p&gt;The pricing starts at $89 per year for a single site, with agency licenses up to 20 sites at $999 per year. Given the hours typically spent on manual link audits, and the ranking improvements that follow from better site structure, the math tends to work out quickly.&lt;/p&gt;

&lt;p&gt;If your site has been publishing content for more than a year and you've never run a systematic internal linking audit, the visual graph alone will be worth the install. You'll likely discover that your site's architecture looks nothing like you imagined.&lt;/p&gt;

&lt;p&gt;Check it out at: &lt;a href="https://nexuwp.com/product/nexu-automated-ai-internal-linker-smart-seo-interlinking-plugin-for-wordpress/" rel="noopener noreferrer"&gt;https://nexuwp.com/product/nexu-automated-ai-internal-linker-smart-seo-interlinking-plugin-for-wordpress/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have questions about internal linking strategy or this plugin? Drop them in the comments — happy to go deeper on any part of this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>internallink</category>
      <category>seo</category>
      <category>wordpress</category>
    </item>
  </channel>
</rss>
