Competitor monitoring sounds simple.
Just check what your competitors are doing, right?
Then reality shows up with a clipboard.
You need to track:
Which competitors appear for our target keywords?
Which pages are ranking?
Did a new domain enter the SERP?
Did our own page drop?
Did a competitor publish a new comparison page?
Which keywords are getting more competitive?
Doing this manually is painful.
Doing it once is fine.
Doing it every week is annoying.
Doing it across 50 keywords is how spreadsheets become emotional damage.
In this tutorial, we will build a competitor monitoring workflow in n8n.
The workflow will:
- Read a list of keywords
- Call a SERP API
- Extract organic search results
- Find your own domain position
- Track known competitor domains
- Detect new domains appearing in the SERP
- Save a snapshot
- Compare this snapshot with the previous one
- Send a report to Slack or email
The workflow looks like this:
Schedule Trigger
→ Read keyword list
→ SERP API request
→ Clean search results
→ Extract competitor visibility
→ Save snapshot
→ Compare with previous snapshot
→ Send report
This is useful for:
SEO teams
content teams
startup founders
market research workflows
brand monitoring
AI research agents
competitor intelligence pipelines
Not a giant platform.
Just a workflow that tells you what changed.
Sometimes that is enough. Humanity may briefly survive.
What we are monitoring
For each keyword, we want to know:
keyword
location
our domain position
our ranking URL
top ranking domains
known competitor positions
new domains in search results
new ranking URLs
date
Example output:
Keyword: serp api for ai agents
Our domain: example.com
Our position: 5
Known competitors:
- competitor-a.com at position 2
- competitor-b.com at position 7
New domains this week:
- newcompetitor.com
- reviewsite.com
This gives you useful signals:
a competitor moved up
your page dropped
a new comparison page appeared
a new domain entered the SERP
a keyword became more competitive
That is more useful than staring at search results manually like a medieval monk copying rankings by candlelight.
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 reporting
This tutorial uses a generic SERP API request.
You can adapt it to providers like Talordata, SerpApi, SearchAPI, DataForSEO, Bright Data, or another SERP API provider.
The provider does not matter as much as the response shape.
You need fields like:
position
title
url
snippet
Workflow overview
In n8n, create this workflow: View integration documentation>>
1. Schedule Trigger
2. Google Sheets: Read keyword list
3. Split In Batches
4. HTTP Request: Call SERP API
5. Code: Clean SERP Results
6. Code: Extract Competitor Visibility
7. Google Sheets: Save Snapshot
8. Google Sheets: Read Snapshot History
9. Code: Compare Snapshots
10. Code: Generate Report
11. Slack or Email: Send Report
For the first version, you can skip comparison and only save snapshots.
But the useful part starts when you compare this week with last week.
Competitor monitoring is not about one result page.
It is about change.
Step 1: Create the keyword sheet
Create a Google Sheet named:
competitor_keywords
Add these columns:
keyword
location
language
own_domain
competitor_domains
Example:
keyword,location,language,own_domain,competitor_domains
serp api for ai agents,United States,en,example.com,"competitor-a.com,competitor-b.com"
google search api alternative,United States,en,example.com,"competitor-a.com,competitor-c.com"
bing search api,United States,en,example.com,"competitor-b.com,competitor-d.com"
The competitor_domains field is a comma-separated list.
This lets you track known competitors while still detecting new domains that appear in search results.
Start small:
10 keywords
1 location
3 to 5 known competitors
weekly schedule
Do not start with 1,000 keywords unless your hobby is debugging sadness.
Step 2: Add a Schedule Trigger
Add a Schedule Trigger node.
Set it to run weekly.
Example:
Every Monday at 9:00 AM
Weekly monitoring is usually enough for competitor visibility.
Daily monitoring can be useful for high-priority keywords, but it also creates more noise.
A competitor moving from position 7 to 8 is not a corporate emergency. It is just Tuesday.
Step 3: Read keywords from Google Sheets
Add a Google Sheets node.
Use:
Operation: Read
Sheet: competitor_keywords
Each item should look like this:
{
"keyword": "serp api for ai agents",
"location": "United States",
"language": "en",
"own_domain": "example.com",
"competitor_domains": "competitor-a.com,competitor-b.com"
}
This becomes the input for each SERP API request.
Step 4: Split keywords into batches
Add a Split In Batches node.
Use:
Batch Size: 1
This makes the workflow easier to debug.
Later, you can increase the batch size if needed.
One request per keyword is boring. Boring is stable. Stable is how workflows live long enough to be useful.
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 normal.
The important idea is:
keyword + location → SERP JSON
Name this node:
Fetch SERP Results
Step 6: Clean SERP results
After the HTTP Request node, add a Code node.
Name it:
Clean SERP Results
This node extracts organic results and normalizes the fields.
Paste this code:
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(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function cleanUrl(url) {
if (!url) return "";
try {
const parsed = new URL(url);
const trackingParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid"
];
for (const param of trackingParams) {
parsed.searchParams.delete(param);
}
parsed.hash = "";
return parsed.toString();
} catch (error) {
return String(url).trim();
}
}
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 normalizeResult(item) {
const rawUrl = item.link || item.url || item.href || "";
const url = cleanUrl(rawUrl);
return {
position: item.position || item.rank || "",
title: cleanText(item.title || ""),
url,
domain: extractDomain(url),
snippet: cleanText(item.snippet || item.description || "")
};
}
function isUsefulResult(result) {
if (!result.title) return false;
if (!result.url) return false;
if (!result.domain) return false;
return true;
}
const input = $('Read Keywords').item.json;
const rawData = $json;
const organicResults = getOrganicResults(rawData);
const cleanedResults = organicResults
.map(normalizeResult)
.filter(isUsefulResult)
.slice(0, 20);
return [
{
json: {
report_date: new Date().toISOString().slice(0, 10),
keyword: input.keyword,
location: input.location,
language: input.language || "en",
own_domain: normalizeDomain(input.own_domain),
competitor_domains: input.competitor_domains || "",
organic_result_count: cleanedResults.length,
organic_results: cleanedResults
}
}
];
Important note:
The line below assumes your Google Sheets node is named Read Keywords:
const input = $('Read Keywords').item.json;
Rename it if your node has a different name.
This is n8n’s charming way of reminding you that node names matter. Tiny workflow bureaucracy, delightful as ever.
Step 7: Extract competitor visibility
Add another Code node.
Name it:
Extract Competitor Visibility
Paste this code:
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(resultDomain, targetDomain) {
const result = normalizeDomain(resultDomain);
const target = normalizeDomain(targetDomain);
if (!result || !target) {
return false;
}
return result === target || result.endsWith("." + target);
}
function parseCompetitorDomains(value) {
if (!value) return [];
return String(value)
.split(",")
.map(normalizeDomain)
.filter(Boolean);
}
function findDomainResult(results, targetDomain) {
for (const result of results) {
if (domainMatches(result.domain, targetDomain)) {
return result;
}
}
return null;
}
function getTopDomains(results) {
const domains = [];
for (const result of results) {
const domain = normalizeDomain(result.domain);
if (domain && !domains.includes(domain)) {
domains.push(domain);
}
}
return domains;
}
function getTopUrls(results) {
return results
.filter(result => result.url)
.map(result => ({
position: result.position,
title: result.title,
url: result.url,
domain: result.domain
}));
}
const results = $json.organic_results || [];
const ownDomain = normalizeDomain($json.own_domain);
const competitorDomains = parseCompetitorDomains($json.competitor_domains);
const ownResult = findDomainResult(results, ownDomain);
const competitorMatches = [];
for (const domain of competitorDomains) {
const match = findDomainResult(results, domain);
if (match) {
competitorMatches.push({
domain,
found: true,
position: match.position,
url: match.url,
title: match.title
});
} else {
competitorMatches.push({
domain,
found: false,
position: "",
url: "",
title: ""
});
}
}
const topDomains = getTopDomains(results);
const topUrls = getTopUrls(results);
return [
{
json: {
report_date: $json.report_date,
keyword: $json.keyword,
location: $json.location,
language: $json.language,
own_domain: ownDomain,
own_found: Boolean(ownResult),
own_position: ownResult ? ownResult.position : "",
own_url: ownResult ? ownResult.url : "",
own_title: ownResult ? ownResult.title : "",
competitor_matches_json: JSON.stringify(competitorMatches),
top_domains: topDomains.join(", "),
top_urls_json: JSON.stringify(topUrls),
organic_result_count: $json.organic_result_count
}
}
];
The output now has one clean snapshot row per keyword.
Example:
{
"report_date": "2026-01-01",
"keyword": "serp api for ai agents",
"location": "United States",
"own_domain": "example.com",
"own_position": 5,
"own_url": "https://example.com/serp-api-ai-agents",
"top_domains": "competitor-a.com, example.com, competitor-b.com",
"competitor_matches_json": "[...]"
}
This is ready to save.
Step 8: Save the snapshot
Create another Google Sheet named:
competitor_serp_snapshots
Add these columns:
report_date
keyword
location
language
own_domain
own_found
own_position
own_url
own_title
competitor_matches_json
top_domains
top_urls_json
organic_result_count
Add a Google Sheets node.
Use:
Operation: Append
Sheet: competitor_serp_snapshots
Map the fields from the previous Code node.
Now every workflow run saves a search snapshot.
That history is the foundation of competitor monitoring.
Without snapshots, you are just repeatedly looking at the present and pretending it is analysis.
Step 9: Read snapshot history
After saving the current snapshot, add another Google Sheets node.
Use:
Operation: Read
Sheet: competitor_serp_snapshots
This reads all saved snapshot rows.
For a small workflow, reading all rows is fine.
For a larger workflow, filter by recent dates or use a database.
A database becomes useful when the sheet starts wheezing like an old printer.
Step 10: Compare current and previous snapshots
Add a Code node.
Name it:
Compare Snapshots
Paste this code:
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.own_domain
].join("|");
}
function parseDomains(value) {
if (!value) return [];
return String(value)
.split(",")
.map(domain => domain.trim().toLowerCase())
.filter(Boolean);
}
function findNewDomains(previousDomains, currentDomains) {
const previousSet = new Set(previousDomains);
return currentDomains.filter(domain => !previousSet.has(domain));
}
function findLostDomains(previousDomains, currentDomains) {
const currentSet = new Set(currentDomains);
return previousDomains.filter(domain => !currentSet.has(domain));
}
function parseJson(value, fallback) {
if (!value) return fallback;
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
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.own_position);
const previousPosition = previous
? normalizePosition(previous.own_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";
}
}
}
const previousDomains = previous ? parseDomains(previous.top_domains) : [];
const currentDomains = parseDomains(current.top_domains);
const newDomains = findNewDomains(previousDomains, currentDomains);
const lostDomains = findLostDomains(previousDomains, currentDomains);
const previousCompetitors = previous
? parseJson(previous.competitor_matches_json, [])
: [];
const currentCompetitors = parseJson(
current.competitor_matches_json,
[]
);
comparisonRows.push({
report_date: currentDate,
previous_date: previousDate,
keyword: current.keyword,
location: current.location,
own_domain: current.own_domain,
previous_position: previousPosition,
current_position: currentPosition,
position_change: positionChange,
change_type: changeType,
own_url: current.own_url || "",
new_domains: newDomains.join(", "),
lost_domains: lostDomains.join(", "),
current_competitors_json: JSON.stringify(currentCompetitors),
previous_competitors_json: JSON.stringify(previousCompetitors),
top_domains: current.top_domains || ""
});
}
return comparisonRows.map(row => ({ json: row }));
This compares:
current own ranking vs previous own ranking
current top domains vs previous top domains
current known competitor visibility vs previous known competitor visibility
It detects:
up
down
same
new ranking
lost ranking
new domains
lost domains
That is the core of the competitor monitoring workflow.
Step 11: Generate a competitor report
Add another Code node.
Name it:
Generate Report
Paste this code:
const rows = items.map(item => item.json);
const validRows = rows.filter(row => !row.error);
if (validRows.length === 0) {
return [
{
json: {
report: "No valid competitor monitoring data was available."
}
}
];
}
const reportDate = validRows[0].report_date;
const previousDate = validRows[0].previous_date;
const trackedKeywords = validRows.length;
const ownRanked = validRows.filter(row =>
row.current_position !== null &&
row.current_position !== "" &&
row.current_position !== undefined
).length;
const lostRankings = validRows.filter(row =>
row.change_type === "lost_ranking"
);
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 rowsWithNewDomains = validRows.filter(row =>
row.new_domains && row.new_domains.length > 0
);
function formatPosition(value) {
if (value === null || value === "" || value === undefined) {
return "not found";
}
return String(value);
}
function formatMovement(row) {
return `- ${row.keyword}: ${formatPosition(row.previous_position)} → ${formatPosition(row.current_position)} (${row.own_url || "no URL"})`;
}
function formatNewDomains(row) {
return `- ${row.keyword}: ${row.new_domains}`;
}
let report = "";
report += `# Competitor Monitoring 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 where our domain ranked: ${ownRanked}\n`;
report += `- Lost rankings: ${lostRankings.length}\n`;
report += `- Keywords with new domains in SERP: ${rowsWithNewDomains.length}\n\n`;
report += `## Our Biggest Wins\n\n`;
if (wins.length) {
report += wins.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No major ranking wins this period.\n\n`;
}
report += `## Our Biggest Drops\n\n`;
if (drops.length) {
report += drops.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No major ranking drops this period.\n\n`;
}
report += `## Lost Rankings\n\n`;
if (lostRankings.length) {
report += lostRankings.map(formatMovement).join("\n") + "\n\n";
} else {
report += `No lost rankings detected.\n\n`;
}
report += `## New Domains Appearing in Search Results\n\n`;
if (rowsWithNewDomains.length) {
report += rowsWithNewDomains
.slice(0, 10)
.map(formatNewDomains)
.join("\n") + "\n\n";
} else {
report += `No new domains detected in the tracked SERPs.\n\n`;
}
report += `## Notes\n\n`;
report += `This report is based on SERP snapshots. Rankings can vary by location, device, personalization, and search result type.\n`;
return [
{
json: {
report
}
}
];
This creates a Markdown report.
Example:
Competitor Monitoring Report
Tracked keywords: 25
Keywords where our domain ranked: 14
Lost rankings: 2
Keywords with new domains in SERP: 6
Our Biggest Wins:
- google search api alternative: 9 → 5
New Domains Appearing:
- serp api for ai agents: newcompetitor.com, reviewsite.com
This is the part people actually read.
Because apparently raw JSON does not inspire executive confidence. Their loss.
Step 12: Send the report to Slack or email
Add a Slack node.
Set message text to:
{{ $json.report }}
Or add an Email node.
Subject:
Competitor Monitoring Report
Body:
{{ $json.report }}
You can also send it to:
Notion
Google Docs
Airtable
Linear
ClickUp
Discord
Use whatever tool your team actually checks.
A report sent to a dead channel is just automation theater.
Improving the workflow
Once the basic workflow works, you can add more useful features.
Track ranking URLs, not just domains
A competitor domain may appear with different pages over time.
For example:
competitor.com/blog/best-tools
competitor.com/comparison/your-brand-vs-competitor
competitor.com/pricing
Tracking URLs helps you detect:
new comparison pages
new content pages
pricing pages entering SERPs
review pages appearing
competitor landing pages moving up
Store top_urls_json and compare URLs between snapshots.
Track known competitors separately
Known competitors are different from unknown new domains.
Known competitors answer:
Are the competitors we care about ranking?
New domains answer:
Who else is entering this SERP?
You need both.
One is monitoring.
The other is discovery.
Add brand monitoring
Add branded searches like:
your brand alternative
your brand pricing
your brand reviews
your brand vs competitor
These are high-intent searches.
If a competitor or review site appears there, you probably want to know.
Not because panic is fun, but because branded SERPs shape buyer perception.
Add content opportunity detection
You can also detect pages that rank repeatedly across many keywords.
For example:
domain appears in 12 tracked keywords
same URL ranks across 5 related searches
new blog post entered 3 SERPs this week
This can tell your content team what competitors are doing well.
Add an LLM summary
After generating the report, send the comparison data to an LLM node.
Prompt:
You are an SEO analyst.
Summarize the competitor monitoring data below.
Rules:
- Focus on meaningful changes.
- Mention new competitors, ranking drops, and lost rankings.
- Do not overreact to small one-position changes.
- Suggest practical next actions.
- Keep the response concise.
Data:
{{ comparison_rows }}
This can turn raw movement into readable advice.
Example:
The main change this week is that two new comparison domains entered the SERP for "google search api alternative." Our own page improved for one keyword but dropped for another. Review the new competing pages and update the affected content with fresher examples and clearer feature comparison sections.
Keep the raw data too.
LLM summaries are useful, but raw snapshots are evidence.
Do not replace evidence with prose. That is how analytics becomes bedtime fiction.
Common mistakes
Monitoring only your own domain
That is rank tracking, not competitor monitoring.
Track the full SERP.
Not saving snapshots
Without historical data, you cannot detect change.
Save every run.
Ignoring location
Search results differ by country and city.
Store location with every row.
Comparing different settings
Do not compare last week's US desktop results with this week's UK mobile results.
That is not analysis. That is spreadsheet astrology.
Tracking too many keywords too soon
Start with a small keyword set.
Clean workflow first. Scale later.
Treating every movement as important
A one-position move may not matter.
New domains, lost rankings, new ranking URLs, and top 10 changes are more useful signals.
Provider note
This workflow works with any SERP API that returns structured search results.
When choosing a provider, test your real keywords.
Check:
Does it return position, title, URL, and snippet?
Is location targeting reliable?
Is the JSON easy to normalize?
Are empty responses common?
Can you afford the weekly volume?
Does it support the search engines and result types you need?
Talordata can be used as the SERP API layer for this kind of n8n workflow, especially when you need structured search data for automation, SEO monitoring, or AI workflows.
But the workflow itself is provider-agnostic.
The rule is simple:
Choose the API with the cleanest response body for your actual workflow.
The homepage is not where your workflow runs.
The JSON is.
Terrible news for adjectives everywhere.
Final workflow recap
The final n8n workflow is:
Schedule Trigger
→ Read keywords
→ Split In Batches
→ HTTP Request to SERP API
→ Clean SERP Results
→ Extract Competitor Visibility
→ Save Snapshot
→ Read Snapshot History
→ Compare Snapshots
→ Generate Report
→ Send to Slack or Email
The important data to store is:
date
keyword
location
own domain position
own ranking URL
known competitor matches
top domains
top ranking URLs
organic result count
Start with:
10 keywords
1 location
weekly schedule
Google Sheets storage
Slack report
Then add:
brand monitoring
URL change detection
LLM summaries
database storage
multi-location tracking
daily alerts for high-priority keywords
Competitor monitoring does not need to start as a large platform.
It can start as a reliable weekly workflow.
That is usually enough to notice what changed before everyone else pretends they saw it coming.
Top comments (0)