Skip to content

Testing & Validation

A migrated or freshly built Cloudflare Pages site must be thoroughly validated before going live. This guide covers every testing layer — from automated file checks to visual regression, performance auditing, form submission validation, and SEO verification.


Every Cloudflare Pages project requires validation across these dimensions:

Content Accuracy

Missing pages, broken HTML, wrong text. Caught by the automated test suite.

Link Integrity

404s, broken internal/external links. Caught by linkinator and the test runner.

Asset Completeness

Missing CSS, JS, images, fonts. Caught by the test suite and manual review.

Visual Fidelity

Layout shifts, missing styles, font changes. Caught by Playwright visual diff.

Form Functionality

Submission failures, email delivery, modals. Caught by manual + automated form tests.

Performance

Slow LCP, high TBT, large CLS. Caught by Lighthouse CLI.

SEO

Missing meta tags, broken structured data, sitemap. Caught by manual + automated checks.

Accessibility

Missing alt text, contrast issues, keyboard nav. Caught by Lighthouse and axe-core.

Security Headers

Missing HSTS, CSP, X-Frame-Options. Caught by _headers file inspection.

Mobile Responsiveness

Layout breakage on small screens. Caught by Playwright viewports and DevTools.

graph LR
    A[1. Automated<br/>Test Suite] --> B[2. Visual<br/>Regression]
    B --> C[3. Link<br/>Checking]
    C --> D[4. Form<br/>Testing]
    D --> E[5. Lighthouse<br/>Audit]
    E --> F[6. Mobile<br/>Responsive]
    F --> G[7. SEO<br/>Validation]
    G --> H[8. Pre-Deploy<br/>Checklist]

The automated test suite validates the static build output before deployment. It spins up a local HTTP server, fetches critical URLs, and checks for required files, valid assets, and disallowed references.

CheckDescriptionStatus
Required files existindex.html, 404.html, theme CSS, logo, robots.txtRequired
Critical URLs return 200Homepage, contact, about, key service pagesRequired
CSS assets loadEvery <link> stylesheet referenced in HTML exists locallyRequired
No stray .local URLsNo leftover WordPress development URLs in outputRequired
Valid HTML structurePages contain <html tagRequired
Inline critical CSSIf no external CSS, verify inline critical CSS presentRecommended

This is based on the real test runner used in production migrations:

tests/run-tests.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const OUTPUT_DIR = path.join(__dirname, '..', 'sitename-static-build');
const PORT = 4174;
const HOST = '127.0.0.1';
// Pages that MUST return 200
const CRITICAL_URLS = [
'/',
'/contact/',
'/about/',
'/services/',
'/privacy-policy/'
];
// Files that MUST exist in the build output
const REQUIRED_FILES = [
'index.html',
'404.html',
'robots.txt',
'wp-content/themes/yourtheme/style.css',
'wp-content/themes/yourtheme/images/logo.webp'
];
function resolveFileFromUrl(urlPath) {
const cleanPath = urlPath.split('?')[0].split('#')[0];
let filePath = path.join(OUTPUT_DIR, cleanPath);
if (cleanPath.endsWith('/')) {
filePath = path.join(OUTPUT_DIR, cleanPath, 'index.html');
} else if (!path.extname(cleanPath)) {
filePath = path.join(OUTPUT_DIR, cleanPath, 'index.html');
}
return filePath;
}
function createStaticServer() {
return http.createServer((req, res) => {
const filePath = resolveFileFromUrl(req.url || '/');
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
res.statusCode = 404;
res.end('Not Found');
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = ext === '.html' ? 'text/html' :
ext === '.css' ? 'text/css' :
ext === '.js' ? 'application/javascript' :
ext === '.webp' ? 'image/webp' :
ext === '.woff2' ? 'font/woff2' :
'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
});
});
}
// Extract local CSS/JS assets from HTML
function getLocalAssetsFromHtml(html) {
const assets = new Set();
const regex = /<(?:link|script)[^>]+(?:href|src)=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = regex.exec(html)) !== null) {
const asset = match[1];
if (asset.startsWith('/') && !asset.startsWith('//')) {
if (!asset.includes('wp-json') && !asset.includes('oembed')) {
assets.add(asset);
}
}
}
return [...assets];
}
// Walk the output directory to find all HTML files
function walkDir(dir, fileList = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath, fileList);
} else {
fileList.push(fullPath);
}
}
return fileList;
}
async function run() {
// 1. Check output directory exists
assert.ok(fs.existsSync(OUTPUT_DIR), `Missing output directory: ${OUTPUT_DIR}`);
// 2. Check required files exist
for (const relPath of REQUIRED_FILES) {
const fullPath = path.join(OUTPUT_DIR, relPath);
assert.ok(fs.existsSync(fullPath), `Missing required file: ${relPath}`);
}
// 3. Start local server and run HTTP checks
const server = createStaticServer();
await new Promise((resolve) => server.listen(PORT, HOST, resolve));
try {
// 4. Check critical URLs return 200
for (const urlPath of CRITICAL_URLS) {
const response = await fetch(`http://${HOST}:${PORT}${urlPath}`);
const text = await response.text();
assert.strictEqual(response.status, 200, `Expected 200 for ${urlPath}, got ${response.status}`);
assert.ok(text.includes('<html'), `Missing HTML tag for ${urlPath}`);
}
// 5. Check CSS assets load
const home = await fetch(`http://${HOST}:${PORT}/`);
const homeHtml = await home.text();
const assets = getLocalAssetsFromHtml(homeHtml).filter(a => a.endsWith('.css'));
for (const asset of assets) {
const res = await fetch(`http://${HOST}:${PORT}${asset}`);
assert.strictEqual(res.status, 200, `Missing CSS asset: ${asset}`);
}
// 6. Check for stray .local URLs
const htmlFiles = walkDir(OUTPUT_DIR).filter(f => f.endsWith('.html'));
const disallowedHost = 'sitename.local';
for (const filePath of htmlFiles) {
const content = fs.readFileSync(filePath, 'utf8');
assert.ok(
!content.includes(disallowedHost),
`Found stray WordPress URL "${disallowedHost}" in ${filePath}`
);
}
} finally {
await new Promise((resolve) => server.close(resolve));
}
console.log('All tests passed.');
}
run().catch((err) => {
console.error('Test suite failed:', err.message);
process.exit(1);
});
Terminal window
# Add to package.json scripts:
# "test": "node tests/run-tests.js"
npm test
All tests passed.
Pass

Update these arrays for each migration project:

// CRITICAL_URLS - Every important page on the site
const CRITICAL_URLS = [
'/',
'/contact/',
'/about/',
'/services/botox/',
'/services/fillers/',
'/gallery/',
'/privacy-policy/'
];
// REQUIRED_FILES - Files that must exist in build output
const REQUIRED_FILES = [
'index.html',
'404.html',
'robots.txt',
'sitemap.xml',
'wp-content/themes/yourtheme/style.css',
'wp-content/themes/yourtheme/images/logo.webp'
];
// disallowedHost - Your local WordPress hostname
const disallowedHost = 'yoursite.local';

Visual regression compares screenshots of the original WordPress site against the static Cloudflare Pages output, pixel by pixel. This catches CSS differences, missing fonts, layout shifts, and broken image loading.

Terminal window
npm install playwright pixelmatch pngjs --save-dev
npx playwright install chromium
graph TD
    A[Launch Playwright<br/>headless Chromium] --> B{For each URL}
    B --> C[Screenshot WordPress<br/>source - baseline]
    B --> D[Screenshot static<br/>build - candidate]
    C --> E[Compare pixels<br/>using pixelmatch]
    D --> E
    E --> F{Mismatch ><br/>threshold?}
    F -->|Yes| G[Save diff image<br/>and flag FAIL]
    F -->|No| H[Mark PASS]
    G --> I[Report Results]
    H --> I
VariableDefaultDescription
VISUAL_WAIT_MS5500Wait time (ms) for lazy-loaded assets before screenshot
PIXELMATCH_THRESHOLD0.1Color sensitivity (0 = exact, 1 = loose)
MAX_MISMATCH_PERCENT1.0Max % of pixels that can differ before failure
TRIGGER_PERFMATTERStrueSimulate user interaction to trigger Perfmatters delayed JS/CSS
VISUAL_HIDE_SELECTORS(see below)CSS selectors to hide dynamic/third-party elements
SOURCE_BASEhttps://sitename.localWordPress source URL
TARGET_BASEhttp://127.0.0.1:4175Static build server URL
VISUAL_URLS(project-specific)Comma-separated URLs to compare

Dynamic elements (cookie banners, chat widgets, reCAPTCHA badges) will always differ between captures. Hide them with VISUAL_HIDE_SELECTORS:

const VISUAL_HIDE_SELECTORS = [
// Accessibility widgets
'[id*="userway"]',
'[class*="userway"]',
'#userway-widget',
'.uwy',
// reCAPTCHA
'.recaptcha',
'.g-recaptcha',
// Cookie consent banners
'.cky-consent-container',
'.cky-overlay',
'.cky-btn-revisit-wrapper',
'.cc-window',
'.cc-banner',
// Chat widgets
'#tidio-chat',
'.intercom-launcher',
// Date/time elements
'.dynamic-date',
'.current-year'
].join(', ');
if (TRIGGER_PERFMATTERS) {
// Simulate user interaction to trigger delayed scripts
await page.mouse.move(10, 10);
await page.mouse.move(200, 200);
await page.evaluate(() => {
window.dispatchEvent(new Event('mousemove'));
window.dispatchEvent(new Event('scroll'));
});
// Scroll through the full page to trigger lazy-loaded content
const scrollHeight = await page.evaluate(() => document.body.scrollHeight);
const viewportHeight = await page.evaluate(() => window.innerHeight);
for (let y = 0; y < scrollHeight; y += Math.max(200, Math.floor(viewportHeight * 0.8))) {
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
await page.waitForTimeout(150);
}
await page.evaluate(() => window.scrollTo(0, 0));
}
Terminal window
# Add to package.json scripts:
# "test:visual": "node tests/visual-diff.js"
npm run test:visual
PASS / -> 0.12% mismatch
PASS /contact/ -> 0.45% mismatch
PASS /about/ -> 0.08% mismatch
Visual diff suite passed.

Pass All pages within threshold.

When a test fails, diff images are saved to visual-diff/diff/:

visual-diff/
├── baseline/ # WordPress screenshots
│ ├── home.png
│ └── contact.png
├── candidate/ # Static build screenshots
│ ├── home.png
│ └── contact.png
└── diff/ # Pixel difference highlights (only for failures)
└── contact.png # Red pixels = differences

Open the diff image to see exactly where visual differences occur. Red-highlighted pixels show mismatches.

ProblemSolution
Fonts not loaded in screenshotIncrease VISUAL_WAIT_MS to 7000-8000
Slider/carousel in different stateAdd CSS to freeze first slide (see capturePage in visual-diff.js)
Cookie banner covering contentAdd banner selectors to VISUAL_HIDE_SELECTORS
Small anti-aliasing differencesIncrease PIXELMATCH_THRESHOLD to 0.15
Page height differs slightlyThe script normalizes sizes automatically (pads shorter image with white)
Too many false positivesIncrease MAX_MISMATCH_PERCENT to 2.0 or 3.0

Lighthouse measures Performance, Accessibility, Best Practices, and SEO from the command line.

Terminal window
npm install -g lighthouse
# Or use per-project:
npm install lighthouse --save-dev
Terminal window
npx lighthouse http://localhost:4173/ \
--output=json \
--output-path=./lighthouse-report.json \
--chrome-flags="--headless"

Add these to package.json:

{
"scripts": {
"audit:mobile": "npx lighthouse http://localhost:4173/ --output=json --output-path=./lighthouse-mobile.json --chrome-flags=\"--headless\"",
"audit:desktop": "npx lighthouse http://localhost:4173/ --output=json --output-path=./lighthouse-desktop.json --chrome-flags=\"--headless\" --preset=desktop",
"audit:all": "npm run audit:mobile && npm run audit:desktop",
"audit:staging": "npx lighthouse https://staging.yoursite.com/ --output=json --output-path=./lighthouse-staging.json --chrome-flags=\"--headless\"",
"audit:compare": "echo '=== BASELINE ===' && cat lighthouse-baseline.json | jq -r '.categories.performance.score * 100 | floor' && echo '=== CURRENT ===' && cat lighthouse-mobile.json | jq -r '.categories.performance.score * 100 | floor'"
}
}
Score RangeRatingBadgeAction
90-100GoodPassShip it
50-89Needs improvementWarningFix before launch
0-49PoorFailInvestigate immediately
Terminal window
# Extract performance score
cat lighthouse-report.json | jq '.categories.performance.score * 100'
# Extract all category scores
cat lighthouse-report.json | jq '{
performance: (.categories.performance.score * 100),
accessibility: (.categories.accessibility.score * 100),
bestPractices: (.categories["best-practices"].score * 100),
seo: (.categories.seo.score * 100)
}'
# Extract Core Web Vitals
cat lighthouse-report.json | jq '{
LCP: .audits["largest-contentful-paint"].displayValue,
TBT: .audits["total-blocking-time"].displayValue,
CLS: .audits["cumulative-layout-shift"].displayValue
}'
MetricTargetWhat It Measures
LCP (Largest Contentful Paint)< 2.5sTime to render largest visible content
TBT (Total Blocking Time)< 200msMain thread blocking during load
CLS (Cumulative Layout Shift)< 0.1Visual stability (layout jumps)
FCP (First Contentful Paint)< 1.8sTime to first visible content
SI (Speed Index)< 3.4sHow quickly content is visually populated
  1. Run baseline audit against WordPress original:

    Terminal window
    npx lighthouse https://sitename.local/ \
    --output=json \
    --output-path=./lighthouse-baseline.json \
    --chrome-flags="--headless --ignore-certificate-errors"
  2. Run audit against static build:

    Terminal window
    npx lighthouse http://localhost:4173/ \
    --output=json \
    --output-path=./lighthouse-optimized.json \
    --chrome-flags="--headless"
  3. Compare scores:

    Terminal window
    echo "=== BASELINE ==="
    cat lighthouse-baseline.json | jq '.categories.performance.score * 100 | floor'
    echo "=== OPTIMIZED ==="
    cat lighthouse-optimized.json | jq '.categories.performance.score * 100 | floor'
IssueLighthouse AuditFix
Render-blocking CSS”Eliminate render-blocking resources”Inline critical CSS, defer rest
Unoptimized images”Properly size images”Convert to WebP, add width/height
No text compression”Enable text compression”Cloudflare Brotli (enabled by default)
Unused JavaScript”Reduce unused JavaScript”Remove analytics/tracker scripts
Missing alt text”Image elements do not have [alt] attributes”Add alt attributes to all <img>
Missing meta description”Document does not have a meta description”Add <meta name="description">
Large DOM”Avoid an excessive DOM size”Remove unused HTML elements

Validate that every internal link resolves and every external link is reachable.

Terminal window
# Install
npm install -g linkinator
# Check local static build
npx linkinator http://localhost:4173/ --recurse
# Check deployed site
npx linkinator https://staging.yoursite.com/ --recurse
# Skip external links (faster, internal only)
npx linkinator http://localhost:4173/ --recurse --skip "^(?!http://localhost)"
# Output as JSON
npx linkinator http://localhost:4173/ --recurse --format json > link-report.json
IssueSymptomFix
Trailing slash mismatch/about returns 404, /about/ worksEnsure consistent trailing slashes in all <a href>
Stray WordPress URLsLinks to sitename.localRun crawler link rewrite step again
Anchor links to missing IDs#section-name goes nowhereVerify id attributes exist in target page
External site downThird-party link returns 5xxReplace or remove dead external links
Case sensitivity/About/ vs /about/Cloudflare Pages is case-sensitive on paths
{
"scripts": {
"test:links": "npx linkinator http://localhost:4173/ --recurse --skip 'fonts.googleapis.com|fonts.gstatic.com'"
}
}

Forms are the most critical interactive element on medical practice sites. Test them at every stage.

StageEnvironmentEmail BehaviorURL
Localnpm run serve (port 8788)Logs to console, no email senthttp://localhost:8788
StagingCloudflare Pages previewReal emails sent via SendGridhttps://staging.yoursite.com
ProductionCloudflare Pages productionReal emails sent via SendGridhttps://yoursite.com
Terminal window
# Start local dev server with Functions support
npx wrangler pages dev public
# Server runs at http://localhost:8788
# Forms submit to /api/submit-form
# Emails are logged to terminal instead of sent

What to verify locally:

  1. Form submission does not throw JavaScript errors
  2. Console shows “DEVELOPMENT MODE: Email would be sent” with correct data
  3. Success modal appears after submission
  4. All form fields are captured correctly (name, email, phone, message)
  5. Required field validation works
  6. Form resets after successful submission

What to verify on staging:

  1. Form submission sends real email
  2. Email arrives within 1-2 minutes
  3. Email contains all submitted fields
  4. Email “from” address is correct
  5. Email “reply-to” is set to submitter’s email
  6. Email is formatted (HTML version) correctly
  7. Success modal appears
  8. No CORS errors in browser console

Scenario 1: All fields filled

Name: "Test User"
Email: "test@example.com"
Phone: "555-123-4567"
Message: "Test message"
Expected: Success modal, email received
Must Pass

Scenario 2: Minimum required fields

Name: "Test User"
Email: "test@example.com"
Phone: (empty)
Message: (empty)
Expected: Success modal, email shows "Not provided" for empty fields
Must Pass

Open browser DevTools (Network tab) during form submission:

POST /api/submit-form
Status: 200
Response Headers:
Access-Control-Allow-Origin: *
Content-Type: application/json

If you see CORS errors, verify the Functions handler includes:

headers: {
'Access-Control-Allow-Origin': '*',
}

And that the OPTIONS handler exists:

export async function onRequestOptions() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
tests/test-forms.js
async function testFormSubmission(baseUrl) {
const formData = new FormData();
formData.append('name', 'Automated Test');
formData.append('email', 'test@example.com');
formData.append('phone', '555-000-0000');
formData.append('message', 'Automated test submission');
formData.append('form_source', 'Automated Test');
try {
const response = await fetch(`${baseUrl}/api/submit-form`, {
method: 'POST',
body: formData,
});
const data = await response.json();
console.log(`Status: ${response.status}`);
console.log(`Success: ${data.success}`);
console.log(`Message: ${data.message}`);
if (!data.success) {
console.error('FAIL: Form submission returned success=false');
process.exit(1);
}
console.log('PASS: Form submission succeeded');
} catch (error) {
console.error('FAIL:', error.message);
process.exit(1);
}
}
const baseUrl = process.argv[2] || 'http://localhost:8788';
testFormSubmission(baseUrl);
Terminal window
# Test locally
node tests/test-forms.js http://localhost:8788
# Test staging
node tests/test-forms.js https://staging.yoursite.com

  1. Open site in Chrome
  2. Press F12 or Cmd+Option+I to open DevTools
  3. Click the device toggle icon (top-left of DevTools)
  4. Test at the breakpoints listed below
DeviceWidthHeightCommon Issues
iPhone SE375667Text overflow, button sizing
iPhone 14390844Standard mobile test
iPhone 14 Pro Max430932Large phone
iPad Mini7681024Tablet portrait
iPad Air8201180Tablet landscape
Galaxy S21360800Android standard

Automate mobile screenshots across viewports:

tests/mobile-test.js
const { chromium } = require('playwright');
const VIEWPORTS = [
{ name: 'mobile-sm', width: 375, height: 667 },
{ name: 'mobile-md', width: 390, height: 844 },
{ name: 'mobile-lg', width: 430, height: 932 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
];
const URLS = ['/', '/contact/', '/services/'];
async function run() {
const browser = await chromium.launch();
const fs = require('fs');
const path = require('path');
const outputDir = path.join(__dirname, '..', 'mobile-screenshots');
fs.mkdirSync(outputDir, { recursive: true });
for (const viewport of VIEWPORTS) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
});
for (const url of URLS) {
const page = await context.newPage();
await page.goto(`http://localhost:4173${url}`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const slug = url.replace(/\//g, '_') || 'home';
await page.screenshot({
path: path.join(outputDir, `${viewport.name}_${slug}.png`),
fullPage: true,
});
await page.close();
}
await context.close();
}
await browser.close();
console.log(`Screenshots saved to ${outputDir}`);
}
run().catch(console.error);
  • Navigation: Hamburger menu opens/closes, all links work
  • Hero section: Image not cut off, text readable, CTA button visible
  • Typography: No horizontal scrolling, text wraps properly
  • Images: Responsive, not overflowing container, loading correctly
  • Forms: Input fields full width, labels visible, submit button reachable
  • Phone numbers: Clickable (tel: links work)
  • Maps: Responsive, not overflowing container
  • Footer: All links accessible, no overlapping text
  • Sticky header: Not covering content on scroll
  • Before/After gallery: Touch-friendly navigation
  • Font sizes: Minimum 16px for body text (prevents iOS zoom on input focus)
  • Touch targets: Minimum 44x44px for buttons and links
  1. Deploy to staging domain
  2. Open on a real phone (iOS Safari + Android Chrome)
  3. Test form submission on mobile
  4. Check phone number links dial correctly
  5. Verify images load (not blocked by data saver)
  6. Test landscape orientation

Verify that the migration preserves (or improves) SEO signals.

For every page, verify these tags exist:

<!-- Required -->
<title>Page Title - Site Name</title>
<meta name="description" content="155 characters or less describing this page">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="canonical" href="https://yoursite.com/this-page/">
<!-- Open Graph (social sharing) -->
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Page description">
<meta property="og:image" content="https://yoursite.com/images/og-image.jpg">
<meta property="og:url" content="https://yoursite.com/this-page/">
<meta property="og:type" content="website">
<!-- Optional but recommended -->
<meta name="robots" content="index, follow">
<html lang="en">
tests/seo-check.js
const fs = require('fs');
const path = require('path');
const OUTPUT_DIR = path.join(__dirname, '..', 'sitename-static-build');
function walkDir(dir, fileList = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(fullPath, fileList);
else fileList.push(fullPath);
}
return fileList;
}
const htmlFiles = walkDir(OUTPUT_DIR).filter(f => f.endsWith('.html'));
const issues = [];
for (const filePath of htmlFiles) {
const content = fs.readFileSync(filePath, 'utf8');
const relative = path.relative(OUTPUT_DIR, filePath);
if (!content.includes('<title>'))
issues.push(`${relative}: Missing <title> tag`);
if (!content.includes('name="description"'))
issues.push(`${relative}: Missing meta description`);
if (!content.includes('rel="canonical"'))
issues.push(`${relative}: Missing canonical URL`);
if (!content.includes('name="viewport"'))
issues.push(`${relative}: Missing viewport meta`);
if (!content.includes('lang='))
issues.push(`${relative}: Missing lang attribute on <html>`);
}
if (issues.length) {
console.log('SEO Issues Found:');
issues.forEach(i => console.log(` - ${i}`));
process.exit(1);
} else {
console.log('All SEO checks passed.');
}

If your site uses schema.org structured data (LocalBusiness, MedicalBusiness, etc.):

  1. Extract JSON-LD from pages:

    Terminal window
    # Check for structured data in build output
    grep -r "application/ld+json" sitename-static-build/ --include="*.html" -l
  2. Validate with Google’s tool: Go to https://search.google.com/test/rich-results, enter staging URL or paste HTML, and fix any errors or warnings.

  3. Common schema types for medical sites:

    {
    "@context": "https://schema.org",
    "@type": "MedicalBusiness",
    "name": "Practice Name",
    "address": {
    "@type": "PostalAddress",
    "streetAddress": "123 Main St",
    "addressLocality": "City",
    "addressRegion": "State",
    "postalCode": "12345"
    },
    "telephone": "+1-555-123-4567",
    "url": "https://yoursite.com"
    }
Terminal window
# Check sitemap exists in build output
ls -la sitename-static-build/sitemap.xml
# Validate XML structure
xmllint --noout sitename-static-build/sitemap.xml
# Check sitemap is referenced in robots.txt
grep -i "sitemap" sitename-static-build/robots.txt

Expected robots.txt:

User-agent: *
Allow: /
Sitemap: https://yoursite.com/sitemap.xml

Ensure canonical URLs point to the production domain, not staging or .local:

Terminal window
# Check for wrong canonical URLs in build output
grep -r "canonical" sitename-static-build/ --include="*.html" | grep -v "yoursite.com"
  1. Go to https://search.google.com/search-console
  2. Add property for your domain
  3. Verify ownership (DNS TXT record recommended via Cloudflare)
  4. Submit sitemap: https://yoursite.com/sitemap.xml
  5. Check for crawl errors after 24-48 hours
  6. Monitor “Coverage” report for indexing issues

  • All pages render correctly in browser
  • Navigation links work on every page
  • Footer links work on every page
  • Logo links to homepage
  • 404 page exists and displays correctly
  • No “Lorem ipsum” or placeholder content
  • Phone numbers are correct and clickable
  • Address is correct
  • Copyright year is current
  • npm test passes (automated test suite)
  • npm run test:visual passes (visual regression)
  • No stray .local WordPress URLs in any HTML file
  • No references to wp-json, wp-login, xmlrpc.php
  • All <img> tags have width and height attributes
  • All <img> tags have alt attributes
  • Favicon exists and loads
  • Lighthouse Performance score >= 90 (mobile)
  • Lighthouse Performance score >= 95 (desktop)
  • LCP < 2.5s
  • TBT < 200ms
  • CLS < 0.1
  • Images converted to WebP where possible
  • CSS is minified or inlined
  • JavaScript is deferred or async
  • _headers file exists with security headers
  • HSTS header present: Strict-Transport-Security: max-age=31536000
  • X-Frame-Options set: DENY or SAMEORIGIN
  • X-Content-Type-Options: nosniff
  • Referrer-Policy set
  • No sensitive data in HTML source (API keys, passwords, internal URLs)
  • Forms use HTTPS endpoints only
  • Every page has a unique <title>
  • Every page has a <meta name="description">
  • Canonical URLs point to production domain
  • robots.txt exists with correct directives
  • sitemap.xml exists and is valid
  • Structured data validates without errors
  • No noindex tags on pages that should be indexed
  • Local form test passes (console output correct)
  • Staging form test passes (email received)
  • Success modal displays correctly
  • Error handling works (server error shows error modal)
  • CORS headers correct
  • SendGrid API key set in Cloudflare environment variables
  • Sender email verified in SendGrid
  • Custom domain configured in Cloudflare Pages
  • SSL certificate provisioned (automatic)
  • www redirect configured (if needed)
  • Old hosting redirect plan documented

After deploying to the production domain, verify everything works in the real environment.

  • Site loads at production URL (https://yoursite.com)
  • HTTPS works (no mixed content warnings)
  • Homepage renders completely
  • Navigation works on all pages
  • Images load correctly
  • CSS/styles applied correctly
  • Forms submit successfully
  • Form email received at correct addresses
  • 404 page displays for invalid URLs
  • Phone numbers click-to-call on mobile
  • Google Maps embed loads (if applicable)
  • Run Lighthouse against production URL
  • Run link checker against production URL
  • Test form on mobile device (real phone)
  • Check all pages in at least 2 browsers (Chrome + Safari)
  • Verify _headers security headers in browser DevTools (Network tab)
  • Check Cloudflare dashboard for any errors
  • Verify DNS propagation: dig yoursite.com
  • Test www and non-www variants both work
  • Submit sitemap to Google Search Console
  • Verify Google Search Console ownership
  • Check Google Analytics / tracking is working (if applicable)
  • Verify no crawl errors in Search Console
  • Monitor Cloudflare analytics for error rates
  • Check email deliverability (form submissions not going to spam)
  • Test from different geographic locations (use VPN or ask colleagues)
  • Document production deployment in MIGRATION_LOG.md
  • Monitor Search Console for indexing issues
  • Check that old WordPress URLs redirect correctly (if 301s configured)
  • Verify form emails still arriving (not hitting SendGrid rate limits)
  • Review Cloudflare analytics for 404 patterns (missing pages)
  • Run full Lighthouse audit and save as production baseline
  • Confirm old hosting can be decommissioned
  • Update any external links pointing to old URLs