Skip to content
Ramesh Gautam

Fetching WordPress posts in Next.js with the REST API

Fetching WordPress posts in Next.js with the REST API, a Next.js article by Ramesh Gautam

WordPress already ships a JSON API, the WordPress REST API. Every site has posts at /wp-json/wp/v2/posts, with no plugin needed. For a headless setup with Next.js, that’s often all you need. If you’re new to the Next.js side, start with what a WordPress developer should know before starting with Next.js.

The WordPress REST API endpoint

GET https://your-site.com/wp-json/wp/v2/posts?per_page=10&_embed

Useful query parameters:

  • per_page (up to 100) and page for pagination. Totals come back in the X-WP-Total and X-WP-TotalPages headers.
  • _embed includes the featured image and author in the same response, saving extra requests.
  • slug=my-post fetches a single post by its slug.
  • _fields=id,slug,title,excerpt,date trims the response to what you use.

A list page with a server component

In the App Router, components are server components by default, so they can be async and fetch directly. The data never ships to the browser as JavaScript:

// app/blog/page.tsx
const WP = process.env.WP_URL;

type Post = {
	id: number;
	slug: string;
	date: string;
	title: { rendered: string };
	excerpt: { rendered: string };
};

export default async function BlogPage() {
	const res = await fetch( `${ WP }/wp-json/wp/v2/posts?per_page=10&_fields=id,slug,date,title,excerpt`, {
		next: { revalidate: 300 },
	} );
	if ( ! res.ok ) throw new Error( 'Failed to load posts' );
	const posts: Post[] = await res.json();

	return (
		<ul>
			{ posts.map( ( post ) => (
				<li key={ post.id }>
					<a href={ `/blog/${ post.slug }` }>{ decode( post.title.rendered ) }</a>
				</li>
			) ) }
		</ul>
	);
}

next: { revalidate: 300 } caches the response for five minutes. Visitors get a static page, and Next.js refreshes it in the background at most every five minutes. For instant updates, call revalidatePath( '/blog' ) from a route that WordPress pings when a post is published.

Titles and content are HTML

title.rendered contains HTML entities (&#8217; for an apostrophe), and content.rendered is full HTML. Decode titles as text; the decode() above can be a tiny helper or the he package. For content, you’ll render HTML, which means trusting it:

<div dangerouslySetInnerHTML={ { __html: post.content.rendered } } />

That’s acceptable when the only people who can publish are trusted editors, because WordPress already filters post content for users without the unfiltered_html capability. If content can come from less trusted sources, sanitise it on the server first, for example with sanitize-html.

Featured images

With _embed, the featured image is at post._embedded['wp:featuredmedia'][0], including source_url, alt_text and every generated size in media_details.sizes. Pass those to next/image and add the WordPress domain to images.remotePatterns in next.config.

When to move beyond REST

The REST API is fine for posts, pages and custom post types registered with show_in_rest. If you need deeply nested relationships or want to request exactly the fields you need in one query, WPGraphQL is the usual next step. Start with REST, and switch only when you hit its limits.