Read these once. Every phase below is an application of one of them.
Written briefs go stale. The decisions that actually govern the build are usually in a meeting recording that nobody re-read.
| Source | What it gives you | Trust |
|---|---|---|
| Meeting transcript / recording | The decisions actually made, in the client's own words | Highest — most recent wins |
| Design mockups | Layout, copy, imagery, section order | High for design |
| Written spec / build notes | Structure and link targets | Medium — often predates the call |
| Outgoing developer's summary | A starting list to verify | Lowest — verify all |
| The current live site | Real copy, real photos, real URLs | Factual |
Do this while you still have it. Once DNS moves, it's gone.
# every page the old site links to curl -s -L "https://CLIENT.com/" | grep -oE 'href="[^"]+"' | sort -u # pull each page so you have the real copy offline for p in about services contact; do curl -s -L "https://CLIENT.com/$p" -o "live/$p.html"; done # how does their booking / phone / form actually work? grep -rioE 'href="[^"]*(book|appoint|schedule)[^"]*"' live/
Produce an explicit keep / rebuild / drop list and get it confirmed. Ambiguity here becomes rework later. Note anything the client said they want to be able to maintain themselves — that's a design constraint, not a nice-to-have.
| # | Access | Where | Unlocks |
|---|---|---|---|
| 1 | WordPress Application Password | WP → Users → your user → Application Passwords | The whole REST API. This is the main build channel. |
| 2 | SSH | hPanel → Advanced → SSH Access | WP-CLI, database, files. Removes most limits below. |
| 3 | FTP | hPanel → Files → FTP Accounts | Fallback when SSH is unavailable |
| 4 | Host control panel | hPanel login | Domains, SSL, backups, email |
| 5 | Registrar / DNS | GoDaddy, Cloudflare… | Launch day only |
Create your own admin user — never work inside the client's personal login. When the previous contractor leaves, their access is revoked without touching yours.
# verify the application password works
curl -u "USER:APP PASSWORD" \
"https://SITE/wp-json/wp/v2/users/me?context=edit"
/wp-admin, which uses cookies and nonces. Anything that lives solely in an admin screen — plugin settings, Elementor's own tools, cache clearing — is out of reach until you have SSH. Plan for a human to do those few clicks.d:\.CLIENT.env # never inside a git working tree
SSH_HOST=...
SSH_PORT=65002
SSH_USER=...
SSH_PASS=...
WP_URL=https://...
WP_USER=...
WP_APP_PASSWORD=...
# versions and what's installed - shapes every later decision GET /wp-json/wp/v2/plugins GET /wp-json/wp/v2/themes GET /wp-json/wp/v2/pages?per_page=100&status=any&context=edit GET /wp-json/ # every available namespace
That last call is worth reading properly. Plugins expose their own namespaces, and they're often the only writable route to a setting — the theme switch and the redirect engine in this guide were both found that way.
You're usually inheriting a live client site mid-project. Establish a rollback point first — every time, no exceptions.
_elementor_data via context=edit).Base64 images inside page markup are the single worst thing you can inherit: the page can't be cached, the client can't swap a photo, and the file balloons. On this project Home was 5.2 MB of HTML because 18 photos were welded into it.
# pull every embedded image out to real files re.finditer(r'data:image/([a-z]+);base64,([A-Za-z0-9+/=]{500,})', html) # de-duplicate on an md5 of the decoded bytes
burns-house-reception.jpg, never IMG_4471.jpg. The client browses this library.media-map.json of slug → id + URL, and build pages from it.POST /wp-json/wp/v2/media
Content-Type: image/jpeg
Content-Disposition: attachment; filename="reception.jpg"
POST /wp-json/wp/v2/media/<id> { "alt_text": "...", "title": "..." }
Result on this project: 5.11 MB → 3.88 MB, and every photo became swappable by the client in two clicks.
Elementor stores a page as a JSON tree in post meta. Generate that tree from small composable functions and you get consistency for free — plus the ability to restyle every page at once.
| File | Responsibility |
|---|---|
wp.py | Auth + get() / post() helpers reading the env file |
eb.py | Primitives: brand tokens, heading, text, image, button, row, col, section |
blocks.py | Repeated visual blocks: tiles, framed images, hero bands, CTA bands, form block |
parts.py | Header and footer, generated once so they convert to a global template later |
build_*.py | One file per page — content and structure only |
publish.py | Writes a page and returns its id |
republish_all.py | Rebuilds every page and swaps it in safely |
{ "id": "<7 hex>",
"elType": "container" | "widget",
"widgetType": "heading", // widgets only
"settings": { ... },
"elements": [ ... ] }
POST /wp-json/wp/v2/pages
{
"title": "Home", "slug": "home", "status": "publish",
"template": "elementor_canvas",
"meta": {
"_elementor_edit_mode": "builder",
"_elementor_template_type": "wp-post",
"_elementor_data": "<the JSON tree, stringified>"
}
}
Native widgets — Heading, Text Editor, Image, Button, Icon List, Video, Star Rating. Never an HTML widget for content. The one defensible exception is a small stylesheet for markup a plugin renders that the builder cannot reach (a contact form). Comment it clearly and keep content out of it.
Every one of these failed silently — the publish returned 200 and the page looked broken for a reason that wasn't visible in the response.
Omit it and the builder emits a truncated CSS class, every flex custom property is dropped, and your rows stack vertically with no error anywhere.
settings["container_type"] = "flex" # set this on EVERY container
This one bit twice. On a widget, background, padding, border and width only apply under underscore-prefixed keys. The unprefixed versions are container-only and are ignored without warning.
_background_background, _background_color, _padding, _border_border, _border_width, _border_color, _element_width, _element_custom_width
Symptom: white label text on a background that never rendered — present in the DOM, invisible on screen.
A "boxed" container is always 100% wide with a centred inner wrapper, so child width percentages are silently ignored. Set content_width: "full" on any row whose children carry widths — and leave room for the gap (4-up ≈ 23.5%, 3-up ≈ 32%, 2-up ≈ 49%) or the row wraps.
Overlay colour alpha is multiplied by an overlay opacity that defaults to 0.5, so your 70% scrim renders at 35% and text over photos is unreadable. Pin it:
background_overlay_opacity = 1
After a page renders once, the builder serves a cached element render. There is no REST route to clear it, and Application Passwords can't reach the admin tool that would.
Some plugins return 200 OK, echo your change back, and never persist it. Always read the value back in a separate request before believing it.
| Plugin | REST writable? | Consequence |
|---|---|---|
| Redirection | Yes | Full redirect map can be scripted |
| Contact Form 7 | No — returns 200, discards | Field/mail config needs a human |
| Yoast SEO | No per-page meta | Titles/descriptions typed by hand |
| Core settings | Yes | Front page, site title, admin email |
On this project the pre-installed host theme injected a CSS rule setting opacity: 0 on ~73 containers, awaiting a scroll-animation library that was never loaded. Every section below the hero was permanently invisible to real visitors, while looking fine in the editor.
POST /wp-json/elementor-one/v1/themes/hello-elementor/activate. That also removed a duplicate header and the "powered by WordPress" footer. Lesson: when a host pre-installs a theme, audit what it injects.If your design includes its own header and footer, set the page template to the builder's Canvas option. Otherwise the theme's own header and footer render as well and you get a duplicate.
Read the response headers. If the CDN or page cache reports a miss and you're still seeing stale content, the problem is upstream in the application, not the cache. Chasing the cache wastes hours.
curl -sI "https://SITE/?cb=$(date +%s)" | grep -iE 'cache|age'
A module-level element reused across pages gives every page identical element ids. Make repeated blocks functions, so each call mints fresh ids.
Some plugins create their database tables in an admin wizard. Their API returns confusing errors until it's done. Look for a setup endpoint and drive it:
POST /wp-json/redirection/v1/plugin/data { } # repeat until done
POST /wp-json/redirection/v1/plugin/finish { }
| Symptom | Cause |
|---|---|
| Rows stack vertically | Missing container_type: "flex" |
| Column widths ignored | Parent is boxed, not full-width |
| Element invisible but in the DOM | Widget style used a non-prefixed key |
| Text unreadable over a photo | Overlay opacity left at default |
| Edits don't appear, cache says miss | Builder's element cache — republish as a new page |
| Whole page blank below the hero | Theme injecting an animation rule with no library |
| Setting won't stick despite 200 | Plugin not REST-writable — needs a human |
You cannot see the site you're building. Build a harness that looks at it for you, and make it report facts rather than impressions.
fullPage screenshot leaves lazy-loaded images and background layers unpainted, producing convincing but false "blank section" results. Hours were lost debugging sections that were rendering perfectly. Fix: strip loading="lazy", then resize the viewport to the full page height and take a normal screenshot.await p.evaluate(() =>
document.querySelectorAll('img').forEach(i => i.removeAttribute('loading')));
const H = await p.evaluate(() => document.documentElement.scrollHeight);
await p.setViewportSize({ width: 1440, height: Math.min(H, 12000) });
await p.screenshot({ path: out }); // no fullPage
Every page, at 390 px and 1440 px:
scrollWidth > innerWidth means broken mobilenaturalWidth > 0 against totala[href="#"] and a[href=""]Don't guess at CSS. Read the computed style of the actual element:
getComputedStyle(el).flexDirection // is the row actually a row? getComputedStyle(el).opacity // is it hidden rather than missing? el.getBoundingClientRect() // is it sized as expected?
This is how the invisible-label and hidden-section bugs were both found in minutes after guessing had failed for far longer.
for l in / /about/ /contact/ ...; do
printf "%-24s %s\n" "$l" "$(curl -s -o /dev/null -w '%{http_code}' -L "$U$l")"
done
A contact form that says "message sent" while delivering nothing is the worst defect a small-business site can ship. For a practice or trade, every lost enquiry is lost revenue — and nobody finds out for weeks.
Install a form-storage plugin so every enquiry is saved regardless of email. This is the safety net that means a delivery failure costs nothing.
Never rely on the host's built-in PHP mail. Use a real mailbox or a transactional provider so messages are authenticated and actually arrive.
nslookup -type=TXT _dmarc.CLIENT.com
nslookup -type=TXT CLIENT.com # SPF
A DMARC policy of p=reject means any mail claiming to be from that domain without proper authentication is discarded outright. This client had exactly that. Send from an address you can authenticate, and set Reply-To to the enquirer so replies still work.
A rebuild can wipe out years of search visibility in a single afternoon. For a local business, that traffic is most of their new customers. These steps are not optional extras.
Consolidating twenty thin service pages into three is good for users. Letting those twenty URLs 404 is not. Map every old path to its closest new page and serve a 301, which passes the accumulated ranking value across.
POST /wp-json/redirection/v1/redirect
{ "url": "/old-page", "match_type": "url", "action_type": "url",
"action_code": 301, "group_id": 1,
"action_data": { "url": "/new-page/" } }
Then test every single one — a redirect that exists in the database but doesn't fire is worth nothing.
User-agent: Googlebot / Disallow: /. Harmless while staging, catastrophic if it survives the cutover — the new site simply never gets indexed and nobody notices for weeks. Make it the first thing you check after DNS moves.An SEO plugin gives you Open Graph, canonicals and page schema for free, but usually not a business entity. Hand-write one JSON-LD block and inject it site-wide: exact name, address and phone, opening hours, services, areas served, and a booking action. This is the main on-page lever for the local map results.
Default titles are weak and descriptions are usually empty. If the SEO plugin's fields aren't API-writable, write the text yourself and hand it over as a paste-in list — ten pages is twenty minutes of someone's time and worth doing before launch.
/wp-sitemap.xml.nslookup -type=NS CLIENT.com # who controls DNS nslookup -type=A CLIENT.com # current web host (your rollback) nslookup -type=MX CLIENT.com # their email provider nslookup -type=TXT CLIENT.com # SPF + verification nslookup -type=TXT _dmarc.CLIENT.com nslookup -type=ANY autodiscover.CLIENT.com
Write the current A record down. That's your undo.
| Record | Action |
|---|---|
@ A | Point to the hosting server IP |
www A | Point to the same IP |
| MX · SPF · DMARC · autodiscover · NS · everything else | Leave untouched |
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Host: CLIENT.com" "http://<candidate-ip>/"
http → https redirects. Allow up to an hour; a certificate warning in the first few minutes is normal — don't revert.Tick each box as you go — your progress is saved on this device. Every line is something that has gone wrong on a real project.
| Purpose | Endpoint |
|---|---|
| Confirm auth | GET /wp/v2/users/me?context=edit |
| List every namespace | GET /wp-json/ |
| Pages (with builder data) | GET /wp/v2/pages?per_page=100&context=edit |
| Create / update page | POST /wp/v2/pages[/<id>] |
| Upload media | POST /wp/v2/media |
| Front page & site settings | POST /wp/v2/settings |
| Install / activate plugin | POST /wp/v2/plugins |
| Switch theme | POST /elementor-one/v1/themes/<slug>/activate |
| Create redirect | POST /redirection/v1/redirect |
# status + final URL after redirects curl -s -o /dev/null -w '%{http_code} %{url_effective}\n' -L "$URL" # is a cache serving this? curl -sI "$URL?cb=$(date +%s)" | grep -iE 'cache|age' # what does the page actually contain? curl -s -L "$URL" | grep -o 'data-widget_type="[^"]*"' | sort | uniq -c # does this IP serve the domain? curl -s -o /dev/null -w '%{http_code}\n' -H "Host: CLIENT.com" "http://IP/"
| Phase | Share of effort |
|---|---|
| Finding the real brief and auditing | 15% |
| Access, backup, assets | 15% |
| Building the page system | 25% |
| Verification and fixing what it finds | 25% |
| Forms, SEO, redirects | 15% |
| Cutover and post-launch checks | 5% |
If verification looks disproportionate, note that on this project it caught: a permanently invisible site, a form that silently discarded every enquiry, a search-engine block that would have prevented indexing, and a DNS change that would have taken down the client's email.
VersAssist · Website Build Guide · v1.0 · September 2026