Generate millions of SEO-quality static pages from a Django queryset, at thousands per second.
A Django reusable app for programmatic SEO at scale: take an entity table (zip codes, products, locations, people — anything you have a row for), pair each row with a content body, render to static HTML, and serve from your CDN. Schema.org JSON-LD, Open Graph, breadcrumbs, and a sane default template included.
Built for operators running content sites where the long tail is the product. Reference deploy: 11,000+ entity pages live, 2,236 pages/second build rate.
Status:
0.1.0.dev0— alpha. API surface is settling. Production-tested via the reference deploy, but the public package itself is brand new — expect minor breaking changes before 1.0. Pin tightly.
If you've ever:
- Generated a page-per-zip-code / page-per-product / page-per-location and watched your prerender service implode at 50K URLs
- Tried to ship "1 page per row in this Postgres table" and ended up writing an ad-hoc render script that drifted from your real templates
- Wanted clean Schema.org JSON-LD on every page without hand-rolling a serializer
- Looked for a Django reusable app that does this and found basically nothing
…then this is for you.
It does one thing well: render a queryset of entity rows to static HTML at maximum throughput, with the schema markup you'd want to write anyway.
- ⚡ Renders entity records to static HTML pages from a Django template, in parallel.
- 🧱 Pluggable adapter pattern — works against the bundled
Pagemodel out of the box, or plug in your own model with one class. - 🏷️ Schema.org JSON-LD per page with a per-page
schema_typeand free-formschema_extrasfor addingPerson,Place,Product, etc. fields. - 🍞 N-level breadcrumb navigation rendered into both HTML and
BreadcrumbListJSON-LD. - 🔄 Build manifest (
manifest.json) emitted alongside output for downstream tooling. - 📊 Run audit log (
PageBuildmodel) — every build run tracked with status, count, errors, runtime. - 🎛️ Filtering by
entity_type,status, plus--limitand--dry-run. - 🔌 Bring your own template with
--template myapp/custom.html.
- ❌ Generate page bodies for you. You bring the content (LLM, scraper, fixtures, your CMS — whatever). This tool ships it.
- ❌ Deploy. The output is a directory of
index.htmlfiles. Pair it with nginx, Cloudflare Pages, S3+CloudFront, or your CI/CD of choice. - ❌ Track SEO performance. Use Search Console, GA4, or similar. Build manifests integrate cleanly with whatever you already have.
pip install programmatic_pages# settings.py
INSTALLED_APPS = [
# ...
"programmatic_pages",
]
PROGRAMMATIC_PAGES = {
"myproject": {
"base_url": "https://example.com",
"site_name": "Example",
"ga_measurement_id": "G-XXXXXXXXXX", # optional
},
}manage.py migrate programmatic_pagesfrom programmatic_pages.models import Page
Page.objects.create(
project_key="myproject",
entity_type="zip-codes",
url_path="zip/10001/",
title="ZIP 10001 — New York, NY",
meta_description="Demographics, businesses, and city info for 10001.",
h1="ZIP Code 10001",
body_html="<p>Generated content goes here.</p>",
schema_type="Place",
status="published",
)manage.py build_static_pages --project myproject --output-dir ./buildProject: Example (myproject)
Base URL: https://example.com
Output: ./build
==================================================
Built 1 pages (0 errors) in 0.0s
Output: ./build
Manifest: ./build/manifest.json
Rate: 200 pages/s
Build ID: 1
Point nginx (or Cloudflare Pages, or S3+CloudFront) at ./build. You're done.
The bundled Page model is a starting point. Most real users have their own page/entity model — maybe Product, Listing, Article. Don't migrate to ours. Write a 50-line adapter against yours.
# myapp/adapters.py
from programmatic_pages.adapter import (
BreadcrumbItem,
PageAdapter,
ProjectConfig,
RenderablePage,
)
from myapp.models import Product, Site
class ProductPageAdapter(PageAdapter):
def get_project_config(self, project_key: str) -> ProjectConfig:
site = Site.objects.get(slug=project_key)
return ProjectConfig(
base_url=site.base_url,
site_name=site.name,
ga_measurement_id=site.ga_id,
)
def iter_pages(self, project_key, *, entity_type=None, status="published"):
qs = Product.objects.filter(site__slug=project_key, status=status)
if entity_type:
qs = qs.filter(category__slug=entity_type)
for product in qs.iterator():
yield RenderablePage(
url_path=f"products/{product.slug}/",
title=f"{product.name} — {product.brand}",
meta_description=product.short_description,
h1=product.name,
body_html=product.rendered_body,
schema_type="Product",
schema_extras={
"brand": {"@type": "Brand", "name": product.brand},
"offers": {
"@type": "Offer",
"price": str(product.price),
"priceCurrency": "USD",
},
},
breadcrumb_chain=[
BreadcrumbItem(label="Home", url="/"),
BreadcrumbItem(label="Products", url="/products/"),
BreadcrumbItem(label=product.category.name, url=f"/products/{product.category.slug}/"),
BreadcrumbItem(label=product.name), # current page, no url
],
)Then run:
manage.py build_static_pages \
--project myproject \
--adapter myapp.adapters.ProductPageAdapter \
--output-dir ./buildThat's the whole integration story. The OSS package never knows about your model — it only sees RenderablePage dataclasses.
Override the bundled template with --template:
manage.py build_static_pages --project myproject --template myapp/custom.htmlYour template receives:
| Variable | Type | Notes |
|---|---|---|
title |
str | Page title |
meta_description |
str | |
canonical_url |
str | Absolute URL |
h1 |
str | |
body_html |
str | Pre-rendered HTML; use |safe |
schema_json |
str | JSON-LD payload; use |safe |
breadcrumb_chain |
list[BreadcrumbItem] | Each has .label and .url |
base_url |
str | Project base URL |
site_name |
str | |
ga_id |
str | GA4 measurement ID, may be empty |
year |
int | Current year |
* |
* | Anything in RenderablePage.extra_context |
See src/programmatic_pages/templates/programmatic_pages/default.html for the reference template.
manage.py build_static_pages --project <key> [options]| Flag | Default | Description |
|---|---|---|
--project |
required | Project key (key into PROGRAMMATIC_PAGES or your adapter's lookup) |
--adapter |
programmatic_pages.adapters.DefaultPageAdapter |
Dotted path to a PageAdapter |
--template |
programmatic_pages/default.html |
Template name or path |
--output-dir |
~/programmatic_pages/build/<project> |
Where to write index.html files |
--entity-type |
(all) | Filter by entity_type |
--status |
published |
Filter by status |
--limit |
0 (all) |
Cap pages built (useful for testing) |
--concurrency |
4 |
Parallel write threads |
--dry-run |
false |
Count + show breakdown, write nothing |
--triggered-by |
manual |
Logged on the PageBuild record (manual, ci, etc.) |
The output of a build is a tree of index.html files:
build/
├── manifest.json
├── zip/
│ ├── 00501/index.html
│ ├── 10001/index.html
│ └── ...
└── products/
├── widget-a/index.html
└── ...
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/example/build;
# Trailing-slash URL paths map to index.html
location / {
try_files $uri $uri/index.html =404;
}
}Treat the output directory as a static site. Most platforms map foo/index.html → /foo/ automatically.
Run build_static_pages as a step in your CI/CD pipeline, then rsync/upload the output:
- name: Build static pages
run: |
manage.py build_static_pages --project ${{ env.PROJECT_KEY }} \
--output-dir ./build --triggered-by ci
- name: Deploy
run: rsync -avz --delete ./build/ deploy@host:/var/www/myproject/0.1.0 (next):
-
us_zip_codesreference demo (~42K pages, demonstrates real throughput) -
docs/site with the adapter pattern guide and deployment cookbook
0.2.0 (planned):
- Sitemap.xml generator (one-shot or chunked)
- Incremental builds (skip pages whose
updated_at< last build) - Cloudflare cache-purge hook on deploy
Later:
- Framework-agnostic core (Flask / FastAPI bindings)
- Optional Markdown-source-of-truth mode
Issues and PRs welcome. Run the test suite locally with:
git clone https://github.com/andrewjpyle/programmatic_pages.git
cd programmatic_pages
uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install -e ".[dev]"
pytest -qMIT. See LICENSE.
Built and maintained by Andrew J. Pyle. Originally extracted from production infrastructure running a portfolio of programmatic SEO sites.