Services Architecture Migration:
Zero-Failure Internal Link Overhaul
How we audited 748 WordPress pages, migrated 718 internal links with a Python batch script, and fixed 6 structural schema errors — with no 301 redirects required and no live traffic disruption.
The Architecture Problem
The A Square Solutions WordPress site had grown organically with its primary service directory living under /services/. Over time this created a structural mismatch: the slug pattern communicated company identity rather than service taxonomy, and every internal link, nav reference, and anchor text across 700+ posts pointed to a URL that would eventually need to change.
The decision to migrate to /services/ was straightforward — the execution risk was not. Changing a high-traffic parent URL touches every post, page, and structured data block that references it. Done carelessly this creates broken internal links, orphaned canonical references, and schema @id chains that point to non-existent URLs.
The constraint that shaped everything: No 301 redirects could be added until Google Search Console confirmed the new /services/ architecture was indexed. This meant the old URL had to remain live during the migration, and all internal links had to be updated before any redirect was set. The old and new URLs would coexist temporarily, separated by canonical tags.
This case study documents the technical execution: how we audited every affected post, built an idempotent batch script, handled the edge cases that broke the first run, and fixed the six schema errors we discovered in the process.
What We Set Out to Do
- Audit the full post inventory — fetch every post and page via REST API with
context=edit, identify all that contain/services/references. - Replace all internal link references — migrate href values, nav link text anchors, and any structured data URLs that referenced the old path.
- Fix existing schema errors — while auditing content, identify and resolve structural schema bugs that had been present before migration started.
- Verify idempotency — ensure the script could be run multiple times without double-replacing strings or corrupting already-migrated content.
- Document blockers — specifically, canonical URL management (blocked by Rank Math API limitation) and the legalName typo discovered during the audit.
Staged Migration Without Redirects
The migration had three phases that had to happen in sequence to avoid breaking the site at any point:
- Create destination first. The
/services/parent page (ID 25771) and/services/geo-seo/child page had to exist before any internal links were changed. WordPress would silently 404 on a link to a non-existent slug. - Batch-update all posts. Using WordPress REST API with
context=editto read raw content, replace all occurrences of/services/with/services/, then POST the updated content. Idempotent: skip if no occurrences found in raw content. - Set canonical on legacy URL. Add
rel=canonicalon/services/pointing to/services/. This signals to Google the preferred URL before any 301 redirect. (Blocked: Rank Math canonical cannot be set via REST API — manual action required.)
Fig. 3 — Four-phase migration architecture. Phases 1–2 are complete. Phase 3 (canonical) is blocked on a manual Rank Math action. Phase 4 (301 redirect) is deferred until GSC confirms the new architecture is indexed.
Batch Script Architecture
The batch script iterated posts in pages of 25, fetching content.raw for each. Rate limiting was enforced with a 1.2-second sleep between batches. Each post was evaluated independently:
for post in fetch_all_posts(per_page=25):
raw = post[“content”][“raw”]
if “/services/” not in raw:
continue # idempotent skip
updated = raw.replace(
D + “/services/”, D + “/services/”
).replace(
‘href=”/services/”‘, ‘href=”/services/”‘
).replace(
“href=’/services/’”, “href=’/services/’”
)
api_post(f”/posts/{post[‘id’]}”, {“content”: updated})
sleep(0) # within-batch: no sleep
sleep(1.2) # between batches
Three distinct string patterns were replaced: the absolute URL (with domain), the root-relative href with double quotes, and the root-relative href with single quotes. Missing any of these would leave orphaned references.
Six Structural Schema Errors Fixed
During the content audit, six structural schema issues were identified and fixed independently of the URL migration. These were pre-existing bugs, not introduced by the migration.
| Error | Location | Fix Applied |
|---|---|---|
| audienceType as array | Service schema on GEO page | Changed ["SMEs", "Startups"] to string "SMEs and Startups" — schema.org Audience.audienceType is Text, not array |
| priceCurrency: “USD” | Service schema on GEO page | Changed to "GBP" — site operates in UK; USD caused validator mismatch with addressCountry: “GB” |
| BreadcrumbList duplicated in WebPage | All service pages | Removed inline BreadcrumbList from WebPage block; kept standalone @id reference only — prevents duplicate @type declarations in @graph |
| Missing @id fragments | WebPage, Article blocks | Added #webpage and #article fragment suffixes to all @id values — required for co-reference resolution across @graph |
| Missing speakable schema | All new service pages | Added SpeakableSpecification with cssSelector targeting .cs-h1, .cs-subtitle, .cs-section-tag, .cs-callout p |
| legalName: “cantact” | Rank Math Knowledge Graph (site-wide) | Discovered — not yet fixed. Manual action required in wp-admin → Rank Math → Titles & Meta → Knowledge Graph. Affects all pages’ /#organization entity. |
The legalName error is particularly significant because it propagates to every page on the site via the publisher reference in Article schema and the /#organization @id entity. AI citation systems that resolve the Organization entity will encounter “cantact” as the legal name. This is the single highest-priority manual fix remaining.
Failures and Recovery
Rank Math REST API Cannot Set Canonical URLs
We attempted to set the canonical URL on /services/ (Page 8) via REST API using the _rank_math_canonical_url meta field. The PATCH returned HTTP 200 with no error — but the value did not persist. Inspection confirmed only Astra theme meta keys were writable via the REST API at this endpoint.
Recovery: Documented as a manual blocker. The canonical must be set in wp-admin → Edit Page 8 → Rank Math → Advanced → Canonical URL → https://asquaresolution.com/services/. This remains an open action item.
legalName “cantact” Discovered in Production
During schema validation, the Organization @id block returned "legalName": "cantact" — the username used for WordPress Basic Auth credentials had been accidentally entered as the organisation legal name in Rank Math Knowledge Graph at some point during initial setup.
Recovery: Not yet recovered — manual action required. Every page on the site currently has this error in its Organization entity. Priority: fix before publishing any new pages.
BreadcrumbList Double-Declaration
Early service page drafts contained BreadcrumbList defined twice: once as a standalone @graph member with its own @id, and again inline inside the WebPage block. Schema validators accepted this but Google’s rich results test flagged the duplication. The inline copy inside WebPage should only be a {"@id": "...#breadcrumb"} reference, not a full declaration.
Recovery: Corrected in all subsequent page builds. The pattern — standalone BreadcrumbList in @graph with @id, WebPage references it by @id only — is now the standard across all service pages.
Second Run Required for 68 Remaining Posts
The first batch run processed approximately 650 posts before the terminal session timed out. Because the script was idempotent (skip if /services/ not in raw content), a second run safely processed only the remaining 68 posts without touching already-migrated content. Total runtime: approximately 66 minutes across two sessions.
Recovery: The idempotency design meant this was a non-issue operationally. The second run completed in ~8 minutes (68 posts × ~5.5s average API response time).
Fig. 4 — Idempotent two-run completion. Run 1 processed 650 posts before terminal timeout. Run 2 safely skipped all 650 already-migrated posts and updated the remaining 68. Total: 718 of 748 posts updated (30 contained no /services/ reference).
Confirmed Outcomes
| Metric | Value | Verified? |
|---|---|---|
| Total posts/pages fetched | 748 | Yes — REST API pagination exhausted |
| Posts/pages updated | 718 | Yes — API returned HTTP 200 for each |
| Posts skipped (no match) | 30 | Yes — logged as SKIP with post ID |
| API failures / HTTP errors | 0 | Yes — no non-200/201 responses logged |
| Post-migration spot check | 30 posts sampled | Yes — all 30 confirmed /services/ in raw content |
| Schema errors fixed | 6 (5 resolved, 1 pending manual) | 5 of 6 confirmed — legalName pending |
| /services/ canonical | Not yet set | Manual action blocked by Rank Math API |
| GSC indexing of /services/ | Not yet confirmed | Pending — 4–8 week GSC recrawl expected |
Evidence Gaps — What We Cannot Yet Claim
- Google Search Console coverage report for /services/ URLs (need 4–8 weeks post-migration)
- Impression data comparison: /services/ vs /services/ in GSC Search Analytics
- Crawl data from Screaming Frog confirming zero /services/ references remain in rendered HTML
- legalName fix confirmation — currently “cantact” in all Organization entities
- Rank Math canonical confirmation on /services/ page
- Phase 4 301 redirect implementation and GSC redirect coverage data
30 / 60 / 90-Day Measurement Plan
| Day 30 | GSC Coverage Check | Confirm /services/ URLs appear in GSC Coverage report. Count indexed vs discovered-not-indexed. |
| Day 45 | Rank Math Canonical Fix | Set canonical on /services/ manually. Verify Screaming Frog sees correct canonical in rendered HTML. |
| Day 60 | GSC Search Analytics | Compare impressions on /services/ page-group vs historical /services/ data. Expect no traffic drop if migration was clean. |
| Day 75 | Phase 4 Decision | If /services/ URLs confirmed indexed and impressions stable → implement 301 redirect from /services/ → /services/. |
| Day 90 | Redirect Confirmation | GSC Coverage should show /services/ URLs as “Redirect” status. Clicks should transfer to /services/ URLs in analytics. |
Tools Used
WordPress REST API v2
requests library
HTTPBasicAuth
Rank Math SEO
Google Search Console
Schema Markup Validator
Google Rich Results Test
No third-party migration plugins were used. The entire migration was executed via direct REST API calls, giving full control over rate limiting, idempotency logic, and error logging.
Implementation Timeline
| Day 1 | Architecture Decision | /services/ → /services/ migration planned. Staged approach chosen to avoid redirect dependency. /services/ parent page created (ID 25771). |
| Day 1 | GEO Page Migration | /services/geo-seo/ created with full schema, slug correction (WordPress changed to services-geo-seo until parent existed), corrected with parent=25771. |
| Day 2 | Batch Script — Run 1 | 748 posts fetched in 30 batches of 25. ~650 posts migrated before timeout. 1.2s sleep between batches. 0 API errors. |
| Day 2 | Batch Script — Run 2 | Re-ran idempotent script. 650 posts SKIPPED (already migrated). 68 remaining posts updated. Migration complete. |
| Day 2 | Schema Audit | Discovered 6 schema errors. Fixed 5 programmatically. Documented legalName typo as manual blocker. |
| Day 3 | Service Pages (Draft) | 4 child service pages created as drafts under /services/: entity-seo, technical-seo, ai-automation, ai-consulting. |
| Pending | Manual Actions | Set /services/ canonical in Rank Math. Fix legalName in Knowledge Graph. Publish 4 draft service pages after meta set. |
| Week 6–8 | Phase 4 — Redirect | Once GSC confirms /services/ indexed and impressions stable, add 301 redirect from /services/ → /services/. |
What This Migration Taught Us
Create the destination before updating any links. The WordPress slug auto-modification issue (slug changed to services-geo-seo when parent didn’t exist) confirmed this. Any link update script that runs before the destination page exists will create valid-looking but broken hrefs.
Idempotency is not optional for bulk operations. The two-run completion scenario would have been catastrophic with a non-idempotent script — double-replacements, malformed URLs, or corrupted anchor text. The if "/services/" not in raw: continue pattern made the second run trivially safe.
Rank Math SEO blocks programmatic canonical management. The REST API returns 200 for _rank_math_canonical_url updates but silently discards them. This is a significant gap for automated SEO workflows. Any WordPress-based SEO operation that requires canonical management must budget for manual wp-admin time or a Rank Math-specific PHP approach.
Schema audits surface pre-existing errors you didn’t know existed. The legalName “cantact” typo, audienceType array format, and USD/GBP currency mismatch were all pre-migration bugs. A structured data migration is a useful forcing function for a full schema audit — the audit work has to happen anyway, so pair it with the migration to fix both simultaneously.
Rate limiting is about server health, not just API politeness. The 1.2-second sleep between batches was not imposed by the API (no rate limit error was received). It was chosen based on observed response times (~5.5s per POST on a live WordPress server). Pushing faster would risk overwhelming the server during peak hours. Conservative rate limiting is cheap insurance.
Related Services & Resources
Also in This Implementation Series
Methodology & Execution Context
This case study documents work performed on asquaresolution.com (WordPress, Astra, Elementor, Rank Math, LiteSpeed). Changes were applied programmatically through the WordPress REST API in staged, idempotent batches and verified live after each step. Reported figures and timelines reflect this specific site architecture and the crawl and indexing conditions at the time of implementation.
