WordPress

Treat WordPress as a headless CMS and rip the whole site: paginate through /wp-json/wp/v2/posts, generate one HTML file per slug, and cache API responses between builds.

What you get

Project layout

my-site/
  sitelo.config.js
  src/
    lib/
      wordpress.js       # WP REST helpers (paginated rip)
    index.ht.js          # home — list latest posts
    blog/
      index.ht.js        # /blog — full archive
      [slug].ht.js       # /blog/:slug — every post
    css/
      styles.css
export default {
  site: 'https://example.com',
  // thousands of pages? raise concurrency
  renderConcurrency: 16,
  renderBatchSize: 128,
}

1. Point at your WordPress site

The REST API is on by default in modern WordPress. Confirm it at https://your-site.com/wp-json/wp/v2/posts.

Set WP_URL in the environment (or hardcode it while experimenting):

WP_URL=https://your-wordpress-site.com

2. Shared WordPress helpers

getAllPosts() reads X-WP-TotalPages and walks every page (WordPress caps per_page at 100). Skip _embed while collecting slugs — only fetch embeds for individual posts.

import { fetchWithCache } from 'sitelo'

const WP_URL = process.env.WP_URL ?? 'https://your-wordpress-site.com'
const PER_PAGE = 100 // WP max for /wp/v2/posts

async function wpFetch(path, query = {}) {
  const url = new URL(`/wp-json/wp/v2${path}`, WP_URL)
  for (const [key, value] of Object.entries(query)) {
    if (value != null) url.searchParams.set(key, String(value))
  }

  const res = await fetchWithCache(url, undefined, {
    maxAge: 3600,
    cache: 'auto',
  })

  if (!res.ok) {
    throw new Error(`WordPress ${res.status}: ${url}`)
  }

  return {
    data: await res.json(),
    totalPages: Number(res.headers.get('X-WP-TotalPages') ?? 1),
    total: Number(res.headers.get('X-WP-Total') ?? 0),
  }
}

export async function getPosts({ page = 1, perPage = 20, embed = true } = {}) {
  const { data } = await wpFetch('/posts', {
    page,
    per_page: perPage,
    _embed: embed ? '1' : undefined,
  })
  return data
}

/** Walk every page of /posts until the site is fully ripped. */
export async function getAllPosts({
  perPage = PER_PAGE,
  embed = false,
  onPage,
} = {}) {
  const first = await wpFetch('/posts', {
    page: 1,
    per_page: perPage,
    _embed: embed ? '1' : undefined,
  })

  const posts = [...first.data]
  onPage?.(1, first.totalPages, posts.length)

  for (let page = 2; page <= first.totalPages; page += 1) {
    const next = await wpFetch('/posts', {
      page,
      per_page: perPage,
      _embed: embed ? '1' : undefined,
    })
    posts.push(...next.data)
    onPage?.(page, first.totalPages, posts.length)
  }

  return posts
}

export async function getPostBySlug(slug) {
  const { data } = await wpFetch('/posts', {
    slug,
    _embed: '1',
  })
  return data[0] ?? null
}

export function postPath(post) {
  return `/blog/${post.slug}`
}

export function featuredImage(post) {
  return post._embedded?.['wp:featuredmedia']?.[0]?.source_url
}

3. Home page

4. Blog index

Use getAllPosts() so the archive isn’t capped at 50–100 items.

5. Rip every post into static pages

generateStaticParams must return every slug you want in dist/. Paginate the API here — don’t call getPosts({ perPage: 100 }) once and stop.

6. Build

WP_URL=https://your-wordpress-site.com sitelo build

First build pages through WordPress once and fills the fetch cache. Later builds reuse cached list/detail responses (cache: 'auto' → filesystem in production) until maxAge expires. Raise renderConcurrency in sitelo.config.js if you’re rendering thousands of post pages.

Notes

HTML from WordPress

title.rendered and content.rendered are HTML strings from WP. Drop them into your template as-is (as above), or sanitize them if you don’t fully trust the CMS.

Private content

Public REST routes only expose published posts. For drafts or custom auth, pass headers into fetchWithCache’s second argument (standard fetch init) and use a stable cacheKey.

Data loading docs · Routing docs