Skip to content

Reading WordPress Content – astrojs-wp-integration

Fetch posts, pages, custom post types, and taxonomy terms from WordPress into Astro content collections. This guide covers static (build-time) and live (request-time) data fetching patterns.

Choose the loader type based on your deployment strategy and content freshness requirements.

FeatureStatic loadersLive loaders
When data loadsBuild time (astro build)Request time (SSR)
Best forSSG sites, stable contentDynamic public/shared content
Astro APIdefineCollectiondefineLiveCollection
Fetch methodgetEntry(), getCollection()getLiveEntry(), getLiveCollection()
CachingBuilt into Astro content storePer-request fetch with optional Astro route caching

Create src/live.config.ts for live collections and src/content.config.ts for static collections:

src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { categorySchema, pageSchema, postSchema } from 'fluent-wp-client/zod';
import {
wordPressPostLoader,
wordPressPageLoader,
wordPressCategoryLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
// Live collections (SSR) - fetch on every request
const posts = defineLiveCollection({
loader: wordPressPostLoader(wp),
schema: postSchema,
});
const pages = defineLiveCollection({
loader: wordPressPageLoader(wp),
schema: pageSchema,
});
const categories = defineLiveCollection({
loader: wordPressCategoryLoader(wp),
schema: categorySchema,
});
export const collections = { posts, pages, categories };

If you need request instrumentation, a custom cache layer, or a proxy, attach it to the client instead of the loader:

const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
fetch: async (input, init) => {
return fetch(input, init);
},
});
const posts = defineLiveCollection({
loader: wordPressPostLoader(wp),
schema: postSchema,
});

Live loaders do not have a separate fetch option. The supported pattern is to build one WordPressClient with the request behavior you want and pass that same client into each loader.

Use mapEntry when a WordPress site returns technical values that should become UI-ready values in Astro content data. The callback is intentionally generic so each site can decide how to map ACF fields, plugin fields, or project-specific meta.

For ACF choices, build a reusable lookup from WordPressResourceDescription.schemas.item.properties.acf.properties.* and use that lookup inside mapEntry:

Building a choice-label lookup for all ACF fields
type AcfChoice = {
label: string;
value: string;
};
type AcfSchemaField = {
choices?: AcfChoice[];
};
const acfFields = await wp.content('posts').getSchemaValue<
Record<string, AcfSchemaField>
>('properties.acf.properties');
const choiceLabels = new Map<string, Map<string, string>>();
for (const [fieldName, fieldSchema] of Object.entries(acfFields ?? {})) {
if (!Array.isArray(fieldSchema.choices)) continue;
choiceLabels.set(
fieldName,
new Map(fieldSchema.choices.map((choice) => [choice.value, choice.label])),
);
}

Then plug that lookup into the loader:

Map ACF choice values in a loader callback
const posts = defineLiveCollection({
loader: wordPressPostLoader(wp, {
mapEntry: (post) => ({
...post,
acf: Object.fromEntries(
Object.entries(post.acf ?? {}).map(([fieldName, value]) => [
fieldName,
choiceLabels.get(fieldName)?.get(String(value)) ?? value,
]),
),
}),
}),
schema: postSchema,
});

For example, a stored value like in_progress becomes In progress when that mapping is present in the discovered ACF field schema. mapEntry is available on live and static loaders, including generic content and term loaders. Live loader callbacks receive { resource, filter } so one mapper can branch for different resources or request filters.

src/content.config.ts
import { defineCollection } from 'astro:content';
import {
postSchema,
wordPressPostStaticLoader,
} from 'wp-astrojs-integration';
// Static collections (SSG) - fetch at build time
const staticPosts = defineCollection({
loader: wordPressPostStaticLoader(wp),
schema: postSchema,
});
export const collections = { posts, pages, categories, staticPosts };
.env
PUBLIC_WORDPRESS_BASE_URL=https://your-wordpress-site.com
src/pages/blog/index.astro
---
import { getLiveCollection } from 'astro:content';
const { entries: posts } = await getLiveCollection('posts');
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>
{post.data.title.rendered}
</a>
<time>{post.data.date}</time>
</li>
))}
</ul>
src/pages/blog/[slug].astro
---
import { getLiveEntry } from 'astro:content';
import WPContent from 'wp-astrojs-integration/components/WPContent.astro';
const { slug } = Astro.params;
const { entry: post } = await getLiveEntry('posts', { slug });
if (!post) {
return Astro.redirect('/404');
}
---
<article>
<h1 set:html={post.data.title.rendered} />
<WPContent
content={post.data.content.rendered}
baseUrl={import.meta.env.PUBLIC_WORDPRESS_BASE_URL}
/>
</article>
import { getLiveEntry } from 'astro:content';
const { entry: post } = await getLiveEntry('posts', { id: 42 });
// Posts in specific categories
const { entries: techPosts } = await getLiveCollection('posts', { categories: [3, 7] });
// Posts with specific tags
const { entries: featuredPosts } = await getLiveCollection('posts', { tags: [12, 15] });
// Search posts by keyword
const { entries: results } = await getLiveCollection('posts', { search: 'headless WordPress' });
// Fetch specific posts by ID
const { entries: selected } = await getLiveCollection('posts', { include: [1, 5, 9] });
// Exclude specific posts
const { entries: others } = await getLiveCollection('posts', { exclude: [42] });
// Posts by a specific author
const { entries: authorPosts } = await getLiveCollection('posts', { author: 3 });
// Draft posts (requires auth)
const { entries: drafts } = await getLiveCollection('posts', { status: 'draft' });
// Ordered posts
const { entries: recentPosts } = await getLiveCollection('posts', { orderby: 'date', order: 'desc' });

Pages work identically to posts but use the page loader.

---
const { entries: pages } = await getLiveCollection('pages');
---
<nav>
{pages.map((page) => (
<a href={`/${page.data.slug}`}>{page.data.title.rendered}</a>
))}
</nav>
src/pages/[slug].astro
---
const { slug } = Astro.params;
const { entry: page } = await getLiveEntry('pages', { slug });
if (!page) {
return Astro.redirect('/404');
}
---
<h1 set:html={page.data.title.rendered} />
<div set:html={page.data.content.rendered} />

WordPress custom post types use the wordPressContentLoader (aligned with fluent-wp-client’s content(resource) method). Ensure your CPT is registered in WordPress with show_in_rest => true and a rest_base (e.g., products).

src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { z } from 'astro/zod';
import { WordPressClient } from 'fluent-wp-client';
import { contentWordPressSchema } from 'fluent-wp-client/zod';
import { wordPressContentLoader } from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
// Extend schema for CPT-specific fields
const productSchema = contentWordPressSchema.extend({
acf: z.object({
price: z.number().optional(),
sku: z.string().optional(),
}).optional(),
});
const products = defineLiveCollection({
loader: wordPressContentLoader(wp, {
resource: 'products', // Matches the CPT rest_base
}),
schema: productSchema,
});
export const collections = { products };
src/pages/products/index.astro
---
import { getLiveCollection } from 'astro:content';
const { entries: products } = await getLiveCollection('products');
---
<div class="product-grid">
{products.map((product) => (
<article>
<h2>{product.data.title.rendered}</h2>
{product.data.acf?.price && (
<p class="price">${product.data.acf.price}</p>
)}
<a href={`/products/${product.data.slug}`}>View</a>
</article>
))}
</div>

Live loaders return the base resource payload by default. If you need embedded relations such as featured media, fetch that entry with WordPressClient and embed: true.

If you want embedded relations through the loader itself, opt in with the loader embed option.

Enable embeds in one live loader
const posts = defineLiveCollection({
loader: wordPressPostLoader(wp, {
embed: ['author', 'wp:featuredmedia', 'wp:term'],
}),
});
src/pages/products/[slug].astro
---
import { getLiveEntry } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import WPImage from 'wp-astrojs-integration/components/WPImage.astro';
const { slug } = Astro.params;
const { entry: product } = await getLiveEntry('products', { slug });
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
if (!product) {
return Astro.redirect('/404');
}
const embeddedProduct = await wp.content('products').item(slug, { embed: ['wp:featuredmedia'] });
const featuredMedia = embeddedProduct._embedded?.['wp:featuredmedia']?.[0];
---
<article>
{featuredMedia && <WPImage media={featuredMedia} size="large" />}
<h1>{product.data.title.rendered}</h1>
{product.data.acf?.price && (
<p class="price">${product.data.acf.price}</p>
)}
{product.data.acf?.sku && (
<p class="sku">SKU: {product.data.acf.sku}</p>
)}
<div set:html={product.data.content.rendered} />
</article>

src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { categorySchema } from 'fluent-wp-client/zod';
import {
wordPressCategoryLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
const categories = defineLiveCollection({
loader: wordPressCategoryLoader(wp),
schema: categorySchema,
});
---
// Fetch all categories
const { entries: categories } = await getLiveCollection('categories');
// Fetch specific category
const { entry: category } = await getLiveEntry('categories', { slug: 'technology' });
// Filter categories
const { entries: parentCategories } = await getLiveCollection('categories', { parent: 0, hideEmpty: true });
---
<ul>
{categories.map((cat) => (
<li>
<a href={`/category/${cat.data.slug}`}>
{cat.data.name} ({cat.data.count} posts)
</a>
</li>
))}
</ul>
src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { categorySchema } from 'fluent-wp-client/zod';
import {
wordPressTagLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
const tags = defineLiveCollection({
loader: wordPressTagLoader(wp),
schema: categorySchema, // Tags share the same schema shape
});
---
// Tag cloud
const { entries: tags } = await getLiveCollection('tags', { orderby: 'count', order: 'desc', perPage: 20 });
---
<div class="tag-cloud">
{tags.map((tag) => (
<a
href={`/tag/${tag.data.slug}`}
style={`font-size: ${Math.max(0.8, tag.data.count / 10)}rem`}
>
{tag.data.name}
</a>
))}
</div>

Custom taxonomies work like built-in terms with the wordPressTermLoader. Ensure your taxonomy is registered in WordPress with show_in_rest => true and a rest_base (e.g., genres).

src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { categorySchema } from 'fluent-wp-client/zod';
import {
wordPressTermLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
const genres = defineLiveCollection({
loader: wordPressTermLoader(wp, {
resource: 'genres', // Matches the taxonomy rest_base
}),
schema: categorySchema,
});
---
// Fetch genres
const { entries: genres } = await getLiveCollection('genres');
// Single genre
const { entry: genre } = await getLiveEntry('genres', { slug: 'science-fiction' });
---
<ul>
{genres.map((g) => (
<li>
<a href={`/genres/${g.data.slug}`}>{g.data.name}</a>
<span>({g.data.count} books)</span>
</li>
))}
</ul>

Use static loaders for pre-built sites where content changes infrequently.

src/content.config.ts
import { defineCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { categorySchema, pageSchema, postSchema } from 'fluent-wp-client/zod';
import {
wordPressPostStaticLoader,
wordPressPageStaticLoader,
wordPressCategoryStaticLoader,
wordPressTermStaticLoader,
wordPressContentStaticLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
const posts = defineCollection({
loader: wordPressPostStaticLoader(wp),
schema: postSchema,
});
const pages = defineCollection({
loader: wordPressPageStaticLoader(wp),
schema: pageSchema,
});
const categories = defineCollection({
loader: wordPressCategoryStaticLoader(wp),
schema: categorySchema,
});
// Custom taxonomy
const genres = defineCollection({
loader: wordPressTermStaticLoader(wp, { resource: 'genres' }),
schema: categorySchema,
});
// Custom post type (static)
const products = defineCollection({
loader: wordPressContentStaticLoader(wp, { resource: 'products' }),
schema: productSchema,
});
export const collections = { posts, pages, categories, genres, products };
src/pages/blog/index.astro
---
import { getCollection } from 'astro:content';
// Fetch all posts (loaded at build time)
const posts = await getCollection('posts');
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>
<h2>{post.data.title.rendered}</h2>
</a>
</li>
))}
</ul>

Static loaders accept an optional filter in their options. This filter is passed to listAll() once at build time and scopes which entries are fetched. Pagination is handled automatically — the page field has no effect; listAll() always exhausts all matching pages.

src/content.config.ts
// Only fetch posts in the "tutorials" category (ID 5)
const tutorials = defineCollection({
loader: wordPressPostStaticLoader(wp, {
filter: { categories: [5] },
}),
schema: postSchema,
});
// Only fetch a specific subset of pages by ID
const keyPages = defineCollection({
loader: wordPressPageStaticLoader(wp, {
filter: { include: [1, 2, 3] },
}),
schema: pageSchema,
});
// Only fetch non-empty categories, ordered by post count
const activeCategories = defineCollection({
loader: wordPressCategoryStaticLoader(wp, {
filter: { hideEmpty: true, orderby: 'count', order: 'desc' },
}),
schema: categorySchema,
});
// CPT with search scope
const featuredBooks = defineCollection({
loader: wordPressContentStaticLoader(wp, {
resource: 'books',
filter: { search: 'award' },
}),
schema: bookSchema,
});
src/pages/blog/[...slug].astro
---
import { getEntry } from 'astro:content';
const { slug } = Astro.params;
const post = await getEntry('posts', slug);
if (!post) {
return Astro.redirect('/404');
}
---
<article>
<h1 set:html={post.data.title.rendered} />
<div set:html={post.data.content.rendered} />
</article>

Private posts, drafts, and password-protected content require authentication. Use live or static loaders only when one static/service credential is safe for every visitor who can reach the route. Use direct fluent-wp-client calls for per-user content.

src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { postSchema } from 'fluent-wp-client/zod';
import {
wordPressPostLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.WP_URL,
auth: {
username: import.meta.env.WP_USERNAME,
password: import.meta.env.WP_APP_PASSWORD,
},
});
const posts = defineLiveCollection({
loader: wordPressPostLoader(wp),
schema: postSchema,
});
src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { WordPressClient } from 'fluent-wp-client';
import { postSchema } from 'fluent-wp-client/zod';
import {
wordPressPostLoader,
} from 'wp-astrojs-integration';
const wp = new WordPressClient({
baseUrl: import.meta.env.WP_URL,
auth: {
token: import.meta.env.WP_JWT_TOKEN,
},
});
const posts = defineLiveCollection({
loader: wordPressPostLoader(wp),
schema: postSchema,
});

Live and static loaders do not receive one Astro request context, so session-based bridge auth is not available there. Use createWordPressAuthBridge() for middleware, routes, and action factories instead, and keep loaders on a public or statically authenticated WordPressClient.

src/pages/account.astro
---
Astro.cache.set(false);
const wp = Astro.locals.wp;
const user = await wp.users().me();
const drafts = await wp.content('posts').list({ status: 'draft' });
---
<h1>{user.name}</h1>
<p>{drafts.length} drafts</p>

Do not pass request tokens through live-loader filters or store request clients in global state.

See Auth Action Bridge for request-scoped auth patterns.


---
const { entry: post } = await getLiveEntry('posts', { slug });
---
<article>
<h1 set:html={post.data.title.rendered} />
<div set:html={post.data.content.rendered} />
<div set:html={post.data.excerpt.rendered} />
</article>

The WPContent component processes WordPress content with Gutenberg block styling and image handling:

---
import WPContent from 'wp-astrojs-integration/components/WPContent.astro';
const { entry: post } = await getLiveEntry('posts', { slug });
---
<article>
<h1 set:html={post.data.title.rendered} />
<WPContent
content={post.data.content.rendered}
baseUrl={import.meta.env.PUBLIC_WORDPRESS_BASE_URL}
/>
</article>

Live loader entries do not include _embedded data automatically. Use the standalone client when you need embedded relations.

Post, page, and custom content live loaders also support an explicit embed option when you want _embedded data through Astro collections.

---
import { WordPressClient } from 'fluent-wp-client';
import WPImage from 'wp-astrojs-integration/components/WPImage.astro';
const wp = new WordPressClient({
baseUrl: import.meta.env.PUBLIC_WORDPRESS_BASE_URL,
});
const post = await wp.content('posts').item(slug, { embed: ['wp:featuredmedia'] });
const featuredMedia = post._embedded?.['wp:featuredmedia']?.[0];
---
<article>
{featuredMedia && (
<WPImage
media={featuredMedia}
size="large"
loading="eager"
alt={featuredMedia.alt_text}
/>
)}
<h1 set:html={post.data.title.rendered} />
</article>

src/pages/blog/index.astro
---
import { getLiveCollection } from 'astro:content';
const [posts, categories] = await Promise.all([
getLiveCollection('posts', { perPage: 10 }),
getLiveCollection('categories'),
]);
const postEntries = posts.entries ?? [];
const categoryEntries = categories.entries ?? [];
---
<div class="blog-layout">
<aside>
<h3>Categories</h3>
<ul>
{categoryEntries.map((cat) => (
<li>
<a href={`/category/${cat.data.slug}`}>
{cat.data.name} ({cat.data.count})
</a>
</li>
))}
</ul>
</aside>
<main>
{postEntries.map((post) => (
<article>
<h2>
<a href={`/blog/${post.data.slug}`}>
{post.data.title.rendered}
</a>
</h2>
<time>{new Date(post.data.date).toLocaleDateString()}</time>
<div set:html={post.data.excerpt.rendered} />
</article>
))}
</main>
</div>
src/pages/shop/index.astro
---
import { getLiveCollection } from 'astro:content';
const { entries: products } = await getLiveCollection('products', { orderby: 'title', order: 'asc' });
// Group by category if using custom taxonomies
const { entries: categories } = await getLiveCollection('product-categories');
---
<div class="shop">
{categories.map((cat) => {
const catProducts = products.filter(p =>
p.data.product_categories?.includes(cat.data.id)
);
return (
<section>
<h2>{cat.data.name}</h2>
<div class="product-grid">
{catProducts.map((product) => (
<a href={`/shop/${product.data.slug}`} class="product-card">
<h3>{product.data.title.rendered}</h3>
{product.data.acf?.price && (
<span class="price">${product.data.acf.price}</span>
)}
</a>
))}
</div>
</section>
);
})}
</div>

EntityLive loaderStatic loaderSchema
PostswordPressPostLoaderwordPressPostStaticLoaderpostSchema
PageswordPressPageLoaderwordPressPageStaticLoaderpageSchema
MediawordPressMediaLoaderwordPressMediaStaticLoadermediaSchema
CategorieswordPressCategoryLoaderwordPressCategoryStaticLoadercategorySchema
TagswordPressTagLoaderwordPressTagStaticLoadercategorySchema
Custom taxonomieswordPressTermLoaderwordPressTermStaticLoadercategorySchema
UserswordPressUserLoaderwordPressUserStaticLoaderauthorSchema
Custom post typeswordPressContentLoaderwordPressContentStaticLoadercontentWordPressSchema (extend as needed)

All filter fields accepted by fluent-wp-client pass through directly. The fields below are the most commonly used; see the fluent-wp-client type definitions for the full list.

Live loader filters are passed as the second argument to getLiveCollection() or getLiveEntry(). Static loader filters are passed as filter in the loader options and applied once at build time.

{
// Entry lookup (live loaders only — ignored by static loaders)
id?: number; // Fetch one post by numeric ID
slug?: string; // Fetch one post by slug
// Collection scoping
search?: string; // Full-text search
include?: number[]; // Only return posts with these IDs
exclude?: number[]; // Omit posts with these IDs
author?: number; // Filter by author ID
authorExclude?: number | number[]; // Exclude posts by author ID
categories?: number | number[]; // Filter by category IDs
categoriesExclude?: number | number[]; // Exclude by category IDs
tags?: number | number[]; // Filter by tag IDs
tagsExclude?: number | number[]; // Exclude by tag IDs
taxRelation?: 'AND' | 'OR'; // Relation between taxonomy filters
status?: 'publish' | 'draft' | 'pending' | 'private' | 'future';
sticky?: boolean;
after?: string; // ISO 8601 — posts published after this date
before?: string; // ISO 8601 — posts published before this date
// Ordering and pagination
orderby?: 'date' | 'modified' | 'title' | 'author' | 'id' | 'slug' | 'relevance';
order?: 'asc' | 'desc';
perPage?: number; // Items per page (live loaders); batch size for listAll (static)
page?: number; // Page number (live loaders only)
offset?: number;
// Embedding
embed?: boolean | string[]; // Request _embedded relations
}

Same as posts plus:

{
parent?: number; // Filter by parent page ID
parentExclude?: number | number[]; // Exclude by parent page ID
orderby?: 'date' | 'modified' | 'title' | 'author' | 'id' | 'slug' | 'menu_order';
}

Terms filter (categories, tags, custom taxonomies)

Section titled “Terms filter (categories, tags, custom taxonomies)”
{
// Entry lookup (live loaders only)
id?: number;
slug?: string;
// Collection scoping
search?: string;
include?: number | number[];
exclude?: number | number[];
hideEmpty?: boolean; // Omit terms with no associated posts
parent?: number; // Filter by parent term ID
// Ordering and pagination
orderby?: 'name' | 'count' | 'slug' | 'id' | 'include';
order?: 'asc' | 'desc';
perPage?: number;
page?: number; // Live loaders only
}
{
// Entry lookup (live loaders only)
id?: number;
slug?: string;
// Collection scoping
search?: string;
include?: number | number[];
exclude?: number | number[];
roles?: string[];
// Ordering and pagination
orderby?: 'id' | 'name' | 'slug' | 'email' | 'url' | 'registered_date' | 'include';
order?: 'asc' | 'desc';
perPage?: number;
page?: number; // Live loaders only
}
  • TypeScript88%
  • PHP11%
  • Astro1%