Your Jekyll site serves customers in specific locations, but it's not appearing in local search results. You're missing out on valuable "near me" searches and local business traffic. Cloudflare Analytics shows you where your visitors are coming from geographically, but you're not using this data to optimize for local SEO. The problem is that local SEO requires location-specific optimizations that most static site generators struggle with. The solution is leveraging Cloudflare's edge network and analytics to implement sophisticated local SEO strategies.
Local SEO requires different tactics than traditional SEO. Start by analyzing your Cloudflare Analytics geographic data to understand where your current visitors are located. Look for patterns: Are you getting unexpected traffic from certain cities or regions? Are there locations where you have high engagement but low traffic (indicating untapped potential)?
Next, define your target service areas. If you're a local business, this is your physical service radius. If you serve multiple locations, prioritize based on population density, competition, and your current traction. For each target location, create a local SEO plan including: Google Business Profile optimization, local citation building, location-specific content, and local link building.
The key insight for Jekyll sites: you can create location-specific pages dynamically using Cloudflare Workers, even though your site is static. This gives you the flexibility of dynamic local SEO without complex server infrastructure.
| Component | Traditional Approach | Jekyll + Cloudflare Approach | Local SEO Impact |
|---|---|---|---|
| Location Pages | Static HTML pages | Dynamic generation via Workers | Target multiple locations efficiently |
| NAP Consistency | Manual updates | Centralized data file + auto-update | Better local ranking signals |
| Local Content | Generic content | Geo-personalized via edge | Higher local relevance |
| Structured Data | Basic LocalBusiness | Dynamic based on visitor location | Rich results in local search |
| Reviews Integration | Static display | Dynamic fetch and display | Social proof for local trust |
Use Cloudflare Analytics to inform your local SEO strategy:
# Ruby script to analyze geographic opportunities
require 'json'
require 'geocoder'
class LocalSEOAnalyzer
def initialize(cloudflare_data)
@data = cloudflare_data
end
def identify_target_locations(min_visitors: 50, growth_threshold: 0.2)
opportunities = []
@data[:geographic].each do |location|
# Location has decent traffic and is growing
if location[:visitors] >= min_visitors &&
location[:growth_rate] >= growth_threshold
# Check competition (simplified)
competition = estimate_local_competition(location[:city], location[:country])
opportunities {
location: "#{location[:city]}, #{location[:country]}",
visitors: location[:visitors],
growth: (location[:growth_rate] * 100).round(2),
competition: competition,
priority: calculate_priority(location, competition)
}
end
end
# Sort by priority
opportunities.sort_by { |o| -o[:priority] }
end
def estimate_local_competition(city, country)
# Use Google Places API or similar
# Simplified example
{
low: rand(1..3),
medium: rand(4..7),
high: rand(8..10)
}
end
def calculate_priority(location, competition)
# Higher traffic + higher growth + lower competition = higher priority
traffic_score = Math.log(location[:visitors]) * 10
growth_score = location[:growth_rate] * 100
competition_score = (10 - competition[:high]) * 5
(traffic_score + growth_score + competition_score).round(2)
end
def generate_local_seo_plan(locations)
plan = {}
locations.each do |location|
plan[location[:location]] = {
immediate_actions: [
"Create location page: /locations/#{slugify(location[:location])}",
"Set up Google Business Profile",
"Build local citations",
"Create location-specific content"
],
medium_term_actions: [
"Acquire local backlinks",
"Generate local reviews",
"Run local social media campaigns",
"Participate in local events"
],
tracking_metrics: [
"Local search rankings",
"Google Business Profile views",
"Direction requests",
"Phone calls from location"
]
}
end
plan
end
end
# Usage
analytics = CloudflareAPI.fetch_geographic_data
analyzer = LocalSEOAnalyzer.new(analytics)
target_locations = analyzer.identify_target_locations
local_seo_plan = analyzer.generate_local_seo_plan(target_locations.first(5))
Create optimized location pages dynamically:
# _plugins/location_pages.rb
module Jekyll
class LocationPageGenerator < Generator
safe true
def generate(site)
# Load location data
locations = YAML.load_file('_data/locations.yml')
locations.each do |location|
# Create location page
page = LocationPage.new(site, site.source, location)
site.pages page
# Create service pages for this location
location['services'].each do |service|
service_page = ServiceLocationPage.new(site, site.source, location, service)
site.pages service_page
end
end
end
end
class LocationPage < Page
def initialize(site, base, location)
@site = site
@base = base
@dir = "locations/#{location['slug']}"
@name = 'index.html'
self.process(@name)
self.read_yaml(File.join(base, '_layouts'), 'location.html')
# Set page data
self.data['title'] = "#{location['service']} in #{location['city']}, #{location['state']}"
self.data['description'] = "Professional #{location['service']} services in #{location['city']}, #{location['state']}. Contact us today!"
self.data['location'] = location
self.data['canonical_url'] = "#{site.config['url']}/locations/#{location['slug']}/"
# Add local business schema
self.data['schema'] = generate_local_business_schema(location)
end
def generate_local_business_schema(location)
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "#{site.config['title']} - #{location['city']}",
"image": site.config['logo'],
"@id": "#{site.config['url']}/locations/#{location['slug']}/",
"url": "#{site.config['url']}/locations/#{location['slug']}/",
"telephone": location['phone'],
"address": {
"@type": "PostalAddress",
"streetAddress": location['address'],
"addressLocality": location['city'],
"addressRegion": location['state'],
"postalCode": location['zip'],
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": location['latitude'],
"longitude": location['longitude']
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "09:00",
"closes": "17:00"
}
],
"sameAs": [
site.config['facebook'],
site.config['twitter'],
site.config['linkedin']
]
}
end
end
end
# _data/locations.yml
- city: "New York"
state: "NY"
slug: "new-york-ny"
address: "123 Main St"
zip: "10001"
phone: "+1-212-555-0123"
latitude: 40.7128
longitude: -74.0060
services:
- "Web Development"
- "SEO Consulting"
- "Technical Support"
Personalize content based on visitor location using Cloudflare Workers:
// workers/geo-personalization.js
const LOCAL_CONTENT = {
'New York, NY': {
testimonials: [
{
name: 'John D.',
location: 'Manhattan',
text: 'Great service in NYC!'
}
],
local_references: 'serving Manhattan, Brooklyn, and Queens',
phone_number: '(212) 555-0123',
office_hours: '9 AM - 6 PM EST'
},
'Los Angeles, CA': {
testimonials: [
{
name: 'Sarah M.',
location: 'Beverly Hills',
text: 'Best in LA!'
}
],
local_references: 'serving Hollywood, Downtown LA, and Santa Monica',
phone_number: '(213) 555-0123',
office_hours: '9 AM - 6 PM PST'
},
'Chicago, IL': {
testimonials: [
{
name: 'Mike R.',
location: 'The Loop',
text: 'Excellent Chicago service!'
}
],
local_references: 'serving Downtown Chicago and surrounding areas',
phone_number: '(312) 555-0123',
office_hours: '9 AM - 6 PM CST'
}
}
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url)
const country = request.headers.get('CF-IPCountry')
const city = request.headers.get('CF-IPCity')
const region = request.headers.get('CF-IPRegion')
// Only personalize HTML pages
const response = await fetch(request)
const contentType = response.headers.get('Content-Type')
if (!contentType || !contentType.includes('text/html')) {
return response
}
let html = await response.text()
// Personalize based on location
const locationKey = `${city}, ${region}`
const localContent = LOCAL_CONTENT[locationKey] || LOCAL_CONTENT['New York, NY']
html = personalizeContent(html, localContent, city, region)
// Add local schema
html = addLocalSchema(html, city, region)
return new Response(html, response)
}
function personalizeContent(html, localContent, city, region) {
// Replace generic content with local content
html = html.replace(//g, generateTestimonialsHTML(localContent.testimonials))
html = html.replace(//g, localContent.local_references)
html = html.replace(//g, localContent.phone_number)
html = html.replace(//g, localContent.office_hours)
// Add city/region to page titles and headings
if (city && region) {
html = html.replace(/(.*?)<\/title>/, `<title>$1 - ${city}, ${region}</title>`)
html = html.replace(/]*>(.*?)<\/h1>/, `<h1>$1 in ${city}, ${region}</h1>`)
}
return html
}
function addLocalSchema(html, city, region) {
if (!city || !region) return html
const localSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
"about": {
"@type": "Place",
"name": `${city}, ${region}`
}
}
const schemaScript = `<script type="application/ld+json">${JSON.stringify(localSchema)}</script>`
return html.replace('</head>', `${schemaScript}</head>`)
}
Manage local citations automatically:
# lib/local_seo/citation_manager.rb
class CitationManager
CITATION_SOURCES = [
{
name: 'Google Business Profile',
url: 'https://www.google.com/business/',
fields: [:name, :address, :phone, :website, :hours]
},
{
name: 'Yelp',
url: 'https://biz.yelp.com/',
fields: [:name, :address, :phone, :website, :categories]
},
{
name: 'Facebook Business',
url: 'https://www.facebook.com/business',
fields: [:name, :address, :phone, :website, :description]
},
# Add more citation sources
]
def initialize(business_data)
@business = business_data
end
def generate_citation_report
report = {
consistency_score: calculate_nap_consistency,
missing_citations: find_missing_citations,
inconsistent_data: find_inconsistent_data,
optimization_opportunities: find_optimization_opportunities
}
report
end
def calculate_nap_consistency
# NAP = Name, Address, Phone
citations = fetch_existing_citations
consistency_score = 0
total_points = 0
citations.each do |citation|
# Check name consistency
if citation[:name] == @business[:name]
consistency_score += 1
end
total_points += 1
# Check address consistency
if normalize_address(citation[:address]) == normalize_address(@business[:address])
consistency_score += 1
end
total_points += 1
# Check phone consistency
if normalize_phone(citation[:phone]) == normalize_phone(@business[:phone])
consistency_score += 1
end
total_points += 1
end
(consistency_score.to_f / total_points * 100).round(2)
end
def find_missing_citations
existing = fetch_existing_citations.map { |c| c[:source] }
CITATION_SOURCES.reject do |source|
existing.include?(source[:name])
end.map { |source| source[:name] }
end
def submit_to_citations
results = []
CITATION_SOURCES.each do |source|
begin
result = submit_to_source(source)
results {
source: source[:name],
status: result[:success] ? 'success' : 'failed',
message: result[:message]
}
rescue => e
results {
source: source[:name],
status: 'error',
message: e.message
}
end
end
results
end
private
def submit_to_source(source)
# Implement API calls or form submissions for each source
# This is a template method
case source[:name]
when 'Google Business Profile'
submit_to_google_business
when 'Yelp'
submit_to_yelp
when 'Facebook Business'
submit_to_facebook
else
{ success: false, message: 'Not implemented' }
end
end
end
# Rake task to manage citations
namespace :local_seo do
desc "Check NAP consistency"
task :check_consistency do
manager = CitationManager.load_from_yaml('_data/business.yml')
report = manager.generate_citation_report
puts "NAP Consistency Score: #{report[:consistency_score]}%"
if report[:missing_citations].any?
puts "Missing citations:"
report[:missing_citations].each { |c| puts " - #{c}" }
end
end
desc "Submit to all citation sources"
task :submit_citations do
manager = CitationManager.load_from_yaml('_data/business.yml')
results = manager.submit_to_citations
results.each do |result|
puts "#{result[:source]}: #{result[:status]} - #{result[:message]}"
end
end
end
Track local rankings and optimize based on performance:
# lib/local_seo/rank_tracker.rb
class LocalRankTracker
def initialize(locations, keywords)
@locations = locations
@keywords = keywords
end
def track_local_rankings
rankings = {}
@locations.each do |location|
rankings[location] = {}
@keywords.each do |keyword|
local_keyword = "#{keyword} #{location}"
ranking = check_local_ranking(local_keyword, location)
rankings[location][keyword] = ranking
# Store in database
LocalRanking.create(
location: location,
keyword: keyword,
position: ranking[:position],
url: ranking[:url],
date: Date.today,
search_volume: ranking[:search_volume],
difficulty: ranking[:difficulty]
)
end
end
rankings
end
def check_local_ranking(keyword, location)
# Use SERP API with location parameter
# Example using hypothetical API
result = SerpAPI.search(
q: keyword,
location: location,
google_domain: 'google.com',
gl: 'us', # country code
hl: 'en' # language code
)
{
position: find_position(result[:organic_results], YOUR_SITE_URL),
url: find_your_url(result[:organic_results]),
local_pack: extract_local_pack(result[:local_results]),
featured_snippet: result[:featured_snippet],
search_volume: get_search_volume(keyword),
difficulty: estimate_keyword_difficulty(keyword)
}
end
def generate_local_seo_report
rankings = track_local_rankings
report = {
summary: generate_summary(rankings),
by_location: analyze_by_location(rankings),
by_keyword: analyze_by_keyword(rankings),
opportunities: identify_opportunities(rankings),
recommendations: generate_recommendations(rankings)
}
report
end
def identify_opportunities(rankings)
opportunities = []
rankings.each do |location, keywords|
keywords.each do |keyword, data|
# Keywords where you're on page 2 (positions 11-20)
if data[:position] && data[:position].between?(11, 20)
opportunities {
type: 'page2_opportunity',
location: location,
keyword: keyword,
current_position: data[:position],
action: 'Optimize content and build local links'
}
end
# Keywords with high search volume but low ranking
if data[:search_volume] > 1000 && (!data[:position] || data[:position] > 30)
opportunities {
type: 'high_volume_low_rank',
location: location,
keyword: keyword,
search_volume: data[:search_volume],
current_position: data[:position],
action: 'Create dedicated landing page'
}
end
end
end
opportunities
end
def generate_recommendations(rankings)
recommendations = []
# Analyze local pack performance
rankings.each do |location, keywords|
local_pack_presence = keywords.values.count { |k| k[:local_pack] }
if local_pack_presence < keywords.size * 0.5 # Less than 50%
recommendations {
location: location,
type: 'improve_local_pack',
action: 'Optimize Google Business Profile and acquire more local reviews',
priority: 'high'
}
end
end
recommendations
end
end
# Dashboard to monitor local SEO performance
get '/local-seo-dashboard' do
tracker = LocalRankTracker.new(['New York, NY', 'Los Angeles, CA'],
['web development', 'seo services'])
@rankings = tracker.track_local_rankings
@report = tracker.generate_local_seo_report
erb :local_seo_dashboard
end
Start your local SEO journey by analyzing your Cloudflare geographic data. Identify your top 3 locations and create dedicated location pages. Set up Google Business Profiles for each location. Then implement geo-personalization using Cloudflare Workers. Track local rankings monthly and optimize based on performance. Local SEO compounds over time, so consistent effort will yield significant results in local search visibility.