ForgeWP

Loops, Custom Post Types & Queries

How to iterate over blog posts, query custom post types, filter taxonomies, and build paginated archive grids using WpQueryLoop and useWpQuery.

In classic WordPress themes, displaying lists of articles or custom post types requires nesting procedural PHP loops:

Classic WordPress Loop (PHP)
1<?php if ( have_posts() ) : while ( have_posts() ) : the_post(); ?>
2    <h2><?php the_title(); ?></h2>
3<?php endwhile; endif; ?>

ForgeWP provides two modern, developer-friendly query mechanisms:

  1. Declarative <WpQueryLoop>: For post lists that should render through a WordPress query loop; it does not itself require client-side query state.
  2. Interactive useWpQuery(): A powerful hook for client-side search, category filters, and "Load More" pagination buttons.

Import runtime components and hooks from @forgewp/react.

For a complete walkthrough, follow Build a Latest Posts Block, then try Build a Featured Story Section.

When should I use `<WpQueryLoop>` instead of `useWpQuery()`?

Use <WpQueryLoop> for standard post listings that can render as part of the page output. It compiles to a native WordPress query loop. If the loop's child components do not need client interactivity, the loop itself does not require a client-side query bundle. Reach for useWpQuery() when the list needs client-side searching, filtering, or pagination; that behavior must run in an interactive island.


1. Declarative Queries with <WpQueryLoop>

The <WpQueryLoop> component iterates over an array of posts and exposes each post to child components via WpPostContext.

A featured header and a full listing can use different queries. A capped header is a deliberate selection; it does not show every post. Set its count, filtering, and ordering explicitly, and label it accordingly.

src/components/ProjectSelections.tsx
1import { WpQueryLoop, useWpTitle } from "@forgewp/react"
2
3function ProjectCard() {
4  const title = useWpTitle()
5  return (
6    <article>
7      <h3>{title}</h3>
8    </article>
9  )
10}
11
12export function ProjectSelections() {
13  return (
14    <>
15      <section>
16        <h2>Featured projects</h2>
17        <WpQueryLoop
18          postType="project"
19          postsPerPage={2}
20          metaKey="featured"
21          metaValue="yes"
22          orderby="date"
23          order="DESC"
24        >
25          <ProjectCard />
26        </WpQueryLoop>
27      </section>
28      <section>
29        <h2>All projects</h2>
30        <WpQueryLoop
31          postType="project"
32          postsPerPage={-1}
33          orderby="date"
34          order="DESC"
35        >
36          <ProjectCard />
37        </WpQueryLoop>
38      </section>
39    </>
40  )
41}

Here the header shows at most two matching projects, newest publication date first. The full listing includes every matching project; postsPerPage={-1} means all matches in both local preview and exported PHP. Use pagination for larger collections. date is the WordPress post date, not an event date stored in a custom field.

For local preview, put featured: "yes" in the selected mock posts' customFields. On WordPress, configure a metadata field or field-plugin control with the same key and values if editors should manage promotion. Query props filter records; they do not create that control or synchronize block attributes with metadata. A fixed selection count stays developer-owned unless you explicitly connect it to an editable setting.

Positive-count local loops retain their layout placeholders when no mock posts match. Supply matching fixtures to check the actual featured selection; an all-results local loop with no matches renders no items.

The two queries remain independent, so a post can appear in both sections. ForgeWP does not automatically exclude promoted items from the full listing, add a featured dashboard, or impose one promotion policy on every theme. Plugin-owned lists use their plugin's selection controls instead.

Standard archive layout

src/app/archive.tsx
1import {
2  WpQueryLoop,
3  useWpTitle,
4  useWpExcerpt,
5  useWpPermalink,
6  useWpFeaturedImage,
7  useWpDate,
8} from "@forgewp/react"
9
10function PostGridCard() {
11  const title = useWpTitle()
12  const excerpt = useWpExcerpt()
13  const permalink = useWpPermalink()
14  const thumbnail = useWpFeaturedImage()
15  const date = useWpDate()
16
17  return (
18    <article className="group flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition hover:shadow-md">
19      <div className="aspect-video w-full overflow-hidden bg-muted">
20        <img
21          src={thumbnail}
22          alt={title}
23          className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
24        />
25      </div>
26      <div className="flex flex-1 flex-col p-6">
27        <time className="text-xs font-semibold text-muted-foreground uppercase">
28          {date}
29        </time>
30        <h3 className="mt-2 font-heading text-xl font-bold text-foreground">
31          <a href={permalink} className="hover:text-brand transition">
32            {title}
33          </a>
34        </h3>
35        <p className="mt-2 line-clamp-3 text-sm leading-relaxed text-muted-foreground">
36          {excerpt}
37        </p>
38        <div className="mt-auto pt-6">
39          <a
40            href={permalink}
41            className="text-brand text-sm font-semibold hover:underline"
42          >
43            Read Article →
44          </a>
45        </div>
46      </div>
47    </article>
48  )
49}
50
51export default function ArchivePage() {
52  return (
53    <main className="mx-auto max-w-6xl px-6 py-16">
54      <header className="mb-12">
55        <h1 className="font-heading text-4xl font-black tracking-tight text-foreground">
56          Latest Publications
57        </h1>
58        <p className="mt-2 text-muted-foreground">
59          Engineering insights, tutorials, and ecosystem updates.
60        </p>
61      </header>
62
63      {/* Declarative Query Loop */}
64      <div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
65        <WpQueryLoop postType="post" postsPerPage={6}>
66          <PostGridCard />
67        </WpQueryLoop>
68      </div>
69    </main>
70  )
71}

<WpQueryLoop> Properties

Table · 4 columns
Property
Type
Default
Description
postType
string
"post"
Post type slug matching a key in cms/mock-data.ts (e.g. "post", "project", "event").
postsPerPage
number
3
Maximum number of posts to fetch and display in the loop.
categoryName
string
—
Filter posts by category slug (e.g. "engineering").
postId
number
—
Restrict the loop to one post ID.
orderby
string
—
Sort by a WordPress field such as date, modified, or title.
order
"ASC" | "DESC"
"DESC"
Sort direction.
taxTaxonomy
string
—
Custom taxonomy identifier (e.g. "project_category").
taxTerms
string
—
Comma-separated term slugs for custom taxonomy filtering.
metaKey
string
—
Custom post meta key to filter by (e.g. "featured").
metaValue
string
—
Custom post meta value to match.
metaCompare
string
—
Meta comparison operator used with metaKey and metaValue.
postIn
string
—
Meta key containing comma-separated post IDs for curated listicles.

What the Compiler Emits

During pnpm export, the ForgeWP compiler transpiles <WpQueryLoop> into a secure, standard WordPress WP_Query:

Generated Template (Excerpt)
1<?php
2$query_args = array(
3    'post_type' => 'project',
4    'posts_per_page' => 6,
5);
6$custom_query = new WP_Query( $query_args );
7if ( $custom_query->have_posts() ) :
8    while ( $custom_query->have_posts() ) : $custom_query->the_post();
9        // Render child components with native PHP data tags
10    endwhile;
11    wp_reset_postdata();
12endif;
13?>

2. Interactive Queries with useWpQuery()

When building modern interactive UIs—such as live search filtering, category tabs, or a "Load More" pagination button—use the useWpQuery() hook.

Load More Pagination Example

src/components/InteractivePostFeed.tsx
1import { useState } from "react"
2import { useWpQuery, type WpPost } from "@forgewp/react"
3import { Loader2 } from "lucide-react"
4
5export function InteractivePostFeed() {
6  const [selectedCategory, setSelectedCategory] = useState<string | undefined>(
7    undefined
8  )
9
10  const { posts, loading, error, hasMore, totalPages, currentPage, loadMore } =
11    useWpQuery({
12      postType: "post",
13      postsPerPage: 6,
14      categoryName: selectedCategory,
15    })
16
17  return (
18    <section className="py-12">
19      {/* Category Tabs */}
20      <div className="mb-8 flex gap-2 overflow-x-auto pb-2">
21        <button
22          onClick={() => setSelectedCategory(undefined)}
23          className={`rounded-lg border px-4 py-2 text-xs font-semibold transition ${
24            !selectedCategory
25              ? "bg-brand text-brand-foreground border-brand"
26              : "border-border bg-card"
27          }`}
28        >
29          All Posts
30        </button>
31        <button
32          onClick={() => setSelectedCategory("engineering")}
33          className={`rounded-lg border px-4 py-2 text-xs font-semibold transition ${
34            selectedCategory === "engineering"
35              ? "bg-brand text-brand-foreground border-brand"
36              : "border-border bg-card"
37          }`}
38        >
39          Engineering
40        </button>
41      </div>
42
43      {/* Error Feedback */}
44      {error && (
45        <div className="rounded-xl border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
46          Failed to load posts: {error}
47        </div>
48      )}
49
50      {/* Post Grid */}
51      <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
52        {posts.map((post: WpPost) => (
53          <article
54            key={post.id}
55            className="rounded-2xl border border-border bg-card p-6"
56          >
57            <h3 className="text-lg font-bold text-foreground">{post.title}</h3>
58            <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
59              {post.excerpt}
60            </p>
61            <a
62              href={post.permalink}
63              className="text-brand mt-4 inline-block text-xs font-bold"
64            >
65              Read More →
66            </a>
67          </article>
68        ))}
69      </div>
70
71      {/* Load More Button */}
72      {hasMore && (
73        <div className="mt-12 text-center">
74          <button
75            onClick={loadMore}
76            disabled={loading}
77            className="inline-flex items-center gap-2 rounded-xl border border-border bg-card px-6 py-3 text-sm font-semibold text-foreground shadow-sm transition hover:bg-muted disabled:opacity-50"
78          >
79            {loading ? (
80              <>
81                <Loader2 className="text-brand h-4 w-4 animate-spin" />
82                <span>Loading more stories...</span>
83              </>
84            ) : (
85              <span>
86                Load More Stories (Page {currentPage} of {totalPages})
87              </span>
88            )}
89          </button>
90        </div>
91      )}
92    </section>
93  )
94}

useWpQuery Arguments and Return Values

useWpQuery() accepts a different argument shape from <WpQueryLoop>. Its typed arguments include postType, postsPerPage, categoryName, s (search text), paged, orderby, order, p (one post ID), taxQuery, metaQuery, and metaRelation. For example, use taxQuery and metaQuery arrays here; taxTaxonomy and metaKey are <WpQueryLoop> props.

WpQueryResults Return Object
1const {
2  posts, // Array of WpPost objects currently loaded
3  loading, // Boolean: true while network or mock fetch is in progress
4  error, // String or null if query failed
5  hasMore, // Boolean: true if there are subsequent pages remaining
6  totalPages, // Total number of pages available for this query
7  currentPage, // Current 1-indexed active page
8  loadMore, // Function: appends the next page to the existing posts array
9  goToPage, // Function: jumps to a specific page number, replacing current posts
10  refetch, // Function: resets and re-executes query from page 1
11} = useWpQuery(queryArgs)

Production metaQuery and taxQuery filters

Local Vite preview evaluates taxQuery and metaQuery against cms/mock-data.ts. WordPress's standard collection REST endpoints do not support ForgeWP's full relational filters, so filtered production queries use the compiler-generated endpoint for that query.

Give every filtered useWpQuery call a stable, unique queryId. The compiler registers that endpoint during pnpm forgewp export; the post type, filter keys, and comparison operators come from source, while current filter values are sent as request parameters and sanitized server-side. This supports interactive changes to selected terms or metadata without rebuilding the theme.

TSX
1const { posts, loading, error } = useWpQuery({
2  queryId: "hospitality-events-by-city",
3  postType: "hospitality_event",
4  postsPerPage: 10,
5  metaQuery: [{ key: "city", value: selectedCity }],
6})

Filtered production queries without a queryId return an error rather than silently fetching an unfiltered collection. Queries without taxQuery or metaQuery can continue using the standard WordPress REST collection route without an ID.


When to Choose <WpQueryLoop> vs useWpQuery()

Table · 3 columns
Requirement
<WpQueryLoop>
useWpQuery()
Rendering
WordPress query loop in generated PHP; client scripts depend on the loop's children.
Client-side query state; requires an interactive hydration island.
Interactivity
Static display (no dynamic client re-ordering).
Interactive: Filter categories, load more, live search (see State, Interactivity & Auto-Hydration).
Data Access
Direct array iteration: posts.map((p) => p.title).
Typical Use Case
Main blog index, single templates, category archives, footers.
Interactive portfolio grids, search results, live facet filters.

Next Step: Menus & Navigation

Now that you can query and render posts, discover how to build responsive headers, mega menus, and mobile drawers:

Menus, Navigation & Active Routes