Onboarding a client
The full process, start to finish. Follow it in order — several steps depend on earlier ones, and the ordering of the DNS section in particular is what keeps a client's email working.
Roughly 90% of this is identical for every client. The genuinely bespoke part is the design; the backend contract is the same everywhere. Don't diverge it per client.
#How to use this document
Every section can be done two ways, and they produce the same result.
With Claude. Open a Claude Code chat in the workspace folder and paste the grey block from the step you're on. Claude does the work — creating records, setting configuration, running the checks — and you approve each action as it asks. You still make the decisions; you are not typing the commands.
By hand. Ignore the grey blocks and follow the numbered steps. They are complete on their own: every click path, field and value is written out.
The grey blocks are self-contained on purpose. You don't have to explain the project first, and you can paste them out of order.
What Claude cannot do, and will tell you so:
- Push to GitHub. It has no credentials. You push, from GitHub Desktop.
- Change DNS at the registrar. It has no login there, and this is the step that can take a client's email down.
- Enter passwords, or create accounts.
- Run the production database directly. Schema changes ship as SQL you paste into the Supabase editor — the database URL is write-only in Vercel and cannot be read back, by anyone.
Everything else it can do with you watching.
#Who can do which parts
Almost all of this can be done by anyone with an agency login, member or admin: creating the client and site record, setting the Search Console URL, GA4 property and Business Profile location, importing pages, pulling data, recording the Pages project, and ticking the site live.
Two things need an admin, and both for the same reason — they can't be undone from the interface:
- Connect Google. OAuth stores a refresh token belonging to whoever signs in, so the connection has to be the agency's own Google account. Connected with a personal one, the client's data stops the day that person leaves.
- DNS and nameservers, which aren't in the platform at all. A bad cutover takes the client's email down, and email doesn't roll back the way a website does.
Everything else is fair game. If you hit a wall, that's what it will be.
#Before you start, decide which path
Migrating — the client already has a site and it has traffic. Use migration-kit. Keep the
existing URLs.
Building fresh — new business, or an agreed redesign. Clone client-template.
The decision matters more than it looks, and there is a check that settles it: export the client's Search Console data first. If pages are ranking, you are migrating, whatever anyone called the job. On FRA we nearly launched a "new" site that would have 404'd 42 of 43 live URLs, including a homepage with 34,000 impressions.
#Survey what the old site does, not just what it says
A rebuild that copies the pages and nothing else quietly drops the parts people actually used. Before planning the build, walk the live site and write down:
- Interactive features — cost or quote calculators, booking forms, job or vacancy listings, filterable galleries, postcode checkers, live chat, customer logins, multi-step enquiry forms. Each is a keep / drop / replace decision for Oliver, not an assumption for you. Some are load-bearing for enquiries; some are abandoned.
- Plugins and third-party services — on WordPress most behaviour lives in plugins. Two bite every time: the SEO plugin owns titles, meta and a redirect list nobody has written down (export it before anything is switched off), and the form plugin owns the address enquiries are sent to.
- Images and media — where they're stored, whether you can get the originals rather than web-compressed copies, which are the client's own photos of real jobs (worth far more than stock), anything licensed where the licence may not transfer, and any large media like brochures or certificates linked from pages. Check Search Console for images ranking in Image Search — those paths are worth preserving like any other URL.
Record all of it in the client's BUILD-BRIEF.md, §2b.
#1. Create the client in the platform
Paste this to do it with Claude:
I'm onboarding a new client to the Majestic One platform. Create the tenant and site records for me in the dashboard at portal.majesticone.co.uk, then tell me the PUBLIC_SITE_ID and PUBLIC_SITE_INGEST_KEY. Also set the lead notification email — ask me what it should be. Client name, domain and notify address: <fill these in>
Dashboard → add the tenant (the client) → add the site. That produces the two values everything
else needs: PUBLIC_SITE_ID and PUBLIC_SITE_INGEST_KEY.
Both are shown on /sites/<id>/setup. If you can't find them, that's the page.
Set notifyEmail on the site now, not later. Without it the contact form submits
successfully and nobody is told. On a fire-safety or emergency-trade site that's the worst
failure available — the enquiry is captured and never answered.
#2. Build the site
Fresh:
git clone https://github.com/majesticonemain-a11y/client-template.git <client-repo>
cd <client-repo>
npm install
cp .env.example .env # fill SITE_ID + INGEST_KEY from step 1
Fill src/data/site.ts (name, NAP, services, areas), then npm run import to seed the
platform's page records from it. Then restyle the components — that's the bespoke part.
Migrating: follow migration-kit/README.md. prerender.mjs crawls the live site,
de-duplicates pages that render the same content under several URLs, and emits static HTML plus
sitemap, robots and _redirects.
#What every client site repo must contain
Check these exist before you go further. They are all in client-template, so a site cloned
from it starts correct — but a site that was built earlier, or adapted from somewhere else,
drifts. FRA was missing one of them and nobody noticed until its preview URL was already live
and indexable.
| File | What it does | Symptom if absent |
|---|---|---|
functions/_middleware.js |
301s *.pages.dev → the real domain |
The preview URL serves a full duplicate of the client's site and Google can index it. Nothing looks wrong. |
src/pages/robots.txt.ts |
Generates robots.txt from SITE_URL |
A hardcoded robots.txt advertises another client's sitemap. This happened to eleven sites at once. |
scripts/verify-build.mjs |
Blocks the build on broken links, blank pages, cross-domain URLs | Bad builds deploy silently. |
public/_redirects |
Path redirects, including /sitemap.xml → /sitemap-index.xml |
Audit tools report "no sitemap". On a migration, every old ranking URL 404s. |
src/pages/404.astro |
A real 404 | Unknown paths return 200 with the homepage, so Google indexes infinite duplicates of it. |
src/components/Analytics.astro |
Consent-gated GA4 | No analytics, or worse, analytics firing before consent. |
Two of those need a matching environment variable to do anything —
CANONICAL_HOST for the middleware and PUBLIC_GA4_ID for analytics. The file being present
is not the same as the feature working; see the env var table in step 5.
#Rules for this step
- Never build from scratch. The platform writes content into these sites, and every section type it can send must exist in the renderer or the page renders blank. Restyle the template freely; don't replace its plumbing.
- Never invent a trust claim — reviews, ratings, accreditations, certifications, insurance, memberships, years in business, project counts, case studies. All of it comes from the client. If you need a number and don't have it, ask.
- Structural things stay in the template — icons, slugs, image paths, schema, robots and sitemap URLs. Anything sourced from editable page data can be wiped by a content rewrite.
#3. Push to GitHub
Each client is its own repo under majesticonemain-a11y. GitHub Desktop or CLI, either is fine.
The repo is the website. Nothing exists anywhere else until it's pushed.
#4. Create the Cloudflare Pages project
Cloudflare → Workers & Pages → Create → Pages → Connect to Git → pick the client's repo.
- Build command:
npm run build - Output directory:
dist - Environment variable:
NODE_VERSION=20
The GitHub↔Cloudflare connection is already authorised at account level, so you're just picking a repo. If the new repo isn't listed, the GitHub app is scoped to selected repos — add it in GitHub → Settings → Applications → Cloudflare Pages.
Every repo now pins Node in
.nvmrc, but Cloudflare readsNODE_VERSIONand that wins. Set it anyway, and keep the two in step — if they ever disagree, the env var decides and.nvmrcbecomes a comment.
The first build will fail, and that is correct. With no environment variables the site has no identity, so the content API returns nothing and the build stops with:
Content API returned nothing — set PUBLIC_SITE_ID / PUBLIC_SITE_INGEST_KEY (.env)
That guard exists so a misconfigured site fails loudly instead of deploying with no pages. The next step clears it.
Note the project name and put it in the platform. Cloudflare gives every project a
<project>.pages.dev host, which serves a complete second copy of the client's site. On
/sites/<id>/setup there's a Cloudflare Pages project field — enter the name exactly as
Cloudflare shows it.
That's what lets the daily self-test confirm the copy redirects to the real domain once the site is live, rather than sitting there competing with the client for their own search terms. Without it the check can only warn that it doesn't know.
Watch the name: it is not always the repo name. The Big Conversion Company's repo is
thebigconversioncompany-site but its Pages project is thebigconversioncompany; HAL's
project is hal-construction, not hal-construction-site; the Dorset site's is
client-renderer. Copy it off the Cloudflare page rather than assuming.
#5. Set the environment variables
The site can't build without these. Create the client in the platform first (§1) — that's
where the first two come from, on /sites/<id>/setup.
Cloudflare → the Pages project → Settings → Environment variables → Production:
| Variable | Value |
|---|---|
PUBLIC_SITE_ID |
From the platform |
PUBLIC_SITE_INGEST_KEY |
From the platform |
PUBLIC_PLATFORM_URL |
https://majestic-system-theone.vercel.app |
CONTENT_API_URL |
Same as above |
SITE_URL |
The client's real domain, e.g. https://soakawayinstallation.co.uk — set this even before launch, because the sitemap is generated from it |
NODE_VERSION |
20 |
PUBLIC_GA4_ID |
Once GA4 exists — optional, add later |
PUBLIC_CONSENT_REQUIRED |
true unless agreed otherwise |
Then Deployments → Retry deployment. It should build.
CANONICAL_HOST is deliberately not in that list — it belongs on launch day (§13). Setting it
early makes the pages.dev preview redirect to a domain that isn't serving the new site yet,
which takes away the URL you were going to review on.
The next section does all of this in one command instead, if you'd rather.
#6. Configure Cloudflare in one command
Paste this to do it with Claude:
Configure the Cloudflare Pages project for <domain>. Run migration-kit/setup-client.mjs with --dry-run first, show me the output, and only run it for real once I say so. Then check every environment variable from the table in the onboarding doc is actually set, and tell me which are missing. Do not put the API token on the command line — read it from migration-kit/.env.
This is a Terminal command, not something you type into the dashboard or a chat window.
One-time setup. Create a Cloudflare API token — Cloudflare → My Profile → API Tokens → Create Token → Custom token, with these three permissions:
- Account · Cloudflare Pages · Edit
- Zone · Zone Settings · Edit
- Zone · DNS · Read
Your Account ID is in the right-hand sidebar of Workers & Pages. Put both in
migration-kit/.env (copy .env.example). That file is gitignored and stays on your machine.
Then, for each client: open Terminal, go to the workspace folder, and run:
cd "/path/to/M1 TEst/migration-kit"
node setup-client.mjs \
--project <pages-project-name> \
--domain <client-domain.co.uk> \
--site-id <PUBLIC_SITE_ID> --key <PUBLIC_SITE_INGEST_KEY> \
--ga4 G-XXXXXXX \
--dry-run
The \ at the end of each line just means "this continues below" — paste the whole block at
once. Drop --ga4 if there's no Analytics property yet.
Run it with --dry-run first. It prints every call it would make and changes nothing. When
the output looks right, run the same command again without that flag.
#Don't put the token in the command
You may see the older form with CF_API_TOKEN=xxx at the front. It still works, but a secret
typed into a command is not private: your shell saves it to ~/.zsh_history, any process on the
machine can read it out of ps, and if you run the command through an assistant it ends up in
the transcript. Keep it in .env and run the plain command.
#What it does
Sets all production env vars, adds the apex custom domain, turns off Email Address Obfuscation, and triggers a rebuild. Then it prints the three steps that genuinely can't be scripted — deploy hook, www→apex redirect, and Search Console.
Two choices in it are deliberate:
- Apex domain only.
wwwmust redirect, not serve a second copy — serving both created duplicate content on the Dorset site. - Email obfuscation off. Left on, Cloudflare rewrites addresses to
cdn-cgi/email-protectionand Ahrefs reports a 404 on every page.
#The environment variables it sets
Env vars in Cloudflare are the ones that count. .env in the site repo is local only and
gitignored — the "it worked locally" failure has bitten twice, both times GA4 and consent.
You rarely need to set these by hand, but you do need to be able to check them. Pages project → Settings → Environment variables (Production):
| Variable | Value | What breaks without it |
|---|---|---|
PUBLIC_SITE_ID |
from /sites/<id>/setup |
The site can't fetch its content or report conversions. |
PUBLIC_SITE_INGEST_KEY |
from the same page | Conversions are rejected — leads still save, attribution doesn't. |
PUBLIC_PLATFORM_URL |
https://majestic-system-theone.vercel.app |
Content fetch fails; pages fall back to built-in defaults. |
CONTENT_API_URL |
same value | Older name for the same thing; some renderers still read it. |
SITE_URL |
https://<domain> |
robots.txt and the sitemap advertise the wrong domain — this is how eleven sites once pointed at Ferndown's sitemap. |
CANONICAL_HOST |
<domain> |
functions/_middleware.js stops redirecting *.pages.dev → the real domain, so the preview URL gets indexed as duplicate content. |
PUBLIC_GA4_ID |
G-XXXXXXXXXX |
No analytics at all. Silent — the site looks perfect. |
PUBLIC_CONSENT_REQUIRED |
true |
See below. |
NODE_VERSION |
20 |
Build runs on Cloudflare's default Node and may fail or behave oddly. |
PUBLIC_CONSENT_REQUIRED stays true. The script defaults it to true; --no-consent
sets it to false and makes GA4 load before the visitor has chosen. Under UK GDPR/PECR,
analytics cookies are non-essential and need consent first, and a banner that appears after
tracking has started is not consent. There is no client on this estate where false is the
right answer.
Anything PUBLIC_* is inlined into the browser bundle at build time and is meant to be
visible — a GA4 measurement id and the ingest key are both readable in page source on every
site that has them. They are not secrets. Nothing that is a real secret belongs in a client
site's environment at all.
Changing an env var does not rebuild the site. Set it, then trigger a deploy — the deploy
hook, or a push. This is the same trap as rotating CRON_SECRET in Vercel.
#Before the DNS switch — the handover pack
Do not start section 7 until the client has been sent all three of these links. The DNS switch is the point of no return; the handover pack is what earns the client's sign-off to press that button — and it is far more persuasive sent before launch ("here is your new site, ready to go") than after.
Every handover pack is the same three links:
The live website — the
<project>.pages.devpreview. It serves the whole site with anX-Robots-Tag: noindexheader untilCANONICAL_HOSTis set at launch (the middleware handles this), so it cannot compete with the client's current site in Google. After launch it 301s to the real domain, so the link never goes stale. Warn the client that forms and calculators on the preview deliver real leads to the inbox.The report of what was done — a design-overview page at
reports.majesticone.co.uk/<client>/design/, built from the repo'smigration/IMPROVEMENTS.md. Real screenshots of every page type, the design system, the before/after performance table, and the honest audit list (what was broken, what we did). Every figure in it must be measured, not estimated — capture the "before" numbers while the old site is still up.The brand guidelines —
reports.majesticone.co.uk/<client>-brand/, per the pattern in the share repo's README (self-containedindex.html+ alogo/folder of downloadable files). SetbrandGuidelinesUrlon the site record so it also appears in the client's portal.
Both report pages live in the majestic-one-share repo — publishing is adding
a folder and pushing to main. The _headers file already sends noindex on
everything there.
Reference example (BCC, August 2026):
thebigconversioncompany.pages.dev ·
reports.majesticone.co.uk/thebigconversioncompany/design/ ·
reports.majesticone.co.uk/thebigconversioncompany-brand/
#After acceptance — archive the old site, then hand the client a copy
The client has said yes. The next thing that happens is not the DNS switch — it is the funeral arrangements for the old site. Once the domain moves, anything you didn't capture is gone, and some of it (form enquiries, plugin configurations) exists nowhere else.
Capture all of it, in this order of value:
- Hosting-panel full backup — the site files and the database, from the host's own backup tool (Hostinger, cPanel, etc.). This is the only copy that contains the WordPress database: posts, pages, users, settings.
- Form enquiries — plugin-stored submissions (Forminator, CF7 databases, Gravity Forms entries) are in the database, but export them separately as CSV from wp-admin too, so the client can open them without restoring WordPress. These are the client's sales records.
- Plugin inventory and configurations — a list of active plugins and versions, and exports of any plugin whose configuration is business logic (calculator formulas, redirect maps, SEO settings). On BCC the calculator formulas lived only inside Forminator's config.
- Static mirror — every page as served, plus assets and sitemaps, taken
with a crawler while the site is still up. This is the fallback that needs
no hosting access, and the reference for "what did the old page actually
say". (On BCC:
site-backups/<domain>-<date>/+ zip, with a README stating what is and isn't inside.) - DNS zone snapshot — every record, especially MX/TXT/DKIM. Section 6 requires this anyway; capture it here so the archive is complete.
Then deliver a copy to the client (WeTransfer or similar for the multi-GB zip) and keep a copy our end, stored with the client's files. Tell the client in writing what the archive contains and what it doesn't — a static mirror is not a database backup, and saying so now beats explaining it in a dispute later. The client owning a full copy of their old site is part of the handover, not a courtesy: it's their content, their enquiry history, and their rollback insurance.
Only when the archive exists in two places (theirs and ours) does the switchover begin.
#7. DNS and the custom domain
Paste this to do it with Claude:
I'm doing the DNS cutover for <domain>, following section 7 of the onboarding doc. Before I touch anything: capture the current zone — every MX, TXT, DKIM, DMARC and subdomain record — and write it to <repo>/docs/dns-cutover.md as a rollback reference. Tell me who the registrar is and who hosts the DNS, and whether the client has live email on this domain. Then walk me through the phases one at a time, checking each one before we move on. I will do the nameserver change myself.Claude can read DNS, verify each phase and tell you what is wrong. It cannot log in to the registrar — phase 5 is yours.
This is the only step you cannot roll back. A website reverts in two minutes. A client's lost email does not. Everything before phase 5 is reversible; phase 5 moves live traffic and live mail at the same moment.
Work through the phases in order. Each one says what it risks, so you know when you are still safe and when you are committed.
#Before you start: capture the existing zone
Write down every record that exists today, before you change anything. That list is your
rollback. fra-dorset-site/docs/dns-cutover.md is the worked example — copy its format.
Two facts to establish first, because both are commonly assumed wrong:
- Who is the registrar, and who hosts the DNS? They are often different companies. You change nameservers at the registrar (123-Reg, GoDaddy, Namecheap), even when the records you are copying live at the host (Hostinger, Krystal). Logging into the wrong one and not finding a nameserver field is the usual first stumble.
- Where does the client's email actually run? If there are MX records, assume mail is live until someone confirms otherwise. "We don't use that address" is not confirmation — check what the website's own footer and contact page advertise.
#Phase 1 — Add the zone (no risk, nothing changes yet)
- Cloudflare dashboard → Add a site → type the domain
- Choose the Free plan
- Cloudflare scans the current host and imports what it can find
- It shows two nameservers (
something.ns.cloudflare.com) — write these down, you need them in phase 5
Nothing is live. The old nameservers are still authoritative until phase 5, so you can edit freely here without affecting anyone.
#Phase 2 — Audit the imported records (no risk)
DNS → Records. Check every record from your capture exists. The scan is good, not perfect, and a record it missed only hurts after phase 5 — by which point mail is already down.
Check these especially:
| Type | Typical name | Why it matters |
|---|---|---|
| MX | @ |
Mail delivery. Note the priorities. |
| TXT | @ |
SPF (v=spf1 …). Missing → outbound mail lands in spam. |
| TXT | @ |
Domain verification (google-site-verification=…). Missing → Search Console de-verifies mid-migration, exactly when you need the data. |
| TXT | _dmarc |
DMARC policy. |
| CNAME | *._domainkey |
DKIM, usually three of them. Missing → mail fails signature checks. |
| CNAME | autodiscover, autoconfig |
Outlook/Apple Mail auto-setup. |
| A / CNAME | anything else | Subdomains you already run — a client portal, a docs site, an FTP host. These matter as much as email and are the easiest to forget, because nobody thinks of them as "the website". |
Set every mail record to DNS-only (grey cloud). Cloudflare's scan imports records proxied by default, and a proxied record answers with Cloudflare's own IPs instead of the real target. The proxy only handles HTTP/HTTPS, so for mail this breaks things without breaking them loudly: DKIM stops verifying and mail scores as spam rather than bouncing. MX and TXT cannot be proxied, so it is only ever the CNAMEs.
#Phase 3 — Remove the old site's records (no risk yet)
Delete the records pointing at the old host — usually the apex A records and the www
CNAME. Phase 4 replaces them.
Still safe: the old nameservers are authoritative, so nothing you do here is visible yet.
#Phase 4 — Attach the domain to Pages (no risk yet)
- Workers & Pages → the project → Custom domains → Set up a custom domain
- Enter the apex (
client.co.uk). Cloudflare sees the zone is in your account and creates the record itself. - Repeat for
www.client.co.uk
Then SSL/TLS → Overview → confirm the mode is Full (strict). If it is on Flexible you get an infinite redirect loop the moment DNS switches. Thirty seconds now, or a broken site and a confusing hour later.
#Phase 5 — Change the nameservers (irreversible — this moves everything)
Baseline first: send a test email to the client's address and confirm it arrives. If mail is broken after the switch you need to know whether you broke it.
- Log in to the registrar — not the hosting panel
- Manage the domain → Nameservers
- Replace the existing pair with the two Cloudflare gave you in phase 1
- Save
- Check the registrar has no web forwarding or URL redirect enabled on the domain. It overrides DNS and will hijack the site no matter what Cloudflare says.
Propagation is usually 15–60 minutes.
#Phase 6 — Verify
d=client.co.uk
for r in NS A MX TXT; do echo "== $r =="; dig +short $r $d; done
echo "== site =="; curl -s -o /dev/null -w '%{http_code}\n' https://$d
echo "== www redirects to apex =="; curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' https://www.$d/
Expect: Cloudflare nameservers, the MX and TXT records unchanged from your capture, 200 on
the apex, and 301 from www to the apex.
Then send a test email in and out of the client's address. Both directions — inbound proves MX, outbound proves SPF and DKIM.
Finally, check a handful of URLs that had traffic still return 200 at their original paths.
Nothing that ranked may 404.
#Phase 7 — Lock in the canonical host
- Pages project → Settings → Variables →
CANONICAL_HOST= the domain - Deployments → latest → Retry deployment — env var changes do not rebuild on their own
This makes <project>.pages.dev 301 to the real domain, so Google cannot index the preview URL
as duplicate content.
Add the www → apex 301 as a Redirect Rule (wildcard pattern https://www.* → https://${1},
status 301). The rule only fires on proxied traffic, so if www is grey-clouded it will
silently do nothing.
#Phase 8 — Clean up
- Submit
https://<domain>/sitemap-index.xmlin Search Console - Decommission the old website files at the previous host
- Keep the old host's email service. Only the website goes.
#If it goes wrong after phase 5
Remove the custom domain from the Pages project and restore the old A records in Cloudflare.
The site reverts in minutes and the zone keeps every mail record. You do not need to move the
nameservers back.
#What Cloudflare switches on by itself
Adding a zone enables two AI-related settings without asking. Neither breaks anything, but the
first time you read a client's robots.txt and find rules nobody wrote, this is why.
Your robots.txt will not match your repo. src/pages/robots.txt.ts generates four lines;
what gets served has a Cloudflare "Managed content" block prepended — content signals, plus
Disallow: / for around nine AI crawlers. Your own rules still appear underneath and your
Sitemap: line survives. Nothing is wrong.
Search engines are unaffected. Googlebot, BingBot and Baidu are all allowed. There is no
Disallow: / for the generic User-agent: *. Blocking Google would be catastrophic and is not
what this does — check for it anyway if a site's traffic ever falls off a cliff.
The split is between crawlers that answer questions and crawlers that harvest for training:
| Status | Examples | |
|---|---|---|
| Search engines | allowed | Googlebot, BingBot, Baidu |
| AI search & assistants | allowed | Claude-SearchBot, OAI-SearchBot, PerplexityBot, ChatGPT-User, Perplexity-User, DuckAssistBot, MistralAI-User, Applebot |
| AI training crawlers | blocked | GPTBot, ClaudeBot, CCBot, Bytespider, Amazonbot, FacebookBot, Google-CloudVertexBot, Meta-ExternalAgent |
That is the right way round for our clients. When someone asks an assistant "who does X near me", the bot that fetches the site is a search bot, and those are allowed — which is what the platform's AI Visibility page is measuring. What is blocked is bulk ingestion into training sets, which does nothing for the client either way.
The per-crawler list is read-only while this is on. AI Crawl Control → Security shows 32 crawlers with individual Block switches, but they are overridden by the zone-level Block AI Bots setting — clicking one just shows a tooltip saying so. To control a single crawler you must disable Block AI Bots first, which unblocks all fifteen at once, and then re-block the others by hand. On a twelve-site estate that is roughly 170 toggles and a window on each zone where nothing is blocked.
So leave it alone unless a client specifically asks to allow AI training use of their
content. The gap it leaves — Claude-User, the fetch when a Claude user asks about a page — is
not worth dismantling a working default for, because every other assistant's equivalent is
already allowed.
#8. Deploy hook
Cloudflare → the project → Settings → Builds & deployments → Deploy hooks → create. Paste the bare URL into the platform's Publishing box on the site record.
This is what lets the platform rebuild the site after it publishes content. Without it, approved changes sit in the platform and never appear.
#9. Analytics and Search Console
- Set
PUBLIC_GA4_ID(and the consent choice) — in Cloudflare, not just.env. - Google Search Console → add a Domain property → verify with a TXT record in Cloudflare DNS
→ submit the sitemap (
sitemap-index.xmlon Astro sites). - Platform →
/sites/<id>/setup→ Connect Google, then set the GSC site URL, the GA4 property id and the Business Profile location.
Both halves are required. Without the platform's Google connection, every SEO figure in the client's reports stays at zero. Expect GSC data to lag days to weeks on a new property — leads and conversions are the only instant numbers.
#10. Verify — don't sign it off until these pass
Paste this to do it with Claude:
Verify the build and deployment for <domain>. Run npm run build and the analytics verifier, then check from the live site: every URL in the sitemap returns 200, an unknown path returns 404, the pages.dev URL 301s to the real domain, www redirects to the apex with the path preserved, and robots.txt points at the right sitemap. Report what actually happened, including anything that failed.
Every check below has an explicit pass condition. "It looked fine" is not one of them: most of the failures this list exists to catch look completely fine.
Run them in this order. The automated ones are cheap and rule out whole classes of problem before you spend time clicking.
#Check 1 — the build guard
npm run build
Pass: verify-build reports clean and the page count matches what you expect.
It blocks on broken internal links, blank pages and cross-domain contamination. If it fails, it is almost certainly right — it once caught every site in the estate advertising the wrong client's sitemap. A page count that dropped is as much a failure as an error: it means pages stopped generating and nothing said so.
#Check 2 — analytics actually send
node migration-kit/verify-analytics.mjs https://<domain> --id G-XXXXXXX
Pass: it reports the hit fired.
This matters far more than it sounds. It loads the page in a real browser and asserts the GA
request is actually sent — a tag can be present, load cleanly, fill dataLayer and never send
anything. That happened to every site at once in June 2026 and Tag Assistant showed nothing
wrong. On a consent-gated site you have to accept cookies for this to pass, which is correct.
#Check 3 — a real lead reaches a human
Submit the contact form on the live site, as a visitor would.
Pass: it arrives in both the dashboard inbox and the notify email inbox.
Both, not either. The dashboard alone means the client never hears about it. This is the single most expensive thing to get wrong — on a fire-safety or emergency trade, a swallowed enquiry is someone who needed help and got silence.
#Check 4 — the live domain serves the new site
View source on the live domain.
Pass: you can see /_astro/ asset paths, and none of the old site's (/wp-content/).
DNS and caching both lie about this. Looking at the HTML is the only reliable answer.
#Check 5 — nothing that ranked has 404'd
Migrations only, and this is the one that costs real money.
Take the URLs from the client's Search Console export — every page with impressions, not a sample.
Pass: every one returns 200 at its original URL, or 301 to a sensible equivalent.
Any 404 is a fail, however unimportant the page looks.
#Check 6 — look at it
Open the site in a browser. Phone and desktop. Click the nav, submit the form, load a few deep pages.
Pass: nothing is visually broken and every interactive thing does what it says.
This is not padding. Several of the worst bugs this project has shipped were invisible in code and obvious on screen: white text on a white background, images at the wrong aspect ratio, a booking form with no handler that silently did nothing. No automated check catches those.
#Check 7 — the platform knows about the site
Open /sites/<id>/setup and read the go-live checklist.
Pass: nothing is marked blocking.
It reads the site's real configuration rather than a list someone remembered to update. Section 10 covers what each item means and why it fails silently.
#11. Configure the platform — the part that fails silently
Paste this to do it with Claude:
Open the go-live checklist at /sites/<id>/setup for <domain> and tell me what is still missing. Then set what you can from evidence that already exists — service areas and tracked areas from the site's own area pages, the Google Business Profile from the dropdown. Ask me about anything that needs a judgement call, especially target keywords and competitors. Flag it if the tracking cost goes above the standard package.
Everything above is infrastructure: if you get it wrong, something visibly breaks. This section is the opposite. Leave any of it blank and nothing errors — no page looks wrong, no build fails. It surfaces weeks later as a lead nobody answered, or a suggestion to build a town page that arrives filed as a blog post.
Leave "This site is live" switched off until launch day. It's the first panel on the setup page. The daily self-test judges a site on whether its Search Console and Business Profile data is genuinely arriving — and a site that hasn't launched has none, by definition. Left ticked during a build, it fails the check every morning until people stop reading the email, which is the one thing that alert cannot afford. Turn it on once the domain is switched over. §13 covers that.
Work through the go-live checklist on /sites/<id>/setup. It reads the site's real
configuration and lists what's missing, marks which items would break something, and links
straight to the page that fixes each one. Don't sign a site off with anything still marked
blocking.
What it covers and why each one matters:
| Set this | Where | If you skip it |
|---|---|---|
| Lead notification email | /leads |
The form submits fine and nobody is told. Worst failure available. |
| Deploy hook | /setup |
Approved changes sit in the database and never reach the live site. |
| Pages imported | /pages |
The platform can't see the site's structure and invents target URLs. |
| Search Console | /setup |
No query data at all — every recommendation is guesswork. |
| Analytics | /setup |
Nothing to measure a change against. |
| Service areas | /intel |
Location content isn't recognised: "build a Bournemouth page" arrives as a blog post. |
| Tracked areas | /seo |
Rankings measured nationally only — a page at #2 in its town reads as #40. |
| Target keywords | /seo |
No way to tell whether anything worked. |
| Competitors | /battle-plan |
No battle plan, no share of voice. |
| Google Business Profile | /setup |
No map pack. For a local trade that's usually where the calls come from. |
The two "areas" fields are different things and you need both. Tracked areas (/seo) drive
rank tracking per town. Service areas (/intel) tell the AI which places this client actually
covers. They are set in different screens and neither is required, which is how
dorsetheatpumpinstaller.co.uk ended up with neither — its Bournemouth opportunity, 1,095
impressions sitting at position 43, was queued as a blog post for months because nothing knew
Bournemouth was one of its towns.
#12. Brand guidelines
Every client gets a brand guidelines page — logo, colour, typography, voice, and downloadable
logo files. FRA Dorset's is the reference: majestic-one-share/fra-dorset-brand/.
These live in majestic-one-share, not on the client's own website. Hosting an agency
deliverable on the client's production site means it competes for crawl budget, relies on a meta
tag to stay out of Google, and has to be special-cased in verify-build and the sitemap. In
majestic-one-share, _headers sends noindex, nofollow as a real HTTP header on every path —
which covers the logo files too, not just the HTML.
majestic-one-share/<client>-brand/
index.html <- the page
logo/ <- svg + png, on-light / on-dark / transparent / mark
Asset paths inside the page must be relative (logo/x.svg), so it works at whatever slug it
lands on. Commit, push, and it deploys.
Then set brandGuidelinesUrl on the site record — on the site overview page, /sites/<id>,
not the setup wizard — so it appears in the client's portal.
The link is unlisted, not private: anyone who has it can open it. That is fine for a client's own logo and colours. Don't put anything in there you wouldn't want forwarded.
#13. Launch day — the full sweep
Paste this to do it with Claude:
<domain> has just gone live. Work through the launch-day sweep in section 13 of the onboarding doc and report what passes and what doesn't. Check every page renders in a browser at desktop, tablet and mobile — actually look, don't just read the markup. Verify the email address in the footer resolves to a real mailbox. I'll submit the test lead and the sitemap myself; tell me when to.
Do this after the domain is serving the new site, in one sitting. Everything here has been missed at least once on this estate, and every one of them fails quietly.
Start by ticking "This site is live" on /sites/<id>/setup. Until you do, the site is
excluded from the daily self-test — which was right while it was being built and is wrong the
moment it is serving real visitors. From that tick onwards you get told each morning if its
data stops arriving, if the pages.dev copy isn't redirecting, or if the live site is telling
Google not to index it.
Also confirm CANONICAL_HOST is set on the Pages project. Without it the pages.dev host
keeps serving a full copy of the site instead of redirecting to the real domain. The middleware
noindexes it either way, so nothing is indexed — but it should be a redirect once you are live,
and the self-test will flag it the next morning if it isn't.
Work top to bottom. If something fails, fix it before moving on — a broken form matters more than a sitemap.
#Does it work?
- Submit a real test lead through the actual form on the live domain. Confirm it reaches both the dashboard inbox and the notify email. Not the preview URL — the real one.
- Click every navigation item, header and footer. Every link, including the logo.
- Call the phone number shown in the header. Check it dials the right number, and that it is the client's number and not one carried over from the template.
- Check the email address in the footer and on the contact page actually exists.
Send one to it. FRA published
hello@before anyone had confirmed there was a mailbox behind it.
#Does it look right?
- Every page, top to bottom, in a browser. Not the code. White-on-white text, an image at the wrong aspect ratio, a button the same colour as its background — all of these have shipped here and all were invisible in the markup.
- Mobile (375px) and tablet (768px). Check the nav opens, nothing overflows sideways, and tap targets aren't touching.
- Dark mode, if the site or the visitor's OS uses it.
- Images: every one loads, none stretched, all have alt text.
- Print the contact page. Rare, but clients do it, and a dark hero can produce a black page.
#Will Google index it correctly?
-
/sitemap-index.xmlreturns 200 and lists the pages you expect — count them. -
/robots.txtpoints at that sitemap and has no strayDisallow: /. - Submit the sitemap in Search Console.
-
<project>.pages.dev301s to the real domain. If it returns 200 you have a complete duplicate of the client's site indexable —CANONICAL_HOSTunset, or the middleware missing. -
www301s to the apex (or whichever is canonical), with the path preserved. Test a deep URL, not just the homepage: a redirect that drops the path sends every page to the homepage. - An unknown path returns 404, not 200. A soft 404 is worse than a hard one.
- On a migration: every URL that had impressions returns 200 or 301. Take the list from the client's Search Console export. Nothing that ranked may 404.
#Is it being measured?
- Accept cookies, then confirm the GA4 hit fires. Watch for the request to
google-analytics.com/g/collectwithen=page_view. A tag can load, filldataLayer, look perfect in DevTools and never send anything.bash node migration-kit/verify-analytics.mjs https://<domain> --id G-XXXXXXX - Decline cookies and confirm nothing fires. The consent banner is not decoration.
- Search Console and Analytics are connected in the platform, and the GA4 property id
(the number) is set — that is not the same as the
G-measurement id. - The go-live checklist on
/sites/<id>/setupshows nothing blocking.
#Then tell the client
- Send the brand guidelines link.
- Confirm where their enquiries will arrive, and ask them to whitelist the sender.
#14. The follow-up, three to seven days later
Paste this to do it with Claude:
<domain> launched <date>. Do the follow-up checks from section 14: what does Search Console say about indexing and the sitemap, has a real lead arrived, is Analytics recording sessions from more than just us, and on a migration has anything that ranked before dropped? Compare against the pre-launch export where there is one. Tell me plainly if something looks wrong.
Book it in when you launch, because the failures below cannot be seen on launch day. They need Google to have crawled, or a real member of the public to have used the site.
- Search Console → Pages. Anything under "Not indexed" that should be indexed? Expect some "Discovered – currently not indexed" on a new site; investigate anything marked Excluded by robots.txt, Redirect error or Soft 404.
- Search Console → the sitemap. Read, and how many URLs discovered? A sitemap submitted but never fetched means it 404s or robots.txt blocks it.
- Has a real lead arrived? If the site had enquiries before and has had none since launch, assume the form is broken until you have proved otherwise. Submit another test.
- Analytics has real sessions, from more than just you. Compare against the same period before launch — a big drop means tracking, not traffic.
- On a migration: has anything that ranked dropped? Compare positions with the pre-launch export. A page that fell from 4 to 40 is usually a redirect that lost the path or a rewritten title.
- Search
site:<domain>in Google. Are the pages you expect there — and is thepages.devcopy absent? - Re-run the site health check in the platform.
- Check the deploy hook works — approve a small content change and confirm it reaches the live site.
If all of that is clean at a week, the launch is done.
#Known friction — expect these
- Cloudflare "Name already in use" when adding an env var → you must edit, not add.
- GitHub Desktop "no files to commit" → it already auto-committed; just push.
- There is still no "delete page" in the platform. Removing a page needs SQL. Ask before doing it.
- GA Realtime lags on a brand-new property, and ad blockers produce false alarms. Use
verify-analytics.mjsrather than guessing.
#Standard defaults for every client
Email obfuscation off · www → apex 301 · *.pages.dev → domain 301 (handled in the
renderer) · preserve MX/TXT on any DNS move · NODE_VERSION=20 · build npm run build ·
output dist.
Internal documentation. Not for clients, and not for search engines — this page sends noindex, but the link itself is the only thing keeping it private.