MW
Tutorial

Headless WordPress with Astro — a starter kit

Build a production-ready Astro frontend that consumes a WordPress backend via REST. Includes auth, ISR-like caching, and deploy-to-Cloudflare config.

intermediate 45 min May 12, 2026
Astro 5+ installed locally A WordPress site (local or hosted) with REST API enabled Basic familiarity with TypeScript

HEADLESS WORDPRESS WITH ASTRO — A…

This tutorial walks you through building a fast, modern Astro frontend backed by WordPress as a content source. By the end you’ll have:

  • An Astro project pulling posts + pages from any WP REST API.
  • Per-route caching via Cloudflare KV.
  • A clean deploy to Cloudflare Pages.

1. Create the Astro project

Spin up a new Astro project with the minimal starter:

npm create astro@latest mw-headless -- --template minimal --typescript strict
cd mw-headless
npm install

Add the integrations we’ll need:

npm install @astrojs/cloudflare @astrojs/sitemap

Wire them into astro.config.mjs:

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
  integrations: [sitemap()],
  site: 'https://your-domain.com',
});

2. Fetch posts from WordPress

WP’s REST API ships out of the box — your posts live at https://your-wp-site.com/wp-json/wp/v2/posts. Wrap it in a tiny client:

// src/lib/wp.ts
const WP = import.meta.env.WP_REST_URL;

export async function getPosts(perPage = 20) {
  const res = await fetch(`${WP}/posts?per_page=${perPage}&_embed=1`);
  if (!res.ok) throw new Error(`WP returned ${res.status}`);
  return res.json();
}

export async function getPostBySlug(slug: string) {
  const res = await fetch(`${WP}/posts?slug=${slug}&_embed=1`);
  const arr = await res.json();
  return arr[0] ?? null;
}

The _embed=1 flag tells WP to inline the featured image + author so you don’t make a second round-trip per post.

3. Build the post list page

---
// src/pages/blog/index.astro
import { getPosts } from '../../lib/wp';
const posts = await getPosts();
---

<ul>
  {posts.map((p) => (
    <li>
      <a href={`/blog/${p.slug}`} set:html={p.title.rendered} />
    </li>
  ))}
</ul>

WP returns titles as HTML-encoded strings (“Don’t…”), so use set:html rather than {p.title.rendered} directly — it’ll decode the entities. Same goes for content.rendered.

4. Add KV caching

REST calls are 100-300ms from most regions. Wrap the fetch in a KV read-through cache and that drops to under 10ms after the first request:

// src/lib/wp.ts (extended)
export async function getPostsCached(env: any) {
  const cached = await env.CACHE.get('posts', 'json');
  if (cached) return cached;
  const fresh = await getPosts();
  await env.CACHE.put('posts', JSON.stringify(fresh), { expirationTtl: 300 });
  return fresh;
}

Five-minute TTL is a safe default. If you publish frequently, lower it or fire a cache-purge webhook from WP on transition_post_status.

5. Deploy

npx wrangler pages deploy dist

Add WP_REST_URL as a Cloudflare Pages environment variable in the dashboard before your first deploy. CI re-runs every time you push.

Verification

Hit /blog in production. The first request may take 200-400ms (cold cache + first KV write); subsequent requests should land in ~30ms. Compare TTFB before/after in Chrome DevTools’ Network panel — that’s your KV hit/miss bar.

Common errors

ErrorCauseFix
WP returned 401REST API requires auth (some hosts disable anon access)Add an Application Password header to the fetch
Cannot read 'rendered'Post structure changed (custom REST)Check _embed=1 is set + handler returns the standard shape
KV put slowWorker waiting on KV writeWrap the put in ctx.waitUntil(...) for fire-and-forget

What’s next

You've completed this tutorial!

Get the next one in your inbox. Practical tips, no fluff.

Subscribe

Get weekly notes in your inbox

Practical tips, tutorials and resources. No spam.