Full-stack lead generation automation using free, API-keyless data sources.
This project demonstrates a production-ready automation pipeline that collects local business leads from OpenStreetMap's Overpass API (completely free, no API keys required), processes and normalizes the data through n8n workflows, and presents results in a modern Next.js dashboard with CSV/Google Sheets export capabilities. Perfect for sales teams, marketers, or anyone needing to discover local businesses without expensive API subscriptions.
Why This Project Is Useful:
- ✅ 100% Free Data Source – No paid APIs, no rate limits, no credit card required
- ✅ Production-Ready Pipeline – Proper data normalization, deduplication, and error handling
- ✅ Complete Solution – From raw OSM data to exportable leads in one workflow
- ✅ Professional Frontend – Modern React dashboard with real-time search and export
┌─────────────┐ POST ┌──────────────┐ ┌─────────────┐
│ Frontend │ ───────────────> │ Webhook │ ───> │ Normalize │
│ (Next.js) │ │ (n8n) │ │ Input │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌─────────────┐ JSON ┌──────────────┐ ┌─────────────┐
│ CSV/Sheets │ <─────────────── │ Respond │ <─── │ Dedupe & │
│ Export │ │ (n8n) │ │ Slice │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Overpass │
│ API │
│ (OpenStreetMap)
└─────────────┘
Frontend Dashboard:
Search interface with city autocomplete, category selection, and real-time results
n8n Workflow:
Complete workflow showing data flow from webhook to Google Sheets
Google Sheets Export:
Automated data export with proper formatting and timestamps
- Backend/Workflow: n8n (workflow automation)
- Data Source: Overpass API (OpenStreetMap, free, no API key)
- Frontend: Next.js 15, React 19, TypeScript
- Export: CSV download, Google Sheets API (optional)
- Testing: Jest + React Testing Library (98% coverage)
- Deployment: Vercel (frontend), n8n Cloud or self-hosted
git clone https://github.com/gamzeozgul/n8n-lead-generator.git
cd n8n-lead-generator
cd frontend && npm install && cd ..npx n8n@latest startOpen http://localhost:5678 and import the pre-built workflow:
- Go to Workflows → Import from File
- Select
workflows/leadgen-overpass.json - Activate the workflow
Or build manually: See Workflow Setup section below.
cd frontend
npm run devOpen http://localhost:3000
cd frontend
npm testURL: POST /webhook/lead-generation
Base URL (local): http://localhost:5678/webhook/lead-generation
{
"city": "Istanbul",
"category": "coffee",
"limit": 20,
"offset": 0
}Required Fields:
city(string) – City name (e.g., "Istanbul", "New York", "Berlin")category(string) – Business category (see supported categories below)
Optional Fields:
limit(number, default: 20, max: 100) – Number of results to returnoffset(number, default: 0) – Pagination offset
Supported Categories:
coffee– Coffee shops and cafesrestaurant– Restaurantsbar– Bars and pubshair– Hairdresserspharmacy– Pharmacies
Success (200 OK):
{
"success": true,
"total": 45,
"items": [
{
"id": "123456789",
"name": "Starbucks",
"category": "coffee",
"city": "Istanbul",
"address": "Istiklal Caddesi 123, Istanbul",
"phone": "+90 212 123 4567",
"website": "https://www.starbucks.com.tr",
"lat": 41.0369,
"lon": 28.9850,
"source": "overpass"
}
]
}Error (400 Bad Request):
{
"success": false,
"error": "city and category are required"
}cURL:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"city":"Istanbul","category":"coffee","limit":20,"offset":0}' \
http://localhost:5678/webhook/lead-generationJavaScript:
const response = await fetch('http://localhost:5678/webhook/lead-generation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
city: 'Istanbul',
category: 'coffee',
limit: 20,
offset: 0,
}),
});
const data = await response.json();Import the pre-built workflow from workflows/leadgen-overpass.json (see Quick Start section above).
Create a new workflow in n8n named leadgen-overpass:
-
Webhook (Trigger)
- Path:
lead-generation - Methods:
POST, OPTIONS
- Path:
-
Code – Normalize Input
const body = $json.body ?? $json; const city = (body.city || "").toString().trim(); const category = (body.category || "").toString().trim(); const limit = Math.max(1, Math.min(parseInt(body.limit ?? '20', 10) || 20, 100)); const offset = Math.max(0, parseInt(body.offset ?? '0', 10) || 0); if (!city || !category) { return [{ json: { _error: true, status: 400, message: "city and category are required" } }]; } return [{ json: { city, category, limit, offset } }];
-
IF – Input Error?
- Condition:
{{$json._error === true}} - True → Error Response
- False → Build Query
- Condition:
-
Code – Build Overpass Query
const category = $json.category.toLowerCase(); const city = $json.city; const limit = $json.limit; const offset = $json.offset; const tagMap = { "coffee": ["amenity=cafe", "amenity=coffee_shop"], "restaurant": ["amenity=restaurant"], "bar": ["amenity=bar", "amenity=pub"], "hair": ["shop=hairdresser"], "pharmacy": ["amenity=pharmacy"], }; const tags = tagMap[category] ?? ["amenity=restaurant"]; const filters = tags.map(t => `nwr[${t}](area.searchArea)`).join(";\n "); const query = `[out:json][timeout:25]; ( relation["name"="${city}"]["admin_level"~"^[45678]$"]["boundary"="administrative"]; ); out geom; map_to_area; ( ${filters}; ); out center meta; `; return [{ json: { query, limit, offset, city, category } }];
-
HTTP Request – Overpass API
- Method:
POST - URL:
https://overpass-api.de/api/interpreter - Body:
={{$json.query}} - Settings: Enable Retry On Fail (3 retries, 5s delay)
Fallback Mirrors (if primary fails):
https://z.overpass-api.de/api/interpreterhttps://overpass.kumi.systems/api/interpreterhttps://overpass.openstreetmap.ru/cgi/interpreter
- Method:
-
Code – Transform & Dedupe
const data = $json.elements || []; const seen = new Set(); const results = []; const prevData = $('Build Overpass Query').item.json || {}; for (const item of data) { if (!item.tags || !item.tags.name) continue; const key = `${item.tags.name}|${item.lat}|${item.lon}`; if (seen.has(key)) continue; seen.add(key); results.push({ id: item.id || null, name: item.tags.name || null, category: prevData.category || null, city: prevData.city || null, street: item.tags["addr:street"] || null, housenumber: item.tags["addr:housenumber"] || null, address: item.tags["addr:full"] || null, phone: item.tags.phone || item.tags["contact:phone"] || null, website: item.tags.website || item.tags.url || null, lat: item.lat || item.center?.lat || null, lon: item.lon || item.center?.lon || null, source: "overpass", }); } return results.map(x => ({ json: x }));
-
Code – Slice (Pagination)
const items = $input.all().map(item => item.json); const prevData = $('Build Overpass Query').item.json || {}; const limit = prevData.limit || 20; const offset = prevData.offset || 0; const page = items.slice(offset, offset + limit); return page.map(x => ({ json: x }));
-
Respond to Webhook – Success
- Response Body:
={{ { success: true, total: $input.all().length, items: $input.all().map(item => item.json) } }} - Headers:
Access-Control-Allow-Origin: *Access-Control-Allow-Methods: POST, OPTIONSAccess-Control-Allow-Headers: Content-Type
- Response Body:
-
Respond to Webhook – Error
- Response Code:
={{$json.status || 400}} - Response Body:
={{ { success: false, error: $json.message || "Invalid input" } }} - Same CORS headers as above
- Response Code:
Workflow Flow:
Webhook → Normalize → IF → (true) Error Response
IF (false) → Build Query → HTTP → Transform & Dedupe → Slice → Success Response
After the Slice node, add a Google Sheets – Append Row node:
-
Google Cloud Console Setup:
- Enable Google Sheets API and Google Drive API
- Create OAuth 2.0 credentials (Web application)
- Authorized redirect URI:
http://localhost:5678/rest/oauth2-credential/callback - Add your email as a Test user in OAuth Consent Screen
-
Add Credential in n8n:
- Go to Credentials → Add Credential → Google OAuth2 API
- Paste Client ID and Client Secret
- Connect your Google account
-
Create Google Sheet:
- Create a new sheet with headers (row 1):
id | name | category | city | street | housenumber | address | phone | website | lat | lon | source | fetched_at - Copy Sheet ID from URL
- Create a new sheet with headers (row 1):
-
Configure Google Sheets Node:
- Operation:
Append Row - Spreadsheet ID: Your Sheet ID
- Map columns (use expressions like
={{$json.name}}) - Important: For
phone, use={{$json.phone ? "'" + $json.phone : ""}}to prevent formula interpretation - For
fetched_at, use={{$now.toISO()}}
- Operation:
-
Wire:
Slice→Google Sheets→Respond to Webhook (Success)
Note: CSV export is already implemented in the frontend. Google Sheets is optional.
- ✅ Webhook-based API endpoint
- ✅ Input validation and normalization
- ✅ Overpass API integration with retry logic
- ✅ Data deduplication
- ✅ Pagination support (limit/offset)
- ✅ Error handling with proper HTTP status codes
- ✅ CORS configuration
- ✅ Optional Google Sheets export
- ✅ Modern React dashboard with TypeScript
- ✅ City autocomplete (no API required)
- ✅ Category selection
- ✅ Real-time search results
- ✅ CSV export functionality
- ✅ Responsive design
- ✅ Error handling and loading states
- ✅ Unit tests with 98% coverage
n8n-lead-generator/
├── workflows/
│ └── leadgen-overpass.json # Pre-built n8n workflow (import ready)
├── frontend/ # Next.js dashboard
│ ├── src/
│ │ ├── app/ # Next.js app router
│ │ ├── lib/ # Utility functions
│ │ └── types/ # TypeScript types
│ └── __tests__/ # Unit tests
├── screenshots/ # Project screenshots
└── README.md # This file
Contributions are welcome! Please feel free to submit a Pull Request.
MIT License – see LICENSE file for details.
Gamze Özgül
- GitHub: @gamzeozgul
- OpenStreetMap for free, open geographic data
- Overpass API for query interface
- n8n for workflow automation platform
- Next.js for the React framework