I Built an n8n Workflow That Audits Any Website's SEO and Emails the Report. Full JSON Inside.
You have probably opened a site, glanced at the title tag, scrolled the source for an H1, then quit because doing that for 12 pages by hand is mind-numbing....
Garvit Sharma
18 June 2026 · 11 MIN READ
I Built an n8n Workflow That Audits Any Website's SEO and Emails the Report. Full JSON Inside.
webight.comOn this page
You have probably opened a site, glanced at the title tag, scrolled the source for an H1, then quit because doing that for 12 pages by hand is mind-numbing. So you skip the check. The site ships with a missing meta description and nobody notices for 3 months.
I got tired of that. So I built a workflow in n8n that takes a single URL, fetches the page, reads its robots.txt and sitemap, parses the title and meta and H1 and schema, runs the page through Google's PageSpeed API, and emails me a report. Start to finish it takes about 40 seconds and I never touch the page myself.
The whole thing runs on one idea: an SEO audit is just a list of yes/no checks against the raw HTML, and a machine reads raw HTML faster and more honestly than you do at 1am. Your eyes skim. A code node does not skim. It either finds the canonical tag or it does not.
I will give you the full workflow JSON at the end. You import it, change 4 things, and it runs. First let me walk the nodes so you understand what each one is doing, because if you do not understand it you cannot fix it when a site returns weird HTML.
The stack
- n8n (self-hosted on a small VPS, but n8n Cloud works the same)
- A Google PageSpeed Insights API key (free, from the Google Cloud console)
- Resend for the email, because that is what I use for every Webight project anyway
That's it. No scraping library, no headless browser. Three of the four data sources are plain HTTP GET requests, and the parsing is one Code node of vanilla JavaScript.
Node 1: the form trigger
I use a Form Trigger, not a webhook, because I wanted to paste a URL into a box from my phone and get a report. The form has one field: the URL to audit. n8n gives the form its own hosted page, so there is no frontend to build.
If you would rather fire this from your own site or from another workflow, swap the Form Trigger for a Webhook node and POST { "url": "https://example.com" } to it. Everything downstream reads the same url field.
Node 2 to 4: fetch the page, robots, and sitemap
Three HTTP Request nodes running off the trigger. One pulls the page HTML. One pulls /robots.txt. One pulls /sitemap.xml. I set Continue On Fail on the robots and sitemap nodes, because plenty of sites are missing one or both, and a missing sitemap is a finding, not a reason for the whole workflow to die.
That last bit matters. When I first built this it crashed on every site without a sitemap, which is exactly the kind of site that needs the audit most. A failed fetch is data. Treat it as data.
Node 5: the Code node that does the real work
This is the brain. It takes the raw HTML string and runs plain regex and string checks against it. No DOM library. n8n's Code node runs in a sandbox without document, so I parse the HTML the boring way, with regular expressions, and it works fine for the tags that matter for SEO.
It checks the title and its length, the meta description and its length, the count of H1 tags, whether a canonical tag exists, whether an Open Graph image is set, whether any JSON-LD schema block is present, whether the robots.txt blocks crawlers, and roughly how many URLs sit in the sitemap. Then it builds a single HTML report string for the email.
Here is the exact code that goes in that node. Copy it whole.
// Code node: "Parse SEO"
const html = $('Fetch Page').first().json.data || '';
const robots = $('Fetch Robots').first().json.data || '';
const sitemap = $('Fetch Sitemap').first().json.data || '';
const url = $('On form submission').first().json['Website URL'];
const grab = (re) => {
const m = html.match(re);
return m ? m[1].trim() : null;
};
const title = grab(/<title[^>]*>([\s\S]*?)<\/title>/i);
const metaDesc = grab(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i);
const canonical = grab(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i);
const ogImage = grab(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']*)["']/i);
const h1Matches = html.match(/<h1[^>]*>[\s\S]*?<\/h1>/gi) || [];
const h1Count = h1Matches.length;
const hasSchema = /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
const robotsBlocksAll = /User-agent:\s*\*[\s\S]*?Disallow:\s*\/\s*$/im.test(robots);
const sitemapUrlCount = (sitemap.match(/<loc>/gi) || []).length;
// score each check, 1 point if it passes
const checks = [
{ name: 'Title tag present', pass: !!title, detail: title || 'missing' },
{ name: 'Title length 30-60 chars', pass: !!title && title.length >= 30 && title.length <= 60, detail: title ? title.length + ' chars' : 'n/a' },
{ name: 'Meta description present', pass: !!metaDesc, detail: metaDesc ? metaDesc.length + ' chars' : 'missing' },
{ name: 'Meta description 70-160 chars', pass: !!metaDesc && metaDesc.length >= 70 && metaDesc.length <= 160, detail: metaDesc ? metaDesc.length + ' chars' : 'n/a' },
{ name: 'Exactly one H1', pass: h1Count === 1, detail: h1Count + ' found' },
{ name: 'Canonical tag present', pass: !!canonical, detail: canonical || 'missing' },
{ name: 'Open Graph image set', pass: !!ogImage, detail: ogImage ? 'yes' : 'missing' },
{ name: 'JSON-LD schema present', pass: hasSchema, detail: hasSchema ? 'yes' : 'missing' },
{ name: 'robots.txt does not block all', pass: !robotsBlocksAll, detail: robotsBlocksAll ? 'BLOCKS CRAWLERS' : 'ok' },
{ name: 'Sitemap has URLs', pass: sitemapUrlCount > 0, detail: sitemapUrlCount + ' URLs' },
];
const passed = checks.filter(c => c.pass).length;
const seoScore = Math.round((passed / checks.length) * 100);
const rows = checks.map(c =>
`<tr><td>${c.pass ? '✅' : '❌'}</td><td>${c.name}</td><td style="color:#666">${c.detail}</td></tr>`
).join('');
const reportHtml = `
<h2>SEO audit for ${url}</h2>
<p><strong>On-page score: ${seoScore}/100</strong> (${passed} of ${checks.length} checks passed)</p>
<table cellpadding="6" style="border-collapse:collapse;font-family:sans-serif;font-size:14px">${rows}</table>
`;
return [{
json: {
url,
seoScore,
passed,
total: checks.length,
reportHtml,
pageSpeedUrl: `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile&key=YOUR_PAGESPEED_KEY`
}
}];
I keep the checks as a flat array on purpose. When you want a new rule, like checking for a viewport meta tag or an hreflang, you add one object to that array and the score math and the table both update on their own. No other edits.
Node 6: the PageSpeed call
The Code node builds the PageSpeed API URL and passes it down. The next HTTP Request node calls it and gets back Lighthouse data as JSON. I pull the four category scores out: performance, accessibility, best practices, SEO. Those are the same numbers you see when you run Lighthouse by hand, except now they land in the email next to the on-page checks.
PageSpeed against a mobile strategy is the one I trust, because mobile is where the render delay bites. When I audited my own site in June, the hero headline had a 4,190ms LCP render delay from a framer-motion opacity animation waiting on hydration. A desktop run hid how bad it was. Mobile did not. So I always pass strategy=mobile.
Node 7: format and email with Resend
A small Set node merges the on-page report and the PageSpeed scores into one HTML body, and the Resend node sends it. I send to myself. You could send to the client, or to a Slack channel, but I like email because the report sits in my inbox as a record I can search later.
The full workflow JSON
Import this in n8n through the menu, choose Import from File or paste it into a new workflow. It is the real export format.
{
"name": "SEO Audit and Email",
"nodes": [
{
"parameters": {
"formTitle": "Website SEO Audit",
"formFields": {
"values": [
{ "fieldLabel": "Website URL", "requiredField": true }
]
}
},
"id": "a1f0c2e1-0001-4a10-9c01-000000000001",
"name": "On form submission",
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2,
"position": [240, 400],
"webhookId": "seo-audit-form"
},
{
"parameters": {
"url": "={{ $json['Website URL'] }}",
"options": { "response": { "response": { "neverError": true } } }
},
"id": "a1f0c2e1-0002-4a10-9c01-000000000002",
"name": "Fetch Page",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [480, 240]
},
{
"parameters": {
"url": "={{ new URL('/robots.txt', $json['Website URL']).href }}",
"options": { "response": { "response": { "neverError": true } } }
},
"id": "a1f0c2e1-0003-4a10-9c01-000000000003",
"name": "Fetch Robots",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [480, 400],
"continueOnFail": true
},
{
"parameters": {
"url": "={{ new URL('/sitemap.xml', $json['Website URL']).href }}",
"options": { "response": { "response": { "neverError": true } } }
},
"id": "a1f0c2e1-0004-4a10-9c01-000000000004",
"name": "Fetch Sitemap",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [480, 560],
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const html = $('Fetch Page').first().json.data || '';\nconst robots = $('Fetch Robots').first().json.data || '';\nconst sitemap = $('Fetch Sitemap').first().json.data || '';\nconst url = $('On form submission').first().json['Website URL'];\n\nconst grab = (re) => { const m = html.match(re); return m ? m[1].trim() : null; };\n\nconst title = grab(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\nconst metaDesc = grab(/<meta[^>]+name=[\"']description[\"'][^>]+content=[\"']([^\"']*)[\"']/i);\nconst canonical = grab(/<link[^>]+rel=[\"']canonical[\"'][^>]+href=[\"']([^\"']*)[\"']/i);\nconst ogImage = grab(/<meta[^>]+property=[\"']og:image[\"'][^>]+content=[\"']([^\"']*)[\"']/i);\n\nconst h1Count = (html.match(/<h1[^>]*>[\\s\\S]*?<\\/h1>/gi) || []).length;\nconst hasSchema = /<script[^>]+type=[\"']application\\/ld\\+json[\"']/i.test(html);\nconst robotsBlocksAll = /User-agent:\\s*\\*[\\s\\S]*?Disallow:\\s*\\/\\s*$/im.test(robots);\nconst sitemapUrlCount = (sitemap.match(/<loc>/gi) || []).length;\n\nconst checks = [\n { name: 'Title tag present', pass: !!title, detail: title || 'missing' },\n { name: 'Title length 30-60 chars', pass: !!title && title.length >= 30 && title.length <= 60, detail: title ? title.length + ' chars' : 'n/a' },\n { name: 'Meta description present', pass: !!metaDesc, detail: metaDesc ? metaDesc.length + ' chars' : 'missing' },\n { name: 'Meta description 70-160 chars', pass: !!metaDesc && metaDesc.length >= 70 && metaDesc.length <= 160, detail: metaDesc ? metaDesc.length + ' chars' : 'n/a' },\n { name: 'Exactly one H1', pass: h1Count === 1, detail: h1Count + ' found' },\n { name: 'Canonical tag present', pass: !!canonical, detail: canonical || 'missing' },\n { name: 'Open Graph image set', pass: !!ogImage, detail: ogImage ? 'yes' : 'missing' },\n { name: 'JSON-LD schema present', pass: hasSchema, detail: hasSchema ? 'yes' : 'missing' },\n { name: 'robots.txt does not block all', pass: !robotsBlocksAll, detail: robotsBlocksAll ? 'BLOCKS CRAWLERS' : 'ok' },\n { name: 'Sitemap has URLs', pass: sitemapUrlCount > 0, detail: sitemapUrlCount + ' URLs' }\n];\n\nconst passed = checks.filter(c => c.pass).length;\nconst seoScore = Math.round((passed / checks.length) * 100);\nconst rows = checks.map(c => `<tr><td>${c.pass ? '✅' : '❌'}</td><td>${c.name}</td><td style=\"color:#666\">${c.detail}</td></tr>`).join('');\nconst reportHtml = `<h2>SEO audit for ${url}</h2><p><strong>On-page score: ${seoScore}/100</strong> (${passed} of ${checks.length} checks passed)</p><table cellpadding=\"6\" style=\"border-collapse:collapse;font-family:sans-serif;font-size:14px\">${rows}</table>`;\n\nreturn [{ json: { url, seoScore, passed, total: checks.length, reportHtml, pageSpeedUrl: `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile&key=YOUR_PAGESPEED_KEY` } }];"
},
"id": "a1f0c2e1-0005-4a10-9c01-000000000005",
"name": "Parse SEO",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [720, 400]
},
{
"parameters": {
"url": "={{ $json.pageSpeedUrl }}",
"options": { "timeout": 60000 }
},
"id": "a1f0c2e1-0006-4a10-9c01-000000000006",
"name": "PageSpeed",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [960, 400]
},
{
"parameters": {
"jsCode": "const seo = $('Parse SEO').first().json;\nconst lh = $json.lighthouseResult ? $json.lighthouseResult.categories : {};\nconst pct = (c) => c ? Math.round(c.score * 100) : 'n/a';\n\nconst psHtml = `<h3>Lighthouse (mobile)</h3><ul><li>Performance: ${pct(lh.performance)}</li><li>Accessibility: ${pct(lh.accessibility)}</li><li>Best practices: ${pct(lh['best-practices'])}</li><li>SEO: ${pct(lh.seo)}</li></ul>`;\n\nreturn [{ json: { url: seo.url, subject: `SEO audit: ${seo.url} scored ${seo.seoScore}/100`, body: seo.reportHtml + psHtml } }];"
},
"id": "a1f0c2e1-0007-4a10-9c01-000000000007",
"name": "Build Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1200, 400]
},
{
"parameters": {
"method": "POST",
"url": "https://api.resend.com/emails",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"from\": \"audits@yourdomain.com\",\n \"to\": \"you@yourdomain.com\",\n \"subject\": \"{{ $json.subject }}\",\n \"html\": {{ JSON.stringify($json.body) }}\n}"
},
"id": "a1f0c2e1-0008-4a10-9c01-000000000008",
"name": "Send via Resend",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1440, 400]
}
],
"connections": {
"On form submission": {
"main": [[
{ "node": "Fetch Page", "type": "main", "index": 0 },
{ "node": "Fetch Robots", "type": "main", "index": 0 },
{ "node": "Fetch Sitemap", "type": "main", "index": 0 }
]]
},
"Fetch Page": { "main": [[{ "node": "Parse SEO", "type": "main", "index": 0 }]] },
"Fetch Robots": { "main": [[{ "node": "Parse SEO", "type": "main", "index": 0 }]] },
"Fetch Sitemap": { "main": [[{ "node": "Parse SEO", "type": "main", "index": 0 }]] },
"Parse SEO": { "main": [[{ "node": "PageSpeed", "type": "main", "index": 0 }]] },
"PageSpeed": { "main": [[{ "node": "Build Email", "type": "main", "index": 0 }]] },
"Build Email": { "main": [[{ "node": "Send via Resend", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" },
"active": false
}
What to replace before it runs
Four things. That's it.
| Where | Find | Replace with |
|---|---|---|
| Parse SEO code node | YOUR_PAGESPEED_KEY |
Your Google PageSpeed Insights API key |
| Send via Resend node | audits@yourdomain.com |
A verified sending address on your Resend domain |
| Send via Resend node | you@yourdomain.com |
Where you want the report delivered |
| Send via Resend credential | httpHeaderAuth | A header named Authorization with value Bearer YOUR_RESEND_KEY |
The Resend key goes in an n8n credential, never pasted into the node body. Create a Header Auth credential, set the name to Authorization and the value to Bearer re_yourkey, then pick it on the Send node. Keys in node bodies leak into exports, and an exported workflow is exactly the kind of file you paste into a blog post.
The one thing this will not catch
Regex parsing reads the HTML the server sends, not the HTML after JavaScript runs. If a site renders its title or schema on the client with React and ships an empty shell, this workflow reports them missing even when a browser would show them. That is not a bug in the workflow. That is the workflow telling you the site does not server-render its SEO tags, which is itself a finding worth knowing. Roughly 1 in 4 of the sites I run through it has at least one tag that only exists after hydration.
If you need post-render checks, the honest fix is to point the page-fetch at a rendering service and feed that HTML into the same Code node. The parsing does not change. Only the source of the HTML does.
So here is the deal. Import the JSON, swap the 4 values, and run it against your own homepage first. If it comes back with a missing canonical or two H1 tags or a meta description nobody wrote, you just found in 40 seconds what you would have skipped at 1am. Fix those, then point it at the last site you shipped, because the bug you are about to find has been sitting there the whole time. Run it and tell me what score you got. I reply to every message.
We build custom platforms, websites, and automation.
The two people who build it are the two you talk to, and every price is on the page.

Garvit Sharma
Full-stack developer and co-founder of Webight, a two-person web and AI studio in India. He writes these from real client work. More about us.
Keep reading
// RELATEDEvery Post on This Blog Is an SEO Experiment. I'll Publish the Ranking Data Monthly, Wins or Losses.
webight.comEvery Post on This Blog Is an SEO Experiment. I'll Publish the Ranking Data Monthly, Wins or Losses.
6 MIN READ
I Generate 30 Local Landing Pages From One JSON File in Next.js. Code Included.
webight.comI Generate 30 Local Landing Pages From One JSON File in Next.js. Code Included.
8 MIN READ
Google Search Console Reports on WhatsApp Every Monday. n8n Plus 199 Rupees a Month.
webight.comGoogle Search Console Reports on WhatsApp Every Monday. n8n Plus 199 Rupees a Month.
9 MIN READ