How I Built Over 100 Programmatic SEO Templates with Python, Hugo, and Local AI

Automate 100+ SEO templates with Python, Hugo & Ollama. We reveal the architecture behind a scalable local search engine, including "Airbnb-style" cards and a fix for Go's printf vs CSS conflict. A practical guide to modern Programmatic SEO.

Finding a local tradesman is hard. Building a high-performance search engine to solve that shouldn't be.

In this article, I’ll show you how I built a Programmatic SEO (pSEO) project for the city of Bremen. The goal: To generate over 100 high-performance landing page templates—covering every district and every trade (locksmiths, plumbers, electricians)—fully automated, blazing fast, and offering real value to the user.

We will dive deep into the tech stack: From data acquisition with Python to content generation using local LLMs (Ollama) and final rendering with Hugo.


🏗️ The Architecture

The problem with classic CMS platforms like WordPress is often performance when dealing with hundreds or thousands of pages. That’s why I chose a Static Site Generator (SSG). The site is "built" once and then served as pure HTML.

The Stack:

  • Data Source: CSV (cleaned and enriched)
  • Logic & Generator: Python (Pandas)
  • Content AI: Ollama (running Llama 3 locally)
  • Frontend/Rendering: Hugo (Go-based)
  • Hosting: Static (e.g., Hetzner/Netlify/Vercel)

Step 1: The Data Foundation (Python & Pandas)

Everything stands or falls with the data. I started with a raw CSV file (handwerker_bremen.csv) containing columns like name, address, rating, zip_code, etc.

The challenge: Data is often "dirty." Zip codes are missing, special characters are broken, and categories are inconsistent.

import pandas as pd
import numpy as np

# Load data
df = pd.read_csv('handwerker_bremen.csv')

# Mapping for clean district names based on Zip Code
PLZ_TO_DISTRICT = {
    "28195": "Bremen-Mitte",
    "28203": "Steintor / Ostertor",
    # ... further mappings
}

def get_district_name(plz):
    return PLZ_TO_DISTRICT.get(str(plz), f"Bremen (Zip {plz})")

# Clean slugs for URLs (e.g., "Locksmith in Bremen Mitte")
def clean_slug(text):
    text = text.lower()
    replacements = {'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', ' ': '-'}
    for old, new in replacements.items():
        text = text.replace(old, new)
    return text

We then group the data by Category and Zip Code. Each of these groups serves as the basis for one of our 100+ unique templates.Python

grouped = df.groupby(['search_category', 'search_plz'])
print(f"Templates to generate: {len(grouped)}")

Step 2: Content Generation with Local AI (Ollama)

This is where it gets interesting. Instead of generic placeholder text ("Here you find a plumber"), I wanted unique introductions for every single one of the 100+ templates. Since API costs can skyrocket with this many pages, I used Ollama running Llama 3 locally on my machine.

The biggest challenge? Hallucinations and cut-offs.

Ollama tends to stop sentences in the middle if the num_predict (token limit) is too low, or it starts rambling.

Our Solution: Prompt Engineering & Fallback Functions

import requests

def query_ollama(prompt):
    payload = {
        "model": "llama3",
        "prompt": prompt,
        "stream": False,
        "options": {
            "temperature": 0.6, # Less creative, more focused
            "num_predict": 200, # Enough room to finish the sentence
        }
    }
    response = requests.post("http://localhost:11434/api/generate", json=payload)
    return response.json().get('response', '').strip()

# IMPORTANT: Cleanly cut off sentences if the AI stops mid-stream
def clean_incomplete_sentence(text):
    if text.strip().endswith(('.', '!', '?')):
        return text
    
    # Cut everything after the last punctuation mark
    last_punct = max(text.rfind('.'), text.rfind('!'), text.rfind('?'))
    if last_punct != -1:
        return text[:last_punct+1]
    return text

The prompt itself assigns the AI a clear role ("Copywriter") and sets hard constraints ("Max 50-70 words", "No headlines").


Step 3: The Hugo Generator

Python doesn't generate HTML directly; instead, it creates JSON files that serve as "Data Files" in Hugo. This keeps logic and design cleanly separated.

The Python script creates a massive bremen_services.json containing the structure for over 100 templates:JSON

[
  {
    "page_slug": "locksmith-bremen-28195",
    "page_title": "Top Locksmith in Bremen-Mitte (28195)",
    "intro_text_ai": "Door slammed shut? Don't panic...",
    "service_list_json": "[...]" 
  },
  ...
]

In Hugo, we use a specific layout (layouts/services/list.html) that iterates over this JSON file and uses resources.FromString to generate virtual HTML pages from these templates.Go

{{ range $index, $page_data := site.Data.bremen_services }}
    {{ $slug := $page_data.page_slug }}
    {{ $filename := printf "services/%s.html" $slug }}
    
    {{/* HTML Content is assembled here */}}
    {{ $html_content := printf "..." }}
    
    {{ $file := resources.FromString $filename $html_content }}
    {{ $file.Publish }}
{{ end }}

Why so complex? Because Hugo is incredibly fast. We can build these 100+ templates and pages in a matter of seconds.


Step 4: UX & Frontend (The "Airbnb Design")

Data is useless if it's presented poorly. Our goal was trust. No one calls a tradesman from a site that looks like it was built in 1998.

We took inspiration from the Airbnb Card Design:

  • Plenty of whitespace
  • Subtle shadows (box-shadow) that elevate on hover
  • Clear "Call to Action" buttons
  • "Social Proof" via highlighted ratings

CSS Snippet for the Cards:

.service-card { 
    background: #fff; 
    border-radius: 12px; 
    border: 1px solid #ebebeb; 
    transition: all 0.3s cubic-bezier(0.2, 0, 0, 1); 
    display: flex; 
    flex-direction: column; 
}

.service-card:hover { 
    transform: translateY(-4px); 
    box-shadow: 0 12px 20px rgba(0, 0, 0, 0.08); 
    border-color: transparent; 
}

.btn-primary {
    background: #FF385C; /* The typical Airbnb Red */
    color: white;
    font-weight: 700;
}

Additionally, we implemented an FAQ Accordion which not only helps users ("How much does this cost?") but also delivers structured data via JSON-LD Schema to Google. This massively increases the chance of getting Rich Snippets in search results.


When Syntax Collides: The printf vs. CSS Battle

One of the most technically demanding aspects of this project wasn't the AI generation or the data scraping—it was a subtle syntax collision between Go templates and CSS that nearly derailed the design implementation.

In Hugo, when we generate pages dynamically using resources.FromString, we build the entire HTML structure as a giant string inside a printf statement. This allows us to inject variables like $title or $district directly into the code. However, printf in Go uses the percentage symbol (%) as a formatting verb (e.g., %s for strings, %d for integers).

Here lies the problem: CSS also relies heavily on the percentage symbol. Whether it's width: 100%, flex-basis: 50%, or keyframe animations, the % is omnipresent in modern web design.

When Hugo's compiler encountered a line like .container { width: 100% } inside our string, it panicked. It tried to interpret the % followed by a closing brace } as a variable placeholder, which doesn't exist. This resulted in cryptic build errors or, even worse, the styles breaking silently because the CSS string was malformed during the render process.

The Solution:

To make this work, we had to implement a strict escaping protocol within our templates. Every single instance of a CSS percentage sign had to be doubled to %.

/* WRONG - Causes Hugo Build Failure */
{{ $html := printf "<style> .box { width: 100%; } </style>" }}

/* RIGHT - Escaped for Go Printf */
{{ $html := printf "<style> .box { width: 100%%; } </style>" }}

This seems like a minor detail, but when you are managing hundreds of lines of inline CSS to ensure critical rendering path performance for over 100 templates, "hunting the percent sign" becomes a major part of the debugging process. It taught us a valuable lesson: when mixing languages (Go and CSS) within a single string context, always be aware of reserved characters.

Conclusion

With just about 200 lines of Python code and a well-structured Hugo architecture, we created a scalable, low-maintenance portal based on over 100 templates. If the data changes (new tradesmen, new prices), we simply run the Python script again, Hugo rebuilds, and the update is live.

Programmatic SEO is powerful—if you prioritize quality over quantity. By using local AI generation via Ollama and a high-quality frontend design, this project stands out distinctly from typical "spam directories."


Tech Stack: Python 3.11, Pandas, Ollama (Llama 3), Hugo Extended, HTML5/CSS3.