VersAssist
Website Build Guide
VersAssist · Contractor Guide

Building & Launching a Client Website with Claude Code

The end-to-end method for taking a client site from brief to live — WordPress and Elementor driven over the REST API, with the verification steps, the traps, and the launch sequence that protects the client's email and search rankings.
Written from: the Wylie Dentistry Co. rebuild (Hostinger + WordPress + Elementor)
Version: 1.0  ·  Audience: VersAssist web contractors & project leads
Read time: ~25 min  ·  Follow the phases in order — 0–2 are where projects are won or lost.
Start here

Five principles this whole guide rests on

Read these once. Every phase below is an application of one of them.

1
The handover note is a claim, not a fact. Verify every item yourself. On the project this guide came from, the outgoing developer reported five pages "complete"; measurement showed they were design mockups pasted into a single code block, with nothing editable.
2
Build only what the client can edit afterwards. If a non-technical owner can't change a headline or swap a photo without calling you, you haven't delivered a website — you've delivered a dependency.
3
A tool reporting success is not evidence. Publishes return 200 while silently discarding data. Contact forms say "message sent" while nothing is delivered. Confirm the outcome, never the status code.
4
Protect what already works. The client's email, phone number and Google rankings existed before you arrived and matter more than your build. Launch steps are ordered around not breaking them.
5
Write the build as scripts, not clicks. Every page is generated from code, so a global change is one edit and a re-run — not ten passes through a visual editor.
Phase 0 · Before you open an editor

Find the real brief

Written briefs go stale. The decisions that actually govern the build are usually in a meeting recording that nobody re-read.

Gather every source, then rank them

SourceWhat it gives youTrust
Meeting transcript / recordingThe decisions actually made, in the client's own wordsHighest — most recent wins
Design mockupsLayout, copy, imagery, section orderHigh for design
Written spec / build notesStructure and link targetsMedium — often predates the call
Outgoing developer's summaryA starting list to verifyLowest — verify all
The current live siteReal copy, real photos, real URLsFactual
Real example The written spec said "edit the existing site — only one new page needed." The meeting transcript from the day before said something completely different: migrate the whole site to new hosting and rebuild it. Building from the spec would have produced the wrong project entirely.

Audit the existing site before you replace it

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/
Capture these five things 1. Full URL list (you'll need it for redirects). 2. Real body copy for every page you're keeping. 3. Every image, at the best resolution available. 4. The booking / enquiry mechanism and its exact URL. 5. Analytics and verification tags already in the head.

Write down the scope before building

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.

Phase 1 · Credentials and their limits

Get access — and learn what it can't do

What to request, in priority order

#AccessWhereUnlocks
1WordPress Application PasswordWP → Users → your user → Application PasswordsThe whole REST API. This is the main build channel.
2SSHhPanel → Advanced → SSH AccessWP-CLI, database, files. Removes most limits below.
3FTPhPanel → Files → FTP AccountsFallback when SSH is unavailable
4Host control panelhPanel loginDomains, SSL, backups, email
5Registrar / DNSGoDaddy, 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"
Know this before you plan a timeline An Application Password authenticates the REST API only. It does not log you into /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.

Store credentials outside the repo

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=...

Sanity-check the environment immediately

# 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.

Phase 2

Back up before you touch anything

You're usually inheriting a live client site mid-project. Establish a rollback point first — every time, no exceptions.

  1. Host-level backup — hPanel → Files → Backups → Create new. Ask the client to do this if you lack panel access.
  2. Page data — save each page's builder JSON to disk (_elementor_data via context=edit).
  3. Rendered HTML — save the front end of every page, so you can compare design later.
  4. Record the DNS — current A records and TTLs. This is your launch-day undo.
Phase 3

Assets: extract, optimise, name, upload

If images are embedded in the page, get them out

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
Always prefer the originals Check the client's handover folder before using anything scraped from a live site. Scraped copies are usually already compressed and often only 500 px wide. Originals were sitting in a shared folder on this project, correctly named per slot.

Optimise, then upload with real names and alt text

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.

Phase 4

Build pages as code

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 layout that works

FileResponsibility
wp.pyAuth + get() / post() helpers reading the env file
eb.pyPrimitives: brand tokens, heading, text, image, button, row, col, section
blocks.pyRepeated visual blocks: tiles, framed images, hero bands, CTA bands, form block
parts.pyHeader and footer, generated once so they convert to a global template later
build_*.pyOne file per page — content and structure only
publish.pyWrites a page and returns its id
republish_all.pyRebuilds every page and swaps it in safely

The element shape

{ "id": "<7 hex>",
  "elType": "container" | "widget",
  "widgetType": "heading",        // widgets only
  "settings": { ... },
  "elements": [ ... ] }

Publishing

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>"
  }
}
Prove the schema before building nine pages Publish one throwaway page using every widget type you intend to use, render it, and look at it. Ten minutes here saved a full rebuild on this project — the builder version on site turned out to differ from the documented one.

Use only widgets the client can edit

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.

What good looks like Home on this project went from one HTML block containing 5.2 MB, to 114 individually editable widgets — 48 headings, 27 text blocks, 21 buttons, 16 images — and about 105 KB of page data.
Phase 5 · the most valuable section here

Traps that cost hours

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.

1. Containers need an explicit type

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

2. Widget styles use different keys to container styles

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.

3. Percentage widths need a full-width parent

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.

4. Background overlays are multiplied by a separate opacity

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

5. Pages are effectively write-once over the API

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.

Workaround Publish each revision as a new page, delete the old one, then claim the slug. Automate it. This constrains your workflow only — the visual editor clears that cache correctly, so the client is unaffected. SSH removes this entirely, which is the strongest practical argument for insisting on it.

6. Not every plugin's settings are writable

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.

PluginREST writable?Consequence
RedirectionYesFull redirect map can be scripted
Contact Form 7No — returns 200, discardsField/mail config needs a human
Yoast SEONo per-page metaTitles/descriptions typed by hand
Core settingsYesFront page, site title, admin email

7. The host's theme can silently hide your entire site

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.

Fix, and the general lesson Switch to the page builder's own minimal theme. Core REST cannot change themes, but the builder exposes a route that can: 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.

8. Page template controls the theme chrome

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.

9. Cache is rarely the cause — check before blaming it

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'

10. Beware the shared object in a page-generator

A module-level element reused across pages gives every page identical element ids. Make repeated blocks functions, so each call mints fresh ids.

11. Plugins with a setup wizard need it running first

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 { }

Quick reference — symptom → cause

SymptomCause
Rows stack verticallyMissing container_type: "flex"
Column widths ignoredParent is boxed, not full-width
Element invisible but in the DOMWidget style used a non-prefixed key
Text unreadable over a photoOverlay opacity left at default
Edits don't appear, cache says missBuilder's element cache — republish as a new page
Whole page blank below the heroTheme injecting an animation rule with no library
Setting won't stick despite 200Plugin not REST-writable — needs a human
Phase 6

Verify like you don't trust yourself

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.

Screenshot properly, or you'll chase ghosts

The full-page screenshot trap Chromium's 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

Assert numbers, not vibes

Every page, at 390 px and 1440 px:

When something looks wrong, measure it

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.

Check every link, every time

for l in / /about/ /contact/ ...; do
  printf "%-24s %s\n" "$l" "$(curl -s -o /dev/null -w '%{http_code}' -L "$U$l")"
done
Phase 7

Forms, and the email problem nobody checks

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.

Always do both of these

1 · Store submissions in the database

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.

2 · Send through authenticated SMTP

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.

Test delivery properly

  1. Point the recipient at your own inbox first — never test into the client's.
  2. Submit a real message through the form.
  3. Confirm it arrived, including spam and trash. A "sent" status proves nothing.
  4. Only then set the client's address and submit one final confirming test.
Isolate before you conclude On this project three submissions all reported success and none arrived. Triggering a password-reset email to the same address from the same server — which arrived in seconds — proved the host's mail was fine and the fault was the form's mail path specifically. Always find a known-good control before blaming a component.

Check the domain's mail policy before choosing a From address

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.

Sender display names Keep the sender name plain. A display name containing a full stop — a company name ending in "Co." — produces a malformed header that some mail servers reject.
Phase 8

Protect the rankings you inherited

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.

1. Redirect every old URL — before cutover

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.

Check robots.txt before launch — every time Hosts routinely block search engines on preview domains: 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.

2. Structured data for local businesses

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.

3. Titles and descriptions

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.

4. Analytics and Search Console

Phase 9 · highest-risk step in the project

Go live without killing their email

The single most important rule in this guide Do not change the nameservers. Hosting panels push this because it's easier for them. It also moves all DNS — including MX, SPF, DKIM, DMARC and autodiscover records — to a provider that has none of them. The client's email stops, usually within the hour, and for a medical or trade business that's far worse than a website outage.

Audit DNS first

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.

Change exactly two records

RecordAction
@ APoint to the hosting server IP
www APoint to the same IP
MX · SPF · DMARC · autodiscover · NS · everything elseLeave untouched
Two practical details Lower the TTL to 600 seconds on both A records the day before. Rollback then takes ten minutes instead of a day.

Use the hosting server's stable IP, not a CDN IP. CDN addresses rotate — one observed changing within the same hour on this project. Take the value from the control panel, and sanity-check that it answers for the domain.
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Host: CLIENT.com" "http://<candidate-ip>/"

Immediately after the change

  1. Confirm the domain resolves to the new IP.
  2. Check robots.txt — the staging block must be gone.
  3. Update the site URL in WordPress from the temporary domain to the real one.
  4. Confirm SSL has issued and httphttps redirects. Allow up to an hour; a certificate warning in the first few minutes is normal — don't revert.
  5. Re-test every redirect on the live domain.
  6. Re-check MX and autodiscover, and have the client send and receive a test email.
  7. Submit the sitemap to Search Console.
Phase 10

Launch day checklist

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.

Content & build

    Technical

      Forms

        SEO

          DNS & email

            Handover

              Do not launch until these three are true A real form submission has been received in an inbox; robots.txt allows crawling; and the client has confirmed their email still works after the DNS change.
              Appendix

              Quick reference

              Endpoints used most

              PurposeEndpoint
              Confirm authGET /wp/v2/users/me?context=edit
              List every namespaceGET /wp-json/
              Pages (with builder data)GET /wp/v2/pages?per_page=100&context=edit
              Create / update pagePOST /wp/v2/pages[/<id>]
              Upload mediaPOST /wp/v2/media
              Front page & site settingsPOST /wp/v2/settings
              Install / activate pluginPOST /wp/v2/plugins
              Switch themePOST /elementor-one/v1/themes/<slug>/activate
              Create redirectPOST /redirection/v1/redirect

              Commands worth memorising

              # 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/"

              Time allocation that reflects reality

              PhaseShare of effort
              Finding the real brief and auditing15%
              Access, backup, assets15%
              Building the page system25%
              Verification and fixing what it finds25%
              Forms, SEO, redirects15%
              Cutover and post-launch checks5%

              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.

              The one-line summary Verify the brief, build only what the client can edit, measure everything you cannot see, and protect their email and rankings above your own deadline.

              VersAssist · Website Build Guide · v1.0 · September 2026