A Claude Code Case Study

From WordPress to
Lightning-Fast Static Site

How an AI agent can migrate your entire website — content, images, templates and all — in a single working session.

Why Migrate?

WordPress powers millions of sites, but for content-driven community projects, heritage blogs, and portfolio sites it often becomes more burden than benefit:

  • Speed — Dynamic PHP + MySQL adds hundreds of milliseconds per request. A static site serves pre-built HTML in single-digit milliseconds from a CDN.
  • Cost — Free-tier WordPress.com limits customisation. Self-hosted WordPress needs ongoing hosting fees, plugin licenses, and maintenance. A static site on Netlify is free.
  • Security — No database, no login page, no attack surface. Static files can't be hacked.
  • Control — Your content lives as portable JSON files and Jinja2 templates. No vendor lock-in, no opaque database exports.

The catch? Migration is tedious. Hundreds of posts, thousands of images, interlinked pages with WordPress-specific markup — it's a perfect job for an AI agent.

The Architecture

Six stages. Each one maps to a single, well-crafted prompt.

Migration Pipeline

WordPress Scraper JSON Data Build Script Static HTML Netlify CDN

Alongside this, a lightweight Flask admin panel lets the site owner manage content locally — create posts, schedule events, upload images — then build and deploy to production with a single click.

Ongoing Content Management

Flask Admin Edit JSON Build Deploy
1

Sitemap Discovery & URL Classification

Every WordPress site exposes a sitemap.xml — an index of all public URLs. The first step is to fetch it, handle nested sub-sitemaps, and classify each URL as either a blog post (date-based path like /2017/03/12/my-post/) or a static page.

This classification drives everything downstream: posts get date metadata, pages get slug mappings, and the scraper knows which extraction logic to apply.

Prompt for Claude Code

Write a Python scraper that:
1. Fetches the sitemap.xml from my WordPress site
2. Handles nested sitemap indexes (sitemap of sitemaps)
3. Classifies each URL as either a blog post or a static page
   - Posts match the pattern /YYYY/MM/DD/slug/
   - Everything else is a static page
4. Maps old page slugs to new canonical slugs (e.g. "gallery-2" → "gallery",
   "the-sunday-school-three" → "sunday-school-three")
5. Saves the classified URL list as JSON for the next step
Use requests + BeautifulSoup with lxml-xml parser.
Add retry logic (3 attempts) and a 1-second delay between requests.
2

Content Scraping & Image Download

For each URL, the scraper extracts the content HTML from WordPress's div.entry-content, downloads every embedded image at full resolution, and rewrites all <img src> attributes to point to local paths.

What gets cleaned

  • WordPress sharing widgets (.sharedaddy, #jp-post-flair)
  • Inline ad scripts and tracking pixels
  • Image sizing parameters (?w=558&h=300)
  • srcset, data-orig-file, and other WP-specific attributes
  • Internal links rewritten from /2017/03/12/slug/ to /posts/slug/

Prompt for Claude Code

Extend the scraper to visit each classified URL and extract content:

For blog posts, extract:
- Title (from h1.entry-title)
- Date (from the URL path /YYYY/MM/DD/)
- Author (from span.author, default to site owner name)
- Tags (from a[rel="tag"] elements)
- Content HTML (from div.entry-content)
- All images: download full-res versions, save to images/posts/{slug}/,
  rewrite src attributes to local paths

For static pages, extract:
- Title and content HTML
- All images saved to images/pages/{slug}/

Clean up WordPress artifacts: remove .sharedaddy, inline ads, srcset
attributes, and image sizing query parameters.
Rewrite all internal links to the new URL structure.
Save each item as a JSON file in data/posts/ or data/pages/.
3

JSON as Your CMS

Instead of a database, every piece of content is a JSON file. This makes the data human-readable, version-controllable with Git, and trivially portable. The schema is intentionally flat — no joins, no relations, just the data you need.

Post Schema

{
  "slug": "my-post-title",
  "title": "My Post Title",
  "date": "2017-03-12",
  "author": "Geoff",
  "tags": ["heritage", "local-history"],
  "excerpt": "First 200 characters of content...",
  "content_html": "<p>Full HTML content...</p>",
  "featured_image": "images/posts/my-post-title/image-001.jpg",
  "images": [
    {
      "filename": "image-001.jpg",
      "original_url": "https://old-site.wordpress.com/wp-content/...",
      "local_path": "images/posts/my-post-title/image-001.jpg",
      "alt": "Description of the image"
    }
  ],
  "original_url": "https://old-site.wordpress.com/2017/03/12/my-post/",
  "source": "scraped"
}

Events, pages, and any other content types follow the same pattern — one JSON file per item, images stored alongside in a predictable folder structure.

4

Static Site Generator with Jinja2

A single Python build script reads every JSON file, renders Jinja2 templates, and outputs a complete static website into an output/ directory. No framework, no dependencies beyond Jinja2 itself.

What it generates

  • Homepage with featured content
  • Paginated blog archive
  • Individual post pages with prev/next
  • Event listings
  • Static pages (About, Contact, etc.)
  • Client-side search JSON
  • sitemap.xml

Template inheritance

A base.html defines the shell — nav, footer, fonts, colour palette via Tailwind CSS. Each page type extends it. Partials like post-card.html keep things DRY.

Prompt for Claude Code

Build a static site generator in a single Python script (build.py):

1. Load all JSON data from data/posts/, data/events/, data/pages/
2. Enrich posts with formatted dates, month names, year/month/day fields
3. Sort posts by date descending, events by date ascending
4. Split events into upcoming (>= today) and past
5. Setup Jinja2 with a templates/ directory, autoescape off
6. Generate:
   - index.html (homepage with featured post, latest 6 posts, 3 events)
   - posts/ archive with pagination (12 per page)
   - Individual post pages with prev/next navigation
   - events/ listing and individual event pages
   - All static pages from data/pages/
   - sitemap.xml with all generated URLs
7. Copy static/ and images/ to output/
8. Update meta.json with build timestamp

Use Tailwind CSS via CDN with a custom colour palette.
Typography: Playfair Display for headings, Inter for body text.
5

Local Admin Panel with Flask

A lightweight Flask app running on localhost:5000 gives the site owner a familiar CMS-like interface — without the overhead of a database or hosting fees. It reads and writes the same JSON files the build script consumes.

Dashboard

Post count, event count, build status at a glance. Quick links to recent content.

Content Editor

Create, edit, delete posts and events. HTML content editing with tag management.

Image Uploads

Upload images with timestamps to prevent collisions. PNG, JPG, WebP, SVG supported.

One-Click Deploy

Triggers build + deploy in a background thread. Live status polling so you never leave the admin.

Prompt for Claude Code

Create a Flask admin panel (admin/app.py) for managing site content:

Routes needed:
- GET / → Dashboard with stats and recent content
- GET/POST /posts, /posts/new, /posts/{slug}/edit, /posts/{slug}/delete
- GET/POST /events, /events/new, /events/{slug}/edit, /events/{slug}/delete
- POST /images/upload → save to images/uploads/ with timestamp
- POST /build → run build.py in background thread, optional --deploy flag
- GET /build/status → JSON endpoint for polling build progress

Post form: title, date, author, tags (comma-separated), content_html,
featured_image. Auto-generate slug from title, excerpt from content.

Event form: title, date, time_start, time_end, location, description_html,
booking_url, booking_label, tags, published flag.

Build endpoint: run as subprocess in a background thread, capture stdout,
track status (running/success/failed) with timestamp.

Max upload size: 1MB. Allowed formats: png, jpg, jpeg, gif, webp, svg.
6

Deploy to Netlify

Netlify serves the static output/ folder from a global CDN — free for personal sites. The deploy function reads credentials from a .env file and pushes with the Netlify CLI.

Redirect rules for old WordPress URLs

The old WordPress URL structure doesn't match the new one. Netlify's netlify.toml handles this with 301 redirects — both for specific page renames and a wildcard pattern for date-based blog post URLs:

# netlify.toml

[build]
  publish = "output"

# Old page slugs → new slugs
[[redirects]]
  from = "/about-us/"
  to = "/about/"
  status = 301

[[redirects]]
  from = "/contact-us/"
  to = "/contact/"
  status = 301

# Catch-all for WordPress date-based post URLs
[[redirects]]
  from = "/:year/:month/:day/:slug/"
  to = "/posts/:slug/"
  status = 301

# Cache static assets aggressively
[[headers]]
  for = "/images/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000"

Prompt for Claude Code

Add a deploy function to build.py that:
1. Reads NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID from a .env file
2. Runs: netlify deploy --prod --dir=output --site={SITE_ID}
3. Passes the auth token as an environment variable
4. Returns success/failure status

Also add a --deploy CLI flag so running "python3 build.py --deploy"
builds the site AND deploys it in one command.

Create a netlify.toml with:
- Publish directory: output/
- 301 redirects mapping old WordPress slugs to new paths
- A wildcard redirect: /:year/:month/:day/:slug/ → /posts/:slug/
- Cache-Control headers (1 year) for images and static assets
7

Redirect the Old Site

The final piece: sending visitors from the old WordPress.com URL to the new domain. WordPress.com offers a Site Redirect upgrade (~$13/year) that 301-redirects all traffic, preserving the path.

Combined with the Netlify redirects from Step 6, this creates a seamless chain:

Visitor requests: yoursite.wordpress.com/2017/03/12/my-post/

WordPress redirects: yournewdomain.com/2017/03/12/my-post/

Netlify redirects: yournewdomain.com/posts/my-post/

Result: Served in ~15ms from the CDN edge.

Every old link — shared on social media, saved in bookmarks, indexed by Google — continues to work. No broken links, no lost SEO equity.

The Result

WordPress Static Site
Page load 1.5 – 3s < 100ms
Hosting cost $4 – 25/mo Free (Netlify)
Security patches Monthly None needed
Plugin updates Constant N/A
Custom design Theme limitations Full control
Content editing wp-admin (online) Flask admin (local)
Data portability XML export JSON files in Git
Uptime 99.9% ~100% (CDN)

What the AI agent handled

In a single working session, Claude Code executed the entire pipeline: wrote the sitemap scraper, extracted and cleaned 200+ posts with 1,000+ images, built the Jinja2 template system, created the Flask admin panel, configured Netlify deployment, and set up redirect rules — all from natural language prompts.

The site owner's role was to describe the intent and review the output. The agent did the engineering.

Try It Yourself

The prompts in this guide are real. Copy them into Claude Code, point them at your WordPress sitemap, and watch it build. A few things to keep in mind:

  • Start with the scraper. Get your data into JSON first. Everything else builds on that foundation.
  • Iterate on templates. The first pass won't be perfect. Use follow-up prompts to refine styling, fix edge cases, and add responsive design.
  • Version control early. Commit after each major step. Your JSON data files are your database — treat them accordingly.
  • Test redirects. Run curl -I yoursite.com/old-path/ to verify 301 chains resolve correctly.

The prompt that starts it all

I have a WordPress.com site at https://mysite.wordpress.com.
I want to migrate it to a static site hosted on Netlify.

Start by fetching my sitemap.xml and scraping all content
(posts, pages, and images) into a local data/ directory
as structured JSON files.

Then build a static site generator, a local Flask admin panel,
and a deploy pipeline to Netlify.

Your Site, Your Data, Your Stack

Static for simple sites. Flask when you need a backend. An AI agent to do the heavy lifting. The goal is always the same: the least complexity that solves the problem.