There's a category of websites that doesn't get enough attention in the developer community: programmatic SEO sites. These are sites where dozens or hundreds of pages are generated from structured data and templates, each targeting specific long-tail search queries. It's a technique used by giants like Zapier (their /apps directory), Nomad List, and Wise's currency pages.
I built StadiumGigs — a job board for stadium and arena employment across the United States — using exactly this approach. No React. No database. No CMS. Just Jekyll, Markdown, Liquid templates, and GitHub Pages.
In this article, I'll break down the architecture, the SEO patterns, the vibe-coded interactive map, and the lessons learned from building a real programmatic site that's live in production.
The Architecture: Jekyll Collections as a Poor Man's Database
The core idea behind programmatic SEO is simple: define a data schema once, create a template once, then generate N pages from N data entries. The site currently serves five distinct content types, each defined as a Jekyll collection:
# _config.yml
collections:
blog:
output: true
sort_by: weight
jobs:
output: true
sort_by: weight
stadien:
output: true
companies:
output: true
cities:
output: true
Each collection maps to a folder of Markdown files with structured front matter. Here's what a stadium entry looks like:
---
title: "SoFi Stadium in Inglewood"
description: "SoFi Stadium is a modern indoor-outdoor venue in Inglewood, California, hosting NFL games and major events year-round."
layout: stadium
permalink: /stadiums/sofi-stadium/
image: "/images/stadiums/sofi-stadium.jpg"
lat: 33.9535
lng: -118.3392
city: Inglewood
state: CA
---
This single file generates a full page with structured content, a Leaflet map pinpointing the stadium's location, and JSON-LD structured data — all from one layout template shared across every stadium in the collection.
The key insight is that Jekyll collections behave like a flat-file database. You can query them with Liquid filters:
{% assign city_jobs = site.jobs | where: "city", page.collection_key %}
{% for job in city_jobs %}
<!-- render job card -->
{% endfor %}
This creates relational-style joins without a database. City pages automatically display only jobs in that city. The home page filters featured jobs by location. Stadium pages link to their hospitality operators. It's all driven by front matter fields acting as foreign keys.
Content Generation with SEO Constraints
Programmatic SEO only works if each generated page is genuinely useful and optimized for search. Here are the constraints I enforce at the content level:
Title Tags: The 155–160 Character Sweet Spot
Google truncates title tags at roughly 580 pixels, which translates to about 50–60 characters for the <title> element. But for meta descriptions, the sweet spot is 150–160 characters. Every page on the site has a hand-crafted description in its front matter:
description: "Find stadium jobs in Inglewood, California at SoFi Stadium, home to the Los Angeles Rams and Los Angeles Chargers."
The template conditionally renders this as both a meta description and Open Graph description:
{% if page.description %}
<meta name="description" content="{{ page.description }}" />
{% endif %}
<meta property="og:description" content="{{ page.description }}"/>
For page titles, the format follows the pattern [Venue/Role] in [City] — a structure that naturally targets location-based search queries like "stadium jobs in Inglewood" or "SoFi Stadium jobs."
Structured Data: Speaking Google's Language
Every job listing outputs JobPosting JSON-LD, which is the schema Google uses for its job search feature:
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "JobPosting",
"datePosted": "{{ page.date | date: '%Y-%m-%d' }}",
"validThrough": "{{ page.validThrough | date: '%Y-%m-%d' }}",
"title": "{{ page.title }}",
"description": "{{ page.description }}",
"employmentType": "{{ page.employmentType }}",
"hiringOrganization": {
"@type": "Organization",
"name": "{{ page.name }}"
},
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"streetAddress": "{{ page.streetAddress }}",
"addressLocality": "{{ page.addressLocality }}",
"addressRegion": "{{ page.addressRegion }}",
"postalCode": "{{ page.postalCode }}",
"addressCountry": "{{ page.addressCountry }}"
}
}
}
</script>
Company pages get Organization schema. The home page uses FAQPage microdata for the FAQ accordion. This layered structured data strategy means different page types "speak" to different Google features — job search, knowledge panels, and rich FAQ snippets.
The Permalink Strategy
Every page has an explicit, human-readable permalink:
permalink: /stadiums/sofi-stadium/
permalink: /inglewood/guest-services/
permalink: /blog/raymond-james-stadium-partners-with-ubeya-for-workforce-management/
Combined with the jekyll-redirect-from plugin, old URLs gracefully redirect to current ones — critical for preserving link equity when you refactor your URL structure:
redirect_from:
- /blog/2026-04-11-raymond-james-stadium-partners-with-ubeya/
Vibe-Coding the Interactive Stadium Map
The interactive stadium map on StadiumGigs is probably the most visually interesting feature — and it was largely vibe-coded. By that I mean I described what I wanted to an AI coding assistant and iteratively refined the result.
The implementation uses Leaflet.js with OpenStreetMap tiles. The clever part is how Jekyll's build step generates the JavaScript data layer:
var stadiums = [
{% for stadium in site.stadien %}
{% if stadium.lat and stadium.lng %}
{
name: "{{ stadium.title }}",
lat: {{ stadium.lat }},
lng: {{ stadium.lng }},
city: "{{ stadium.city }}",
state: "{{ stadium.state }}",
url: "{{ stadium.url | relative_url }}",
image: "{{ stadium.image | relative_url }}"
}{% unless forloop.last %},{% endunless %}
{% endif %}
{% endfor %}
];
This is server-rendered JavaScript — Liquid templates generate a JavaScript array at build time that Leaflet consumes at runtime. No API calls. No loading spinners. The data is baked directly into the HTML.
Each marker uses a custom SVG stadium icon, and clicking one opens a popup with the venue name, location, and a link to its dedicated page:
stadiums.forEach(function(stadium) {
var popupContent = '<div class="stadium-popup">' +
'<h3>' + stadium.name + '</h3>' +
'<p>' + stadium.city + ', ' + stadium.state + '</p>' +
'<a href="' + stadium.url + '">View stadium →</a>' +
'</div>';
L.marker([stadium.lat, stadium.lng], {icon: stadiumIcon})
.addTo(map)
.bindPopup(popupContent);
});
Individual stadium pages also get their own zoomed-in map, conditionally rendered only when coordinates exist in the front matter:
{% if page.lat and page.lng %}
<div id="stadium-location-map"></div>
<script>
var map = L.map('stadium-location-map')
.setView([{{ page.lat }}, {{ page.lng }}], 15);
</script>
{% endif %}
The entire map feature — the overview map, per-stadium maps, custom icons, popups, responsive styling — took about two hours of back-and-forth with an AI assistant. That's what vibe-coding looks like in practice: you describe intent, review output, adjust, repeat.
The Cross-Linking Graph: How Templates Create Internal Links
One of the most underrated SEO techniques is strategic internal linking. In a programmatic site, this comes almost for free through templates.
The home page automatically features jobs from three cities and links to their hub pages:
{% assign featured_jobs = site.jobs | where: "city", "inglewood" %}
{% for job in featured_jobs limit: 3 %}
<a href="{{ job.url | relative_url }}">{{ job.title }}</a>
{% endfor %}
<a href="/inglewood/">See all Inglewood jobs</a>
City pages link to individual jobs. Stadium pages link to hospitality companies. Company pages link back to stadiums they operate at. The result is a tightly connected graph where every page is reachable within 2–3 clicks from the home page — exactly what search engines reward.
This cross-linking isn't manually maintained. It's a natural consequence of the data model. Add a new stadium with city: Inglewood in its front matter, and it automatically appears on the Inglewood city page, the stadium map, and the home page's featured section.
Deployment: Zero-Cost, Zero-Ops
The site deploys via GitHub Actions to GitHub Pages:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- run: bundle exec jekyll build
env:
JEKYLL_ENV: production
- uses: actions/upload-pages-artifact@v3
deploy:
needs: build
uses: actions/deploy-pages@v4
Push to main, and the site is live within minutes. Total hosting cost: $0. The jekyll-sitemap plugin auto-generates a sitemap.xml on every build, so search engines always have the latest page inventory.
Why Not a Framework? The Case for Boring Technology
You might wonder: why Jekyll in 2026? Why not Next.js, Astro, or Hugo?
The answer is operational simplicity. Jekyll collections give you 90% of what a headless CMS + framework combo provides, with 10% of the complexity:
| Feature | Jekyll Approach | Framework Approach |
|---|---|---|
| Data layer | Markdown files with YAML front matter | Database + API + ORM |
| Templates | Liquid | JSX/React components |
| Routing | File-based + explicit permalinks | File-based or config |
| Structured data | Inline JSON-LD in layouts | Same, but more abstraction |
| Deployment | GitHub Pages (free) | Vercel/Netlify/AWS |
| Build time | Seconds for ~40 pages | Seconds (but more moving parts) |
| Dependencies | Ruby + 3 plugins | Node + dozens of packages |
For a niche job board like StadiumGigs, where the goal is connecting people with part-time stadium employment opportunities, a static site that builds in seconds and costs nothing to host is the right call. Every dependency you don't have is a dependency that can't break.
Scaling the Approach: What Comes Next
The programmatic pattern scales naturally. Adding a new city means creating one Markdown file with the right collection_key, and the entire template system picks it up. Want to add 50 stadiums? Write 50 Markdown files (or generate them from a CSV with a script), each with the same front matter schema, and the map, listing pages, and cross-links all update automatically.
Some technical improvements on the roadmap:
-
Build-time image optimization with
jekyll-picture-tagfor responsive images -
Canonical URLs in the
<head>to prevent duplicate content issues - Twitter Card meta tags alongside existing Open Graph tags
- Automated content generation from structured data sources — think scraping venue APIs for event schedules and auto-generating blog posts
Key Takeaways for Developers
Programmatic SEO is a legitimate engineering challenge. It's not just about churning out pages — it's about data modeling, template design, structured data, and internal linking strategy.
Jekyll collections are surprisingly powerful. The
where,where_exp, andsort_byfilters turn flat Markdown files into a queryable data layer.Vibe-coding interactive features works. The Leaflet map was built through iterative AI-assisted development. The result is clean, performant, and required no deep JavaScript expertise.
Static sites can be sophisticated. JSON-LD structured data, conditional rendering, build-time JavaScript generation, and automated deployments — all without a single line of server-side code.
Start boring, optimize later. Jekyll + GitHub Pages gets you from zero to production with zero infrastructure cost. You can always migrate to something fancier when you outgrow it.
If you're interested in programmatic SEO or building niche content sites, check out StadiumGigs as a living example, or browse the interactive stadium map to see the Leaflet integration in action. The entire approach described here is general enough to apply to any domain — real estate listings, restaurant directories, event guides — anywhere you have structured data that maps to location-specific search intent.
Top comments (0)