Why I wrote this: In 2024 I launched aijsons.com — 39 client-side developer tools, zero backend, all vanilla JavaScript. I chose GitHub Pages for hosting and Cloudflare for CDN because the stack costs nothing at my traffic level and keeps every tool auditable in a public repo. What no tutorial told me was how many small config mistakes would break SEO, AdSense verification, and redirect chains six months later. This article is the deployment guide I wish I had: every file name, every pitfall, and every fix pulled from the actual json-website repository.

The Stack at a Glance

Before diving into config files, here is the architecture that has served aijsons.com since launch:

Developer pushes to main
        │
        ▼
GitHub Pages (origin: static HTML/CSS/JS)
        │
        ▼
Cloudflare CDN (edge cache + Worker for URL normalization)
        │
        ▼
User browser (all tool logic runs client-side)

There is no Node server, no API route, no database. The entire site is a directory of HTML files. That simplicity is the point — users paste sensitive JSON into our tools, and nothing ever leaves their browser. But it also means all routing, caching, and redirect logic must live in static config files (_headers, _redirects, and a Cloudflare Worker), not in application code.

Why GitHub Pages + Cloudflare (Not Vercel or a VPS)

I evaluated three options before committing:

  • Vercel / Netlify — excellent DX, but I wanted the site tied directly to a public GitHub repo with transparent commit history. GitHub Pages gives me that for free.
  • A VPS ($5–20/mo) — unnecessary overhead for static files. I would be paying to run nginx serving HTML that never changes between deploys.
  • GitHub Pages + Cloudflare — free hosting, free CDN, free SSL, and Cloudflare's _headers / _redirects support on Pages-compatible setups.

For a client-side tool site with 35+ pages and no server-side logic, this combination has handled everything from 10MB JSON parsing benchmarks to AdSense ads.txt crawlers — once the config was correct.

Step 1: Repository Layout

The repo root is the web root. No build step, no dist/ folder:

json-website/
├── index.html              # Homepage
├── CNAME                   # Custom domain (www.aijsons.com)
├── _headers                # Cloudflare cache + security headers
├── _redirects              # 301/410 rules (max 100 on Cloudflare Pages)
├── robots.txt
├── sitemap.xml             # Index pointing to sub-sitemaps
├── sitemap-pages.xml
├── sitemap-tools.xml
├── sitemap-blog.xml
├── ads.txt                 # AdSense authorization
├── tools/                  # One directory per tool
│   └── json-formatter/
│       └── index.html
├── blog/                   # One directory per article
│   └── deploy-static-tools-github-pages-cloudflare/
│       └── index.html
└── worker-redirect-v2.js   # Cloudflare Worker (trailing slash normalization)

Each tool and blog post is a folder with an index.html inside. This gives clean URLs like /tools/json-formatter/ without server-side rewrite rules. The trade-off: you need a Worker or redirect rule to enforce trailing slashes consistently — more on that in Step 5.

Step 2: Custom Domain and DNS

The CNAME file in the repo root tells GitHub Pages which domain to serve:

www.aijsons.com

In Cloudflare DNS, I set:

  • CNAME wwwcdsyab1995-hash.github.io (proxied through Cloudflare)
  • CNAME or redirect for apex aijsons.comwww.aijsons.com

Pitfall I hit: serving the site on both aijsons.com and www.aijsons.com without a forced redirect. Google indexed both, Search Console showed duplicate URLs, and our robots.txt pointed to one canonical sitemap while users bookmarked the other. Fix: pick www as canonical, 301 everything else, and use www in every canonical tag and sitemap <loc>.

Step 3: The _headers File (Cache + Security + ads.txt)

Cloudflare reads _headers at deploy time. Here is the production config from our repo, trimmed to the sections that matter most:

# Immutable assets — 1 year cache
/css/*
  Cache-Control: public, max-age=31536000, immutable
/js/*
  Cache-Control: public, max-age=31536000, immutable

# HTML — short cache with stale-while-revalidate
/
  Cache-Control: public, max-age=21600, stale-while-revalidate=86400

# ads.txt MUST be text/plain for AdSense crawler
/ads.txt
  Content-Type: text/plain; charset=utf-8
  Cache-Control: public, max-age=21600, stale-while-revalidate=86400

# Security headers on all pages
/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: SAMEORIGIN
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), microphone=(), camera=()

Three lessons from tuning this file:

  1. Separate cache policies for assets vs HTML. Our CSS and JS files are fingerprinted by path; caching them for a year eliminates repeat downloads. HTML pages change more often (new blog posts, tool updates), so a 6-hour TTL with stale-while-revalidate lets Cloudflare serve cached pages while fetching updates in the background.
  2. ads.txt content-type matters. Google AdSense crawls https://yourdomain.com/ads.txt and expects text/plain. If your CDN serves it as text/html, verification fails silently. We explicitly set the content type in _headers.
  3. Security headers cost nothing on static sites. X-Frame-Options: SAMEORIGIN prevents clickjacking of our tools. Since we have no forms or cookies beyond analytics, CSP is simpler than on a dynamic app — but the baseline headers still help.

Step 4: The _redirects File (Migrations, Merges, and the 100-Rule Limit)

When you run a site for 18+ months, URLs accumulate. We merged duplicate tool pages, retired 70+ low-quality blog posts, and killed an entire /news/ section. All of that lives in _redirects:

# Tool consolidation
/tools/json-diff/        /tools/json-compare/  301
/tools/json-tree-viewer/ /tools/json-viewer/   301

# Blog merge (multiple old slugs → one canonical article)
/blog/json-formatter-guide-developers/*  /blog/json-formatter-complete-guide-2026/  301

# Retired section → 410 Gone
/news/*  /404.html  410

# ads.txt must return 200 on apex WITHOUT following www redirect
/ads.txt  /ads.txt  200!

Critical pitfall — glob patterns that do not match: I initially wrote /tools/json-diff/* expecting it to redirect /tools/json-diff/. On Cloudflare Pages, the * wildcard requires at least one path segment after the slash. The URL /tools/json-diff/ did not match, so the duplicate page stayed live and Google indexed both Compare and Diff as separate tools. Fix: add explicit rules without the glob:

/tools/json-diff/   /tools/json-compare/  301
/tools/json-diff     /tools/json-compare/  301

Second pitfall — the 100-rule limit: Cloudflare Pages allows a maximum of 100 redirect rules in _redirects. Our file currently has ~90. When we needed more, we prioritized: (1) 301 merges for live duplicate content, (2) 410 for deleted spam pages, (3) date-suffix cleanup. Rules that targeted already-deleted files were removed — they were causing double-redirect chains without helping users.

Step 5: Cloudflare Worker for Trailing Slashes

GitHub Pages serves both /blog and /blog/ as the same content, but search engines treat them as different URLs. Our Worker (worker-redirect-v2.js) runs at the Cloudflare edge and 301-redirects any extensionless path without a trailing slash:

// Simplified from worker-redirect-v2.js
if (originalPath !== '/' && !originalPath.endsWith('/')
    && !/\.[a-zA-Z0-9]{2,6}$/.test(originalPath)) {
  return Response.redirect(url.origin + originalPath + '/' + url.search, 301);
}

Files with extensions (.xml, .txt, .html) are excluded so /sitemap.xml and /ads.txt are not broken. The Worker also handles legacy path migrations like /pages/format.html/tools/json-formatter/ — paths that predate our current directory structure.

Division of labor: Worker handles URL shape normalization (trailing slashes, legacy paths). _redirects handles content migrations (merged articles, retired sections). Keeping these separate made debugging redirect loops much easier.

Step 6: Sitemap Strategy for a Multi-Section Site

A single sitemap with 100+ URLs becomes hard to maintain and slow to regenerate. We split into an index file and three sub-sitemaps:

sitemap.xml          → index
sitemap-pages.xml    → 9 pages (home, about, blog index, policies)
sitemap-tools.xml    → 35 tool pages
sitemap-blog.xml     → 28 curated articles (down from 97)

When we pruned low-quality blog posts for AdSense review, we updated three things in the same commit: deleted or redirected the HTML files, removed URLs from sitemap-blog.xml, and added 301 rules in _redirects. Skipping any one of these steps caused the problems I flagged in our site audit: blog index listing dead links, Search Console reporting 404s, and related-article widgets pointing to removed pages.

Pitfalls Checklist (From Real Production Incidents)

Symptom Root Cause Fix
Sitemap returns 500 Malformed XML or wrong content-type on non-www domain Validate XML, serve via www canonical, check _headers
AdSense "ads.txt unauthorized" Apex domain redirects before crawler reads file /ads.txt /ads.txt 200! in _redirects
Duplicate tool pages indexed Glob redirect /* did not match exact path Add explicit rules without wildcard
Blog index shows 105 posts, sitemap has 28 HTML list not regenerated after content prune Generate list from sitemap or shared manifest
Redirect loop on old URLs Glob rule matches destination path too Remove stale rules; test with curl -IL

Post-Deploy Verification (Copy This Checklist)

After every deploy that touches routing or sitemaps, I run these checks from a terminal:

# Canonical redirect
curl -sI https://aijsons.com/ | grep -i location
# Expect: https://www.aijsons.com/

# Sitemap healthy
curl -sI https://www.aijsons.com/sitemap.xml | head -1
# Expect: HTTP/2 200

# ads.txt reachable on apex
curl -sI https://aijsons.com/ads.txt | head -1
# Expect: HTTP/2 200

# Tool merge redirect
curl -sI https://www.aijsons.com/tools/json-diff/ | grep -i location
# Expect: /tools/json-compare/

# Trailing slash enforcement
curl -sI https://www.aijsons.com/blog | grep -i location
# Expect: /blog/

I also submit the updated sitemap in Google Search Console and wait 3–7 days before checking the "Pages" report for new 404 errors. Static sites feel "done" after deploy, but SEO health is a continuous sync problem between HTML files, redirect rules, and sitemap XML.

What I Would Do Differently on a New Project

If I started a second tool site today, I would:

  • Generate the blog index from sitemap-blog.xml in a pre-deploy script, so the listing and sitemap never drift apart.
  • Keep redirect rules under 60 to leave headroom before the 100-rule cap.
  • Pick canonical domain on day one and never serve content on both apex and www.
  • Skip a separate /news/ section entirely — one /blog/ with categories is simpler to maintain.

The stack itself — GitHub Pages + Cloudflare + vanilla JS — I would keep. It has scaled from a single JSON formatter to 39 tools without a line of server code, and that privacy guarantee is the product's core value.

Key Takeaways

  • GitHub Pages + Cloudflare is a viable, free stack for client-side developer tool sites with no backend.
  • _headers controls caching and AdSense-critical content types; _redirects handles content migrations within a 100-rule budget.
  • A Cloudflare Worker should handle trailing-slash normalization separately from content redirects.
  • Sitemap, redirect rules, and HTML navigation must be updated in the same deploy — never one without the others.
  • Test with curl -IL after every routing change; redirect glob patterns are trickier than they look.