Reading WordPress Content – astrojs-wp-integration
Reading WordPress Content
Section titled “Reading WordPress Content”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.
Static vs Live loaders
Section titled “Static vs Live loaders”Choose the loader type based on your deployment strategy and content freshness requirements.
| Feature | Static loaders | Live loaders |
|---|---|---|
| When data loads | Build time (astro build) | Request time (SSR) |
| Best for | SSG sites, stable content | Dynamic public/shared content |
| Astro API | defineCollection | defineLiveCollection |
| Fetch method | getEntry(), getCollection() | getLiveEntry(), getLiveCollection() |
| Caching | Built into Astro content store | Per-request fetch with optional Astro route caching |
1. Configure content collections
Section titled “1. Configure content collections”Create src/live.config.ts for live collections and src/content.config.ts for static collections:
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 requestconst 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.
Map fields before Astro receives them
Section titled “Map fields before Astro receives them”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:
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:
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.
import { defineCollection } from 'astro:content';import { postSchema, wordPressPostStaticLoader,} from 'wp-astrojs-integration';
// Static collections (SSG) - fetch at build timeconst staticPosts = defineCollection({ loader: wordPressPostStaticLoader(wp), schema: postSchema,});
export const collections = { posts, pages, categories, staticPosts };2. Add environment variables
Section titled “2. Add environment variables”PUBLIC_WORDPRESS_BASE_URL=https://your-wordpress-site.comFetching posts
Section titled “Fetching posts”All posts (collection listing)
Section titled “All posts (collection listing)”---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>Single post by slug
Section titled “Single post by slug”---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>Single post by ID
Section titled “Single post by ID”import { getLiveEntry } from 'astro:content';
const { entry: post } = await getLiveEntry('posts', { id: 42 });Filtered posts
Section titled “Filtered posts”// Posts in specific categoriesconst { entries: techPosts } = await getLiveCollection('posts', { categories: [3, 7] });
// Posts with specific tagsconst { entries: featuredPosts } = await getLiveCollection('posts', { tags: [12, 15] });
// Search posts by keywordconst { entries: results } = await getLiveCollection('posts', { search: 'headless WordPress' });
// Fetch specific posts by IDconst { entries: selected } = await getLiveCollection('posts', { include: [1, 5, 9] });
// Exclude specific postsconst { entries: others } = await getLiveCollection('posts', { exclude: [42] });
// Posts by a specific authorconst { entries: authorPosts } = await getLiveCollection('posts', { author: 3 });
// Draft posts (requires auth)const { entries: drafts } = await getLiveCollection('posts', { status: 'draft' });
// Ordered postsconst { entries: recentPosts } = await getLiveCollection('posts', { orderby: 'date', order: 'desc' });Fetching pages
Section titled “Fetching pages”Pages work identically to posts but use the page loader.
All pages
Section titled “All pages”---const { entries: pages } = await getLiveCollection('pages');---
<nav> {pages.map((page) => ( <a href={`/${page.data.slug}`}>{page.data.title.rendered}</a> ))}</nav>Single page
Section titled “Single page”---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} />Custom Post Types (CPT)
Section titled “Custom Post Types (CPT)”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).
Configure the collection
Section titled “Configure the collection”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 fieldsconst 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 };Fetch CPT entries
Section titled “Fetch CPT entries”---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>Single CPT entry
Section titled “Single CPT entry”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.
const posts = defineLiveCollection({ loader: wordPressPostLoader(wp, { embed: ['author', 'wp:featuredmedia', 'wp:term'], }),});---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>Taxonomy terms
Section titled “Taxonomy terms”Categories
Section titled “Categories”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 categoriesconst { entries: categories } = await getLiveCollection('categories');
// Fetch specific categoryconst { entry: category } = await getLiveEntry('categories', { slug: 'technology' });
// Filter categoriesconst { 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>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 cloudconst { 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
Section titled “Custom taxonomies”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).
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 genresconst { entries: genres } = await getLiveCollection('genres');
// Single genreconst { 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>Static site generation (SSG)
Section titled “Static site generation (SSG)”Use static loaders for pre-built sites where content changes infrequently.
Configure static collections
Section titled “Configure static collections”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 taxonomyconst 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 };Fetch static content
Section titled “Fetch static content”---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>Build-time filters
Section titled “Build-time filters”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.
// 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 IDconst keyPages = defineCollection({ loader: wordPressPageStaticLoader(wp, { filter: { include: [1, 2, 3] }, }), schema: pageSchema,});
// Only fetch non-empty categories, ordered by post countconst activeCategories = defineCollection({ loader: wordPressCategoryStaticLoader(wp, { filter: { hideEmpty: true, orderby: 'count', order: 'desc' }, }), schema: categorySchema,});
// CPT with search scopeconst featuredBooks = defineCollection({ loader: wordPressContentStaticLoader(wp, { resource: 'books', filter: { search: 'award' }, }), schema: bookSchema,});Static single entry
Section titled “Static single entry”---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>Authentication for private content
Section titled “Authentication for private content”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.
Basic auth (app passwords)
Section titled “Basic auth (app passwords)”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,});JWT auth
Section titled “JWT auth”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,});Session-based auth (auth bridge)
Section titled “Session-based auth (auth bridge)”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.
---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.
Rendering content
Section titled “Rendering content”Basic HTML rendering
Section titled “Basic HTML rendering”---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>WPContent component (Gutenberg support)
Section titled “WPContent component (Gutenberg support)”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>Featured media
Section titled “Featured media”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>Complete examples
Section titled “Complete examples”Blog with categories
Section titled “Blog with categories”---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>Product catalog with CPT
Section titled “Product catalog with CPT”---import { getLiveCollection } from 'astro:content';
const { entries: products } = await getLiveCollection('products', { orderby: 'title', order: 'asc' });
// Group by category if using custom taxonomiesconst { 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>Reference: Available loaders
Section titled “Reference: Available loaders”| Entity | Live loader | Static loader | Schema |
|---|---|---|---|
| Posts | wordPressPostLoader | wordPressPostStaticLoader | postSchema |
| Pages | wordPressPageLoader | wordPressPageStaticLoader | pageSchema |
| Media | wordPressMediaLoader | wordPressMediaStaticLoader | mediaSchema |
| Categories | wordPressCategoryLoader | wordPressCategoryStaticLoader | categorySchema |
| Tags | wordPressTagLoader | wordPressTagStaticLoader | categorySchema |
| Custom taxonomies | wordPressTermLoader | wordPressTermStaticLoader | categorySchema |
| Users | wordPressUserLoader | wordPressUserStaticLoader | authorSchema |
| Custom post types | wordPressContentLoader | wordPressContentStaticLoader | contentWordPressSchema (extend as needed) |
Reference: Common filter options
Section titled “Reference: Common filter options”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.
Posts and custom post types
Section titled “Posts and custom post types”{ // 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}