Content Accuracy
Missing pages, broken HTML, wrong text. Caught by the automated test suite.
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.
| Check | Description | Status |
|---|---|---|
| Required files exist | index.html, 404.html, theme CSS, logo, robots.txt | Required |
| Critical URLs return 200 | Homepage, contact, about, key service pages | Required |
| CSS assets load | Every <link> stylesheet referenced in HTML exists locally | Required |
No stray .local URLs | No leftover WordPress development URLs in output | Required |
| Valid HTML structure | Pages contain <html tag | Required |
| Inline critical CSS | If no external CSS, verify inline critical CSS present | Recommended |
This is based on the real test runner used in production migrations:
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 200const CRITICAL_URLS = [ '/', '/contact/', '/about/', '/services/', '/privacy-policy/'];
// Files that MUST exist in the build outputconst 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 HTMLfunction 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 filesfunction 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);});# Add to package.json scripts:# "test": "node tests/run-tests.js"
npm testAll tests passed.Test suite failed: Missing required file: 404.htmlUpdate these arrays for each migration project:
// CRITICAL_URLS - Every important page on the siteconst CRITICAL_URLS = [ '/', '/contact/', '/about/', '/services/botox/', '/services/fillers/', '/gallery/', '/privacy-policy/'];
// REQUIRED_FILES - Files that must exist in build outputconst 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 hostnameconst 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.
npm install playwright pixelmatch pngjs --save-devnpx playwright install chromiumgraph 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
| Variable | Default | Description |
|---|---|---|
VISUAL_WAIT_MS | 5500 | Wait time (ms) for lazy-loaded assets before screenshot |
PIXELMATCH_THRESHOLD | 0.1 | Color sensitivity (0 = exact, 1 = loose) |
MAX_MISMATCH_PERCENT | 1.0 | Max % of pixels that can differ before failure |
TRIGGER_PERFMATTERS | true | Simulate user interaction to trigger Perfmatters delayed JS/CSS |
VISUAL_HIDE_SELECTORS | (see below) | CSS selectors to hide dynamic/third-party elements |
SOURCE_BASE | https://sitename.local | WordPress source URL |
TARGET_BASE | http://127.0.0.1:4175 | Static 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));}# Add to package.json scripts:# "test:visual": "node tests/visual-diff.js"
npm run test:visualPASS / -> 0.12% mismatchPASS /contact/ -> 0.45% mismatchPASS /about/ -> 0.08% mismatchVisual diff suite passed.Pass All pages within threshold.
PASS / -> 0.12% mismatchFAIL /contact/ -> 3.87% mismatchVisual diffs exceeded threshold: /contact/ (3.87%)Fail Contact page exceeds mismatch 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 = differencesOpen the diff image to see exactly where visual differences occur. Red-highlighted pixels show mismatches.
| Problem | Solution |
|---|---|
| Fonts not loaded in screenshot | Increase VISUAL_WAIT_MS to 7000-8000 |
| Slider/carousel in different state | Add CSS to freeze first slide (see capturePage in visual-diff.js) |
| Cookie banner covering content | Add banner selectors to VISUAL_HIDE_SELECTORS |
| Small anti-aliasing differences | Increase PIXELMATCH_THRESHOLD to 0.15 |
| Page height differs slightly | The script normalizes sizes automatically (pads shorter image with white) |
| Too many false positives | Increase MAX_MISMATCH_PERCENT to 2.0 or 3.0 |
Lighthouse measures Performance, Accessibility, Best Practices, and SEO from the command line.
npm install -g lighthouse# Or use per-project:npm install lighthouse --save-devnpx lighthouse http://localhost:4173/ \ --output=json \ --output-path=./lighthouse-report.json \ --chrome-flags="--headless"npx lighthouse http://localhost:4173/ \ --output=json \ --output-path=./lighthouse-desktop.json \ --chrome-flags="--headless" \ --preset=desktopnpx lighthouse http://localhost:4173/ \ --only-categories=performance \ --output=json \ --output-path=./lighthouse-perf.json \ --chrome-flags="--headless"npx lighthouse http://localhost:4173/ \ --output=html \ --output-path=./lighthouse-report.html \ --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 Range | Rating | Badge | Action |
|---|---|---|---|
| 90-100 | Good | Pass | Ship it |
| 50-89 | Needs improvement | Warning | Fix before launch |
| 0-49 | Poor | Fail | Investigate immediately |
# Extract performance scorecat lighthouse-report.json | jq '.categories.performance.score * 100'
# Extract all category scorescat 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 Vitalscat lighthouse-report.json | jq '{ LCP: .audits["largest-contentful-paint"].displayValue, TBT: .audits["total-blocking-time"].displayValue, CLS: .audits["cumulative-layout-shift"].displayValue}'| Metric | Target | What It Measures |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Time to render largest visible content |
| TBT (Total Blocking Time) | < 200ms | Main thread blocking during load |
| CLS (Cumulative Layout Shift) | < 0.1 | Visual stability (layout jumps) |
| FCP (First Contentful Paint) | < 1.8s | Time to first visible content |
| SI (Speed Index) | < 3.4s | How quickly content is visually populated |
Run baseline audit against WordPress original:
npx lighthouse https://sitename.local/ \ --output=json \ --output-path=./lighthouse-baseline.json \ --chrome-flags="--headless --ignore-certificate-errors"Run audit against static build:
npx lighthouse http://localhost:4173/ \ --output=json \ --output-path=./lighthouse-optimized.json \ --chrome-flags="--headless"Compare scores:
echo "=== BASELINE ==="cat lighthouse-baseline.json | jq '.categories.performance.score * 100 | floor'echo "=== OPTIMIZED ==="cat lighthouse-optimized.json | jq '.categories.performance.score * 100 | floor'| Issue | Lighthouse Audit | Fix |
|---|---|---|
| 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.
# Installnpm install -g linkinator
# Check local static buildnpx linkinator http://localhost:4173/ --recurse
# Check deployed sitenpx linkinator https://staging.yoursite.com/ --recurse
# Skip external links (faster, internal only)npx linkinator http://localhost:4173/ --recurse --skip "^(?!http://localhost)"
# Output as JSONnpx linkinator http://localhost:4173/ --recurse --format json > link-report.json# Installnpm install -g broken-link-checker
# Check site recursivelyblc http://localhost:4173/ --recursive --ordered --exclude-external
# Include external links (slower, more thorough)blc http://localhost:4173/ --recursive --ordered| Issue | Symptom | Fix |
|---|---|---|
| Trailing slash mismatch | /about returns 404, /about/ works | Ensure consistent trailing slashes in all <a href> |
| Stray WordPress URLs | Links to sitename.local | Run crawler link rewrite step again |
| Anchor links to missing IDs | #section-name goes nowhere | Verify id attributes exist in target page |
| External site down | Third-party link returns 5xx | Replace 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.
| Stage | Environment | Email Behavior | URL |
|---|---|---|---|
| Local | npm run serve (port 8788) | Logs to console, no email sent | http://localhost:8788 |
| Staging | Cloudflare Pages preview | Real emails sent via SendGrid | https://staging.yoursite.com |
| Production | Cloudflare Pages production | Real emails sent via SendGrid | https://yoursite.com |
# Start local dev server with Functions supportnpx wrangler pages dev public
# Server runs at http://localhost:8788# Forms submit to /api/submit-form# Emails are logged to terminal instead of sentWhat to verify locally:
What to verify on staging:
Scenario 1: All fields filled
Name: "Test User"Email: "test@example.com"Phone: "555-123-4567"Message: "Test message"Expected: Success modal, email receivedScenario 2: Minimum required fields
Name: "Test User"Email: "test@example.com"Phone: (empty)Message: (empty)Expected: Success modal, email shows "Not provided" for empty fieldsScenario 3: Invalid email
Name: "Test User"Email: "not-an-email"Expected: Browser validation prevents submission (HTML5 type="email")Scenario 4: Empty required fields
Name: (empty)Email: (empty)Expected: Browser validation prevents submission (required attribute)Scenario 5: Special characters
Name: "O'Brien & Associates"Message: "Need info <urgent>"Expected: Success, no XSS, characters display correctly in emailScenario 6: Rapid double submission
Submit form twice quicklyExpected: Only one email sent (debounce protection)Open browser DevTools (Network tab) during form submission:
POST /api/submit-formStatus: 200Response Headers: Access-Control-Allow-Origin: * Content-Type: application/jsonIf 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', }, });}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);# Test locallynode tests/test-forms.js http://localhost:8788
# Test stagingnode tests/test-forms.js https://staging.yoursite.comF12 or Cmd+Option+I to open DevTools| Device | Width | Height | Common Issues |
|---|---|---|---|
| iPhone SE | 375 | 667 | Text overflow, button sizing |
| iPhone 14 | 390 | 844 | Standard mobile test |
| iPhone 14 Pro Max | 430 | 932 | Large phone |
| iPad Mini | 768 | 1024 | Tablet portrait |
| iPad Air | 820 | 1180 | Tablet landscape |
| Galaxy S21 | 360 | 800 | Android standard |
Automate mobile screenshots across viewports:
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);tel: links work)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">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.):
Extract JSON-LD from pages:
# Check for structured data in build outputgrep -r "application/ld+json" sitename-static-build/ --include="*.html" -lValidate 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.
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"}# Check sitemap exists in build outputls -la sitename-static-build/sitemap.xml
# Validate XML structurexmllint --noout sitename-static-build/sitemap.xml
# Check sitemap is referenced in robots.txtgrep -i "sitemap" sitename-static-build/robots.txtExpected robots.txt:
User-agent: *Allow: /
Sitemap: https://yoursite.com/sitemap.xmlEnsure canonical URLs point to the production domain, not staging or .local:
# Check for wrong canonical URLs in build outputgrep -r "canonical" sitename-static-build/ --include="*.html" | grep -v "yoursite.com"https://yoursite.com/sitemap.xmlnpm test passes (automated test suite)npm run test:visual passes (visual regression).local WordPress URLs in any HTML filewp-json, wp-login, xmlrpc.php<img> tags have width and height attributes<img> tags have alt attributes_headers file exists with security headersStrict-Transport-Security: max-age=31536000DENY or SAMEORIGINnosniff<title><meta name="description">robots.txt exists with correct directivessitemap.xml exists and is validnoindex tags on pages that should be indexedAfter deploying to the production domain, verify everything works in the real environment.
https://yoursite.com)_headers security headers in browser DevTools (Network tab)dig yoursite.comMIGRATION_LOG.md