pSEO Case Study — How We Built 500 Pages for a Local Contractor in 7 Days
A regional roofing contractor was invisible in 28 of the 30 cities they served. Seven days later they had 500 indexed location pages. Here's exactly how we built it — tools, prompts, timeline, and results.
SEOHQ
February 11, 2026
In January 2026, a regional roofing contractor came to us with a problem that’s common and frustrating: they were doing $4M in annual revenue, had a 4.8 Google rating, had been in business for 14 years — and were invisible in organic search for every city except their primary location.
Their website had a homepage, six service pages, an about page, and a service area page that listed 30 cities in a paragraph. That’s it. When potential customers in 28 of those 30 cities searched “roofing contractor [city]” or “roof replacement [city],” this company did not appear. Their competitors — many of them smaller, newer, and less capable — did.
The diagnosis was straightforward: no city-specific pages meant no geographic ranking signals. The fix was equally clear: build a page for every city they serve, for every core service they offer. The execution is where the real work is.
Here’s exactly how we built 500 pages in seven days.
The Brief
The contractor served 30 cities across a major metro area. Their core services were:
- Roof replacement
- Roof repair
- Emergency roof repair
- Storm damage assessment
- Gutter installation and cleaning
- Commercial roofing
Six services across 30 cities = 180 core pages. We also built:
- 30 general contractor location pages (one per city, covering all services)
- 200 additional pages targeting sub-service and specific query patterns (roof leak repair, flat roof installation, tile roof replacement, TPO roofing, etc.)
- 90 emergency service pages for high-conversion urgent queries
Final target: 500 pages. Timeline: delivered in 7 days. Budget: one-time project.
Day 1: Data and Architecture
Before writing a word of content, we built the data foundation.
The location dataset was a CSV with one row per city. Columns included:
- City name
- State abbreviation
- County
- Primary zip code
- 3–4 nearest neighboring cities (validated manually against actual geography)
- Local climate note (relevant for roofing — hail frequency, storm season, heat considerations)
- Population tier (used to vary scope of service area description)
Validating the neighboring cities was a half-day task. Claude AI occasionally generates plausible-sounding but geographically incorrect neighbors — we verified every row against a map before running generation.
The URL structure was defined before any content was written:
- General location pages:
/roofing-contractor/[city]-[state]/ - Service + location pages:
/[service]/[city]-[state]/ - Emergency pages:
/emergency-roofing/[city]-[state]/
Every URL was pre-generated from the dataset and checked for conflicts, duplicates, and slug formatting issues.
The internal linking map was built as a spreadsheet: which pages link to which, what anchor text patterns to use, and which service hub pages (the parent pages at /services/[service]/) would aggregate links to all city pages.
Day 1 output: validated CSV, URL map, internal link architecture, Astro project initialized.
Day 2: Prompt Engineering
The quality of the final pages lives or dies in the prompt engineering step. We spent a full day on this before generating any production content.
We built five Claude prompts using the Anthropic API:
Prompt 1 — Location page body: Generates the main content block for the general city page. Structured as four sections: opening (customer-centric, city-specific), services overview, local context (climate, common roofing issues in that geography), and CTA. Length target: 260–300 words.
Prompt 2 — Service + location page body: Variant for service-specific pages. Tighter focus on one service type, deeper explanation of what that service involves and when it’s needed, city-specific framing. Length: 220–260 words.
Prompt 3 — Emergency page body: Short, conversion-optimized. Opens with urgency, covers what to do right now, establishes 24/7 availability, single prominent CTA. Length: 150–180 words.
Prompt 4 — FAQ block: 3 questions per page, at least 2 referencing the specific city. No generic answers — each prompt variant specifies the service type and city for context.
Prompt 5 — Meta title and description: Character-count constrained. Title: 52–60 chars. Meta: 148–158 chars. Differentiator field pulled from the business profile (in this case: “14-year local company, certified by manufacturer”).
We ran 30 test generations per prompt — one for each city — and reviewed every output. Adjusted prompts four times before they were production-ready. The most common fix: preventing Claude from listing the same neighboring cities in every opening paragraph when those cities weren’t geographically consistent.
The production prompt stack was finalized by end of Day 2.
Day 3–4: Content Generation
With validated prompts and a complete dataset, the generation step ran as a Python script calling the Anthropic API in a loop.
import anthropic
import csv
import time
import os
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
def generate(prompt_template, row):
filled = prompt_template.format(**row)
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": filled}]
)
return resp.content[0].text
with open("locations.csv") as f:
rows = list(csv.DictReader(f))
for row in rows:
slug = f"{row['city'].lower().replace(' ', '-')}-{row['state'].lower()}"
out_dir = f"output/{slug}"
os.makedirs(out_dir, exist_ok=True)
for prompt_name, template in PROMPTS.items():
out_path = f"{out_dir}/{prompt_name}.txt"
if os.path.exists(out_path):
continue
content = generate(template, row)
with open(out_path, "w") as out:
out.write(content)
time.sleep(0.4) # rate limiting
500 pages × 5 prompts = 2,500 API calls. At 0.4 seconds between calls, the full generation run took approximately 17 minutes. Total API cost for the entire generation run: under $4.
Day 3 completed the generation. Day 4 was spent on QC — reviewing outputs for the issues we watch for in every build:
- Hallucinated neighboring cities (found 6, corrected manually)
- Repeated sentence structures across adjacent city pages (adjusted prompt, regenerated 12 pages)
- Missing or malformed data in 8 rows caused blank fields in output (fixed CSV, regenerated those rows)
- Two emergency pages that read too passive for the urgency context (prompt adjustment, regenerated the full emergency set for consistency)
End of Day 4: 500 reviewed, QC-passed content blocks ready for templating.
Day 5: Template Build and Assembly
The Astro project had a single dynamic page template at src/pages/roofing/[city].astro. It imported the content from our generation outputs — structured as a JSON file per city containing all five content blocks — and assembled the final HTML with:
- H1 from the URL slug + business name
- Body content from Prompt 1 or 2 depending on page type
- FAQ section from Prompt 3
- LocalBusiness JSON-LD schema (generated as part of the template, not via Claude — schema required precise NAP data we controlled)
- Internal links to neighboring city pages and the service hub page
- CTA section with phone number and booking link
The schema generation was handled in the template itself using structured data from the CSV — city, address, phone, service type — rather than via AI. Schema needs to be accurate, not creative.
Day 5: Astro build completed, 500 pages generating correctly from template.
Day 6: Technical Review and Sitemap
Before submitting anything to Google, we ran technical checks:
- Built and ran the Astro site locally, clicked through 30 random pages
- Validated schema on 10 pages using Google’s Rich Results Test — all passed
- Ran Lighthouse on 5 pages — all scored 90+ performance (static HTML advantage)
- Checked all internal links resolved correctly
- Generated XML sitemap covering all 500 URLs
- Deployed to Vercel for production
Day 6: Site live, sitemap ready.
Day 7: Submission and Handover
Submitted the sitemap to Google Search Console. Requested indexing for the 30 primary city pages manually (the highest-priority targets). Documented the prompt stack, CSV structure, and Astro template for the client’s records.
Day 7: Project complete.
Results at 90 Days
At the 90-day mark:
- 487 of 500 pages indexed (13 pending, likely due to crawl budget allocation on lower-priority sub-service pages)
- 312 keywords ranking in positions 1–50 in Google Search Console
- 64 keywords in positions 1–10, including primary city + service combos for 18 of their 30 target cities
- Organic clicks up 340% from the baseline month (pre-build)
- First attributed organic leads: 7 inbound calls in Month 2, 19 in Month 3
At 180 days: 61 keywords in positions 1–3. Monthly organic leads exceeding the client’s previous Google Ads spend. The pSEO build paid for itself within four months.
What Made It Work
Speed came from preparation: the prompt engineering day, the data validation, the URL architecture planning. Skipping any of those steps would have cost more time in fixes than they saved in setup.
Quality came from the QC day — not from assuming the AI output was automatically good. Every build needs a human review pass before deployment.
The results came from the combination: comprehensive geographic coverage, technically sound pages, correct schema, fast load times, and a client domain with 14 years of authority behind it.
That last point matters. This build worked as well as it did partly because the domain had earned trust over time. A brand new domain with identical pages would have taken longer to see results — the pages would have been the same quality, but the domain authority amplification wouldn’t have been there.
Build on an established domain when you can. Build on a new domain when you have to — and plan for a longer runway.
Ready to scale?
Get the SEOHQ Toolkit
Templates, prompts, and tools to build local SEO at scale.
Browse Tools on Gumroad