A weekly SEO report does not need to start as a giant dashboard.
Sometimes you just need a simple workflow that answers a few useful questions:
Which pages ranked for our target keywords this week?
Did any new competitors appear?
Did our pages move up or down?
Which keywords have weak visibility?
What changed enough that someone should look at it?
You can build this with n8n, a SERP API, Google Sheets, and a Slack or email report.
The basic workflow looks like this:
Schedule Trigger
→ Read keyword list
→ Call SERP API
→ Extract organic results
→ Find target domain rankings
→ Save weekly snapshot
→ Compare with previous snapshot
→ Generate report
→ Send to Slack or email
This is not meant to replace a full SEO platform.
It is a practical automation for teams that want a lightweight weekly search visibility report without manually checking keywords every Monday like a spreadsheet goblin with trust issues.
What we are building
We will build an n8n workflow that runs once per week and tracks SEO visibility for a target domain.
For each keyword, the workflow will collect:
keyword
location
search engine
target domain
found or not found
ranking position
ranking URL
top ranking domains
new competitors
SERP result count
report date
The final report will look something like this:
Weekly SEO Report
Tracked keywords: 25
Keywords where our domain ranked: 14
Keywords not found: 11
Top 10 rankings: 6
Biggest wins:
- "serp api for ai agents": position 8 → 4
- "google search api alternative": position 12 → 7
Lost visibility:
- "bing search api": position 9 → not found
New competitors:
- examplecompetitor.com
- anotherdomain.com
Simple. Useful. Not a 47-tab dashboard that slowly becomes a civic hazard.
What you need
You need:
n8n
a SERP API that returns search results as JSON
Google Sheets, Airtable, PostgreSQL, or another storage option
Slack or email for sending the report
This tutorial uses a generic SERP API format.
You can adapt it to providers like Talordata, SerpApi, SearchAPI, DataForSEO, or another provider that returns search results as structured JSON.
The provider matters less than the response shape.
You want fields like:
position
title
link
snippet
That is enough to build a basic weekly SEO report.
The workflow structure
In n8n, create a workflow with these nodes:
1. Schedule Trigger
2. Google Sheets: Read keyword list
3. Split In Batches
4. HTTP Request: Fetch SERP results
5. Code: Normalize results
6. Code: Find target domain ranking
7. Google Sheets or Database: Save snapshot
8. Google Sheets or Database: Read previous snapshot
9. Code: Compare snapshots
10. Code: Generate report text
11. Slack or Email: Send report
You can simplify the first version:
Schedule Trigger
→ Read keywords
→ HTTP Request
→ Code
→ Google Sheets
→ Slack
Then add snapshot comparison after you confirm the data is clean.
Do not build the cathedral before checking whether the front door opens.
Step 1: Prepare your keyword sheet
Create a Google Sheet called seo_keywords.
Use columns like this:
keyword
location
language
target_domain
Example:
keyword,location,language,target_domain
serp api for ai agents,United States,en,example.com
google search api alternative,United States,en,example.com
bing search api,United States,en,example.com
local seo rank tracker,United States,en,example.com
You can add more columns later:
device
search_engine
priority
keyword_group
owner
For now, keep it simple.
A good first version is:
10 to 30 keywords
1 target domain
1 location
weekly schedule
Step 2: Add a Schedule Trigger
In n8n, add a Schedule Trigger node.
Set it to run weekly.
Example:
Every Monday at 9:00 AM
Weekly is usually enough for SEO reporting.
Daily rank tracking is useful in some cases, but weekly reports are better for decision-making. Daily noise tends to make people panic over tiny movements, because apparently humans see a one-position drop and immediately prepare a funeral.
Step 3: Read keywords from Google Sheets
Add a Google Sheets node.
Use:
Operation: Read
Sheet: seo_keywords
This returns one item per keyword.
Each item should look like this:
{
"keyword": "serp api for ai agents",
"location": "United States",
"language": "en",
"target_domain": "example.com"
}
This becomes the input for the SERP API request.
Step 4: Split keywords into batches
Add a Split In Batches node.
Set batch size to something small, like:
1
This lets n8n call the SERP API once per keyword.
If you have many keywords, you can increase the batch size later.
Starting with batch size 1 makes debugging easier.
Debugging automation is already enough of a little haunted basement. No need to make it worse.
Step 5: Call the SERP API
Add an HTTP Request node.
Use:
Method: GET
Response Format: JSON
A generic request might look like this:
https://your-serp-api-endpoint.example.com/search
Query parameters:
api_key={{ $env.SERP_API_KEY }}
engine=google
q={{ $json.keyword }}
location={{ $json.location }}
language={{ $json.language }}
output=json
Different providers use different parameter names.
Some use:
q
query
engine
gl
hl
location
device
That is fine.
The important idea is:
keyword + location → search results JSON
If you use Talordata or another SERP API provider, check its docs for the exact endpoint and parameter names.
Step 6: Normalize organic results
After the HTTP Request node, add a Code node.
The SERP API response may use fields like:
organic_results
organic
results
This Code node normalizes the response into a clean structure.
function getOrganicResults(data) {
if (Array.isArray(data.organic_results)) {
return data.organic_results;
}
if (Array.isArray(data.organic)) {
return data.organic;
}
if (Array.isArray(data.results)) {
return data.results;
}
if (data.serp && Array.isArray(data.serp.organic_results)) {
return data.serp.organic_results;
}
return [];
}
function cleanText(value) {
if (!value) return "";
return String(value)
.replace(/\s+/g, " ")
.trim();
}
function normalizeResult(item) {
return {
position: item.position || item.rank || "",
title: cleanText(item.title || ""),
url: item.link || item.url || "",
snippet: cleanText(item.snippet || item.description || "")
};
}
const keyword = $json.keyword;
const location = $json.location;
const language = $json.language;
const targetDomain = $json.target_domain;
const organicResults = getOrganicResults($json);
const normalizedResults = organicResults.map(normalizeResult);
return [
{
json: {
keyword,
location,
language,
target_domain: targetDomain,
organic_results: normalizedResults,
organic_result_count: normalizedResults.length
}
}
];
Now every keyword has a predictable structure:
{
"keyword": "serp api for ai agents",
"target_domain": "example.com",
"organic_results": [
{
"position": 1,
"title": "Example Result",
"url": "https://example.com/page",
"snippet": "Example snippet"
}
]
}
This is the boring part.
Boring is good.
Boring data can be trusted.
Step 7: Find the target domain ranking
Add another Code node.
This node checks whether your target domain appears in the organic results.
function extractDomain(url) {
if (!url) return "";
try {
const parsed = new URL(url);
let domain = parsed.hostname.toLowerCase();
if (domain.startsWith("www.")) {
domain = domain.slice(4);
}
return domain;
} catch (error) {
return "";
}
}
function normalizeDomain(domain) {
if (!domain) return "";
domain = String(domain)
.toLowerCase()
.trim();
domain = domain.replace(/^https?:\/\//, "");
domain = domain.replace(/^www\./, "");
domain = domain.split("/")[0];
return domain;
}
function domainMatches(resultUrl, targetDomain) {
const resultDomain = extractDomain(resultUrl);
const normalizedTarget = normalizeDomain(targetDomain);
if (!resultDomain || !normalizedTarget) {
return false;
}
return (
resultDomain === normalizedTarget ||
resultDomain.endsWith("." + normalizedTarget)
);
}
function getTopDomains(results, limit = 10) {
const domains = [];
for (const result of results.slice(0, limit)) {
const domain = extractDomain(result.url);
if (domain && !domains.includes(domain)) {
domains.push(domain);
}
}
return domains;
}
const organicResults = $json.organic_results || [];
const targetDomain = $json.target_domain;
let matchedResult = null;
for (const result of organicResults) {
if (domainMatches(result.url, targetDomain)) {
matchedResult = result;
break;
}
}
const topDomains = getTopDomains(organicResults, 10);
return [
{
json: {
report_date: new Date().toISOString().slice(0, 10),
keyword: $json.keyword,
location: $json.location,
language: $json.language,
target_domain: targetDomain,
found: Boolean(matchedResult),
position: matchedResult ? matchedResult.position : "",
ranking_url: matchedResult ? matchedResult.url : "",
ranking_title: matchedResult ? matchedResult.title : "",
organic_result_count: $json.organic_result_count,
top_domains: topDomains.join(", ")
}
}
];
This returns one clean tracking row per keyword.
Example:
{
"report_date": "2026-01-01",
"keyword": "serp api for ai agents",
"target_domain": "example.com",
"found": true,
"position": 4,
"ranking_url": "https://example.com/serp-api-ai-agents",
"top_domains": "example.com, competitor.com, another.com"
}
Now we have the core of the SEO report.
Step 8: Save the weekly snapshot
Add a Google Sheets node or database node.
Create a sheet called seo_weekly_snapshots.
Use columns like:
report_date
keyword
location
language
target_domain
found
position
ranking_url
ranking_title
organic_result_count
top_domains
Append each tracking row.
Now every weekly run creates a snapshot.
This gives you history.
Without history, you do not have SEO reporting.
You have weekly vibes in spreadsheet form, which is somehow worse.
Step 9: Compare with the previous snapshot
Once weekly snapshots are saved, you can compare this week with last week.
There are a few ways to do this in n8n.
The simplest approach:
1. Save this week's rows
2. Read previous week's rows from the same sheet
3. Compare rows by keyword + location + target_domain
Add a Google Sheets node to read rows from seo_weekly_snapshots.
Filter in the workflow or in a Code node.
For a simple first version, read all rows and let the Code node find the latest previous date.
Add a Code node like this:
function normalizePosition(value) {
if (value === "" || value === null || value === undefined) {
return null;
}
const number = Number(value);
if (Number.isNaN(number)) {
return null;
}
return number;
}
function makeKey(row) {
return [
row.keyword,
row.location,
row.target_domain
].join("|");
}
const rows = items.map(item => item.json);
const dates = [
...new Set(
rows
.map(row => row.report_date)
.filter(Boolean)
)
].sort();
const currentDate = dates[dates.length - 1];
const previousDate = dates[dates.length - 2];
if (!currentDate || !previousDate) {
return [
{
json: {
error: "Not enough snapshot dates to compare",
current_date: currentDate || "",
previous_date: previousDate || ""
}
}
];
}
const currentRows = rows.filter(row => row.report_date === currentDate);
const previousRows = rows.filter(row => row.report_date === previousDate);
const previousByKey = {};
for (const row of previousRows) {
previousByKey[makeKey(row)] = row;
}
const comparisonRows = [];
for (const current of currentRows) {
const key = makeKey(current);
const previous = previousByKey[key] || null;
const currentPosition = normalizePosition(current.position);
const previousPosition = previous
? normalizePosition(previous.position)
: null;
let changeType = "no_previous_data";
let positionChange = "";
if (previous) {
if (previousPosition === null && currentPosition === null) {
changeType = "not_found";
} else if (previousPosition === null && currentPosition !== null) {
changeType = "new_ranking";
} else if (previousPosition !== null && currentPosition === null) {
changeType = "lost_ranking";
} else {
positionChange = previousPosition - currentPosition;
if (positionChange > 0) {
changeType = "up";
} else if (positionChange < 0) {
changeType = "down";
} else {
changeType = "same";
}
}
}
comparisonRows.push({
report_date: currentDate,
previous_date: previousDate,
keyword: current.keyword,
location: current.location,
target_domain: current.target_domain,
previous_position: previousPosition,
current_position: currentPosition,
position_change: positionChange,
change_type: changeType,
ranking_url: current.ranking_url,
ranking_title: current.ranking_title,
top_domains: current.top_domains
});
}
return comparisonRows.map(row => ({ json: row }));
Important detail:
Position 3 is better than position 8.
So 8 → 3 means up 5.
SEO ranking numbers work backwards because apparently even numbers wanted drama.
Step 10: Detect new competitors
A useful weekly report should not only track your own domain.
It should also show which domains are appearing in search results.
If your snapshot stores top_domains, you can compare this week's top domains with last week's.
Add this helper inside your comparison Code node if you want competitor changes:
function parseDomains(value) {
if (!value) return [];
return String(value)
.split(",")
.map(domain => domain.trim())
.filter(Boolean);
}
function findNewDomains(previousDomains, currentDomains) {
const previousSet = new Set(previousDomains);
return currentDomains.filter(domain => !previousSet.has(domain));
}
Then for each keyword:
const previousDomains = previous ? parseDomains(previous.top_domains) : [];
const currentDomains = parseDomains(current.top_domains);
const newDomains = findNewDomains(previousDomains, currentDomains);
Add this to the comparison row:
new_domains: newDomains.join(", ")
This is useful because rankings can change even when your own domain does not move.
A new competitor entering the SERP is still worth noticing.
Step 11: Generate a weekly report
Now add a Code node to turn comparison rows into a readable report.
const rows = items.map(item => item.json);
const validRows = rows.filter(row => !row.error);
if (validRows.length === 0) {
return [
{
json: {
report: "No valid SEO comparison data was available this week."
}
}
];
}
const reportDate = validRows[0].report_date;
const previousDate = validRows[0].previous_date;
const trackedKeywords = validRows.length;
const rankedKeywords = validRows.filter(row =>
row.current_position !== null &&
row.current_position !== "" &&
row.current_position !== undefined
).length;
const notFoundKeywords = trackedKeywords - rankedKeywords;
const top10Keywords = validRows.filter(row =>
Number(row.current_position) > 0 &&
Number(row.current_position) <= 10
).length;
const wins = validRows
.filter(row => row.change_type === "up")
.sort((a, b) => Number(b.position_change) - Number(a.position_change))
.slice(0, 5);
const drops = validRows
.filter(row => row.change_type === "down")
.sort((a, b) => Number(a.position_change) - Number(b.position_change))
.slice(0, 5);
const newRankings = validRows
.filter(row => row.change_type === "new_ranking")
.slice(0, 5);
const lostRankings = validRows
.filter(row => row.change_type === "lost_ranking")
.slice(0, 5);
function formatMovement(row) {
return `- ${row.keyword}: ${row.previous_position || "not found"} → ${row.current_position || "not found"} (${row.ranking_url || "no URL"})`;
}
let report = "";
report += `# Weekly SEO Report\n\n`;
report += `Report date: ${reportDate}\n`;
report += `Previous snapshot: ${previousDate}\n\n`;
report += `## Summary\n\n`;
report += `- Tracked keywords: ${trackedKeywords}\n`;
report += `- Keywords ranked: ${rankedKeywords}\n`;
report += `- Keywords not found: ${notFoundKeywords}\n`;
report += `- Top 10 rankings: ${top10Keywords}\n\n`;
report += `## Biggest Wins\n\n`;
if (wins.length) {
report += wins.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No major ranking wins this week.\n\n`;
}
report += `## Biggest Drops\n\n`;
if (drops.length) {
report += drops.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No major ranking drops this week.\n\n`;
}
report += `## New Rankings\n\n`;
if (newRankings.length) {
report += newRankings.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No new rankings detected this week.\n\n`;
}
report += `## Lost Rankings\n\n`;
if (lostRankings.length) {
report += lostRankings.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No lost rankings detected this week.\n\n`;
}
report += `## Notes\n\n`;
report += `This report is based on search result snapshots. Rankings can vary by location, device, personalization, and result type.\n`;
return [
{
json: {
report
}
}
];
This creates a clean Markdown report.
You can send it to Slack, email, Notion, or save it to a database.
Step 12: Send the report to Slack
Add a Slack node.
Set the message text to:
{{ $json.report }}
Or use an Email node.
Subject:
Weekly SEO Report
Body:
{{ $json.report }}
If your team uses Notion, you can also create a new page every week.
Use whatever destination people actually read.
A report nobody reads is just a spreadsheet wearing a tie.
Step 13: Add search intent notes with an LLM
Once the basic report works, you can add an LLM node after the comparison step.
Ask it to summarize changes.
Prompt:
You are an SEO analyst.
Summarize the weekly SEO changes below.
Rules:
- Be concise.
- Focus on meaningful changes.
- Mention ranking wins, drops, lost rankings, and new competitors.
- Do not overreact to small changes.
- Return action items.
SEO comparison data:
{{comparison_rows}}
Output example:
This week, visibility improved for API-related keywords, especially "serp api for ai agents" and "google search api alternative." The main issue is that "bing search api" dropped out of the tracked results. Review whether the ranking page is still indexed and whether competitors have published newer comparison content.
This turns raw ranking data into a more readable report.
But keep the raw data.
LLM summaries are helpful.
Raw snapshots are evidence.
Do not replace evidence with prose. That is how dashboards become bedtime stories.
Step 14: Store the full SERP snapshot
For a more advanced version, store the top results for each keyword.
Create another sheet called seo_serp_results.
Columns:
report_date
keyword
location
position
title
url
domain
snippet
This lets you analyze:
which competitors appear most often
which URLs entered the SERP
which domains disappeared
which result titles changed
which keywords have unstable SERPs
If you only store your own ranking position, you miss a lot of useful search context.
A weekly SEO report becomes much more useful when it shows the market around you, not just your own little island.
Step 15: Common mistakes
Tracking too many keywords too early
Start with a small keyword set.
A clean report for 20 keywords is better than a broken workflow for 2,000.
Ignoring location
Search results change by location.
Store the location with every snapshot.
Comparing different search settings
Do not compare last week's desktop US results with this week's mobile UK results.
That is not analysis. That is spreadsheet astrology.
Not saving raw enough data
At minimum, save:
keyword
location
position
ranking URL
top domains
date
You need history to explain changes.
Sending every small movement as an alert
A move from position 7 to 8 is usually not a crisis.
Alert on meaningful changes:
entered top 10
left top 10
lost ranking
new competitor appeared
moved more than 3 positions
Treating SERP data as perfectly stable
Search results vary.
Use weekly trends, not single snapshots, to make decisions.
Step 16: Provider note
This workflow works with any SERP API that returns structured search results.
When choosing one, test with your real keywords.
Check:
Are organic results easy to parse?
Are position, title, URL, and snippet available?
Does location targeting work?
Does the response include enough results?
Are empty responses common?
Can you afford the weekly volume?
Is the JSON consistent enough for n8n?
Talordata is one option for this kind of workflow because it provides structured SERP data that can be used in automation tools like n8n. But the workflow itself is provider-agnostic.
The practical rule is simple:
Do not choose from a homepage.
Choose from the response body.
That is where your workflow either becomes easy or starts demanding sacrifices.
Final workflow recap
The full n8n workflow looks like this:
Schedule Trigger
→ Read keywords from Google Sheets
→ Split In Batches
→ HTTP Request to SERP API
→ Normalize organic results
→ Find target domain ranking
→ Save weekly snapshot
→ Read previous snapshot
→ Compare changes
→ Generate Markdown report
→ Send to Slack or email
Start with the smallest useful version:
10 keywords
1 target domain
1 location
weekly schedule
Google Sheets storage
Slack report
Then add:
competitor tracking
top domain changes
LLM summaries
Notion reports
database storage
top 10 movement alerts
search intent analysis
The point is not to build a massive SEO platform.
The point is to stop manually checking search results and start getting a useful weekly signal.
That is what automation is for.
Not replacing thinking.
Just removing the repetitive part that makes thinking feel like punishment.
Top comments (0)