A self-hosted programmatic-SEO + daily-AI-blog toolkit that runs entirely on Cloudflare.
Plug in a URL (or a keyword list), point a cron at it, and pages-seo quietly publishes programmatic landing pages and a daily blog post — each with a hero image — and pings IndexNow so Bing/Yandex/Seznam crawl them within minutes.
Free tier covers most cases. No servers, no Docker, no Vercel bill at scale.
- Brand DNA generator — paste a URL, get a structured brand profile baked into every prompt.
- Content calendar — auto-plans 4–8 weeks of upcoming articles from the brand DNA; add, remove, swap, or reorder them in a Monday-first grid.
- Daily AI blog — multi-step chain (start → text → image → publish) that survives Pages Functions' aggressive isolate kills.
- Programmatic landing pages — one URL per keyword, AI-written, served from D1 with edge caching.
- Hero images — Workers AI (Flux) by default; OpenAI / Gemini Imagen as fallback.
- Keyword puller — free Google-Autocomplete-based seed expansion, queues straight into D1.
- Sitemap + IndexNow — automatic XML sitemap, on-publish IndexNow pings, robots.txt.
- Embeddable widget — drop a
<script>on any site to render your latest posts. - Admin dashboard — single-page SPA with email/password login, runs jobs and inspects the queue.
- Cover image editor — canvas-based crop, captions, badges, gradient overlay.
- Multi-AI registry — Workers AI → OpenAI → Anthropic → Gemini → Groq → DeepSeek → Mistral → Together → Cerebras. Each is optional.
Sign in with GitHub, paste one Cloudflare API token (the page pre-fills every required permission for you — one click to generate), and we provision D1, R2, the Pages project, the schema, and your admin account in about a minute. Nothing to install locally.
If you'd rather drive wrangler yourself, pick whichever runtime you already have:
# Bash / Zsh
curl -fsSL https://seo.benjaminb.xyz/install/run.sh | bash
# Python
curl -fsSL https://seo.benjaminb.xyz/install/run.py | python3
# Node
curl -fsSL https://seo.benjaminb.xyz/install/run.js | nodeThe CLI installer uses wrangler login (OAuth) instead of a token — no scope juggling required.
Note
We previously offered a "Deploy to Cloudflare" 1-click button. Cloudflare's auto-generated CI token for new Pages projects doesn't include Pages:Edit, which breaks the first deploy. Until Cloudflare ships a fix, the browser installer above is the no-terminal path that actually works end-to-end.
Either way, the installer will:
- Check that
wrangleris installed (offers to install it for you). - Run
wrangler loginif needed — opens your browser, no API token to copy. - Prompt for your project slug, site name, admin email, and password.
- Provision a D1 database and an R2 bucket on your Cloudflare account.
- Download the latest source, patch
wrangler.toml, runwrangler pages deploy(which uploads both the static assets and the Functions bundle — no GitHub linkage needed). - Set
SITE_NAMEandSITE_URLas Pages environment variables. - Open your new site's
/adminwith the credentials baked into the URL hash, so the first-run setup card auto-creates your account.
Total wall-clock time: about 2 minutes. The installer is idempotent — re-run with the same slug if anything fails and it'll pick up from where it stopped. See cli/README.md for details.
Tip
The legacy bash setup.sh flow also works from a clone (provisions via Wrangler the same way) — see the section below.
Browser installer at seo.benjaminb.xyz/install
The browser flow uses a Cloudflare API token instead of wrangler login. It's another option if you can't run Node locally, but it requires you to authorise the Cloudflare Workers & Pages GitHub App on your account once (because the browser flow can't deploy Functions via Direct Upload — Cloudflare's public REST API doesn't expose that yet). The CLI above avoids that step entirely.
From a clone with bash setup.sh
git clone https://github.com/Benjamin-Bloch/pages-seo
cd pages-seo
npm install -g wrangler && wrangler login
npm run setup # or: bash setup.sh / python3 setup.py / node setup.jsResumable — if a step fails, fix the issue and re-run. Delete .setup-state to start over.
After install, the onboarding wizard walks you through Brand DNA → AI providers → 28-day content plan. Daily automation is optional — the cron Worker in cron-worker/ needs wrangler deploy once for that, or just hit Run now from the admin dashboard whenever you want a fresh post.
After you save brand DNA, the admin Content Calendar auto-plans the next four weeks of articles — one slot per day, each pre-titled and tagged to a target keyword. Slots are colour-coded:
- 🟢 Published — already live.
- 🟣 Generating — the cron is mid-chain on this slot.
- 🟡 Draft — manually edited and held back from publish.
- 🔵 Scheduled — queued for its date.
You can drag-add, remove, swap, or rename any slot. The cron picks up "scheduled" slots in date order; manual "Run now" promotes a slot regardless of date.
| Action | Where |
|---|---|
| Save / regenerate brand DNA | Admin → Brand DNA |
| Re-plan the content calendar | Admin → Content Calendar → "Regenerate" |
| Run today's blog post manually | Admin → Daily blog → "Run now" |
| Pull keywords from a seed | Admin → Programmatic → "Pull keywords" |
| Queue keywords from CSV | Admin → Programmatic → "Upload CSV" |
| Force the next programmatic page | Admin → Programmatic → "Run next" |
| Ping IndexNow for one URL | Admin → SEO → "Ping IndexNow" |
| Get the embed snippet | Admin → Embeds → pick or create |
| Preview a sample post for your brand | Admin → Daily blog → "Preview sample" (dry-run; no D1 / R2 writes) |
The cron Worker drives the blog chain at 08:00 UTC and generates a programmatic page at 09:00 UTC. Edit cron-worker/wrangler.jsonc to change the schedule.
Two routes, same contract:
<!-- Generic (zero config) -->
<div id="ps-blog"></div>
<script src="https://<your-domain>/widget.js" defer></script>
<!-- Named embed (title, accent, post limit configurable in admin) -->
<div id="ps-blog"></div>
<script src="https://<your-domain>/api/embed/<id>" defer></script>The widget paints cards instantly (article list baked into the response), loads body HTML on demand, supports deep-linking (?post=<slug>), and degrades gracefully inside srcdoc iframes (Wix, Webflow, GoDaddy previews).
Workers AI is bound automatically and is the default. Every other provider is optional — set its API key as a Pages secret and it joins the fallback chain.
| Provider | Secret | Text | Image |
|---|---|---|---|
| Cloudflare Workers AI | (binding) | ✅ Llama 3.3 70B | ✅ Flux 1 schnell |
| OpenAI | OPENAI_API_KEY |
✅ gpt-5 | ✅ gpt-image-1 |
| Anthropic | ANTHROPIC_API_KEY |
✅ Claude Fable 5 | — |
| Google Gemini | GEMINI_API_KEY |
✅ Gemini 2.5 Pro | ✅ Imagen 4 |
| Groq | GROQ_API_KEY |
✅ Llama 3.3 70B | — |
| DeepSeek | DEEPSEEK_API_KEY |
✅ deepseek-chat | — |
| Mistral | MISTRAL_API_KEY |
✅ mistral-large | — |
| Together AI | TOGETHER_API_KEY |
✅ Llama 3.3 70B | — |
| Cerebras | CEREBRAS_API_KEY |
✅ Llama 3.3 70B | — |
Override the per-provider model with env vars like OPENAI_TEXT_MODEL or GEMINI_IMAGE_MODEL — see .env.example.
Adding another OpenAI-compatible provider takes one entry in functions/_lib/ai.js — copy the groqText block and swap the URL / env var.
public/ static landing + admin SPA
functions/ Pages Functions (file-based routing)
├── _lib/ shared helpers (ai, auth, util, topics, links, widget_render)
├── api/admin/... admin API (session cookie or bearer token)
│ ├── blog/ multi-step blog chain (start/text/image/publish)
│ ├── prog/ programmatic pages (generate-next, pull-keywords, etc.)
│ ├── calendar/ content-calendar planner + slot CRUD
│ ├── embed/ CRUD for named blog embeds
│ └── ... IndexNow ping, providers list, queue, posts
├── api/embed/[id].js embed widget bundle (per-embed)
├── widget.js.js embed widget bundle (generic, zero-config)
├── blog/ public blog index + /blog/<slug>
├── p/[slug].js public programmatic page
├── sitemap.xml.js full sitemap
└── feed.xml.js RSS
cron-worker/ scheduled Worker that calls the admin API
schema/init.sql D1 schema
setup.{sh,py,js} identical three-flavour resumable installer
Pages Functions run in V8 isolates that get killed pretty aggressively when the request returns. Cloudflare's waitUntil extends that — but not by enough for an end-to-end "generate text → generate image → upload → publish" run when the model is slow. The chain (/start → /text → /image → /publish) persists state in blog_jobs, each step is idempotent, and the cron Worker drives the steps one at a time over short HTTP calls.
The admin SPA uses email + password with PBKDF2-SHA256 (100k iterations — Cloudflare Workers caps PBKDF2 there) and HMAC-SHA256-signed session cookies (HttpOnly, Secure, SameSite=Lax, 14-day expiry). Login is rate-limited (5 failed attempts per email+IP triggers a 1-hour lockout). The ADMIN_TOKEN is kept as a bearer-token recovery path and for the cron Worker.
npm run dev # local Pages Functions runtime
npm run db:console # quick D1 querywrangler dev proxies the live D1/R2/AI bindings into your local Function runtime so you can test without redeploying.
npm run deploy # delegates to deploy.shNo resource changes, no secret prompts — just wrangler pages deploy + wrangler deploy for the cron Worker.
How much does this cost to run?
On Cloudflare's free tier: $0 for most hobby use. The free tier covers 100k Pages Function invocations/day, 5GB R2 storage, 5M D1 reads/day, and 10k Workers AI neurons/day (≈ a dozen posts with hero images). LLM API keys are pay-per-use if you opt into them — you can run forever on Workers AI alone.
How do I bring my own domain?
In the Cloudflare dashboard: Pages → your project → Custom domains → "Set up a custom domain". The setup script asks for the domain you'll use so the SPA, IndexNow key file, and sitemap reflect the right origin.
Can I edit posts after they're generated?
Yes — the admin dashboard has an inline post editor with markdown preview. Edits invalidate the edge cache; the change is live within seconds.
Does this work without the cron Worker?
Yes. The cron Worker is just a scheduled HTTP client that hits the admin API. You can trigger every job manually from the dashboard, or call the API from any cron source (GitHub Actions, your own server, etc.).
Is the AI-generated content "safe" for SEO?
Google's stance (as of late 2025) is that AI content is fine if it's useful. This toolkit injects your brand DNA, CTA, and keyword targets into every prompt, so output is on-brand and topical rather than generic filler. That said: read what you publish. The admin's "Preview sample" lets you dry-run a post for any brand without writing to D1/R2.
Where do I report issues?
GitHub Issues — bug template and feature template included.
If you find pages-seo useful, a star helps it surface to others who'd benefit. There's no analytics on this repo — the only signal I have that this matters to anyone is the count above this line.
PRs welcome. See CONTRIBUTING.md for the short version. The codebase is small and entirely framework-free JavaScript — no React, no build step, no transpiler.
MIT — see LICENCE.
Built by Benjamin Bloch · seo.benjaminb.xyz is this exact codebase running on its own daily cron.
If pages-seo saved you a few hours, ⭐ star the repo — it's the only metric I'm allowed to track.