atomicsolutions.dev

Headless WordPress with React: A Practical Guide Using React Query Hooks

Andrej Tomic
react-wordpress wordpress react headless

WordPress powers roughly 40% of the web, but its default PHP-rendered frontend is showing its age. If you want fast page loads, full control over your UI, and the ability to ship a native mobile app alongside your web frontend, going headless is the right call. The catch is that wiring up the WordPress REST API by hand is tedious: you write the same useEffect/fetch/useState boilerplate for every endpoint, manage cache invalidation yourself, and reinvent loading and error states on every screen.

@atomic-solutions/react-wordpress removes all of that friction. It wraps the WordPress REST API in a set of React Query hooks so you can query posts, pages, and auth state with a single import — no hand-rolled fetch logic, no manual cache keys, no surprises. The same hooks work in React web apps and React Native.

This guide walks through everything from installation to fetching paginated posts with infinite scroll, including how to protect routes with the authentication hooks.

For a full hook reference, see the React WordPress hooks documentation.


Prerequisites

  • A WordPress site with the REST API accessible (it is on by default since WP 4.7)
  • A React project (Vite, Next.js, Expo — all work)
  • Basic familiarity with React Query concepts (useQuery, QueryClient)

Install

pnpm add @atomic-solutions/react-wordpress

The package has peer dependencies on react and @tanstack/react-query. If you have not set up React Query yet:

pnpm add @tanstack/react-query

Step 1: Set Up WordPressProvider

@atomic-solutions/react-wordpress uses React context to pass the WordPress base URL down to every hook. Wrap your application root with WordPressProvider and give it your site’s REST API base URL.

// src/main.tsx (or App.tsx / _app.tsx)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WordPressProvider } from '@atomic-solutions/react-wordpress'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <WordPressProvider baseURL="https://your-site.com/wp-json">
        <Router />
      </WordPressProvider>
    </QueryClientProvider>
  )
}

Two things to note here:

  1. WordPressProvider sits inside QueryClientProvider. It relies on React Query internally, so the QueryClient must be available in context first.
  2. The baseURL should point at the WP REST API root — typically https://your-site.com/wp-json. Do not add a trailing slash.

For Next.js App Router, place this in a providers.tsx client component and import it from your root layout.tsx.


Step 2: Fetch a List of Posts

With the provider in place, you can start querying from any component in the tree. The usePosts hook accepts the same query parameters as the native WordPress REST API — per_page, page, categories, tags, search, and more.

import { usePosts } from '@atomic-solutions/react-wordpress'

function BlogList() {
  const { data, isLoading, error } = usePosts({ per_page: 10 })

  if (isLoading) return <div>Loading…</div>
  if (error) return <div>Something went wrong.</div>

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

The data object returned by usePosts shapes the WordPress REST API response: data.data is the array of post objects, and data.headers carries pagination metadata like X-WP-Total and X-WP-TotalPages if you need to build a manual paginator.

Post objects mirror the WordPress REST API shape — post.title.rendered gives you the HTML-decoded title, post.content.rendered gives you the full post body, and post.excerpt.rendered gives you the excerpt. For list views, excerpt.rendered is usually the right choice to avoid pulling the entire post body on every card.


Step 3: Fetch a Single Post

When a user navigates to a post page, use usePost with the post’s slug (or numeric ID):

import { usePost } from '@atomic-solutions/react-wordpress'

interface PostPageProps {
  slug: string
}

function PostPage({ slug }: PostPageProps) {
  const { data: post, isLoading, error } = usePost(slug)

  if (isLoading) return <div>Loading post…</div>
  if (error) return <div>Post not found.</div>
  if (!post) return null

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  )
}

usePost accepts either a slug string or a numeric post ID. Passing the slug is preferable for SEO-friendly URLs since you usually have it from the route params without an extra lookup.

Note the use of dangerouslySetInnerHTML for title.rendered and content.rendered. WordPress returns these fields as HTML strings — this is expected behavior from the REST API. If you need to sanitize content (for example, if anonymous users can submit posts), run it through a library like dompurify before rendering.


Step 4: Handle Loading and Error States Properly

The pattern above works, but in a real app you want consistent loading skeletons and error boundaries rather than per-component fallbacks. React Query’s isLoading, isFetching, and error states compose cleanly with Suspense and error boundaries if you enable suspense: true in your QueryClient defaults, but the hooks also work in traditional mode.

A pragmatic pattern for list views:

import { usePosts } from '@atomic-solutions/react-wordpress'

function BlogList() {
  const { data, isLoading, isError, isFetching } = usePosts({
    per_page: 12,
    orderby: 'date',
    order: 'desc',
  })

  if (isLoading) {
    return (
      <div className="grid grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="h-48 animate-pulse rounded bg-gray-200" />
        ))}
      </div>
    )
  }

  if (isError) {
    return (
      <p className="text-red-600">
        Could not load posts. Check that your WordPress REST API is publicly
        accessible and CORS headers are configured.
      </p>
    )
  }

  return (
    <div className="relative">
      {isFetching && (
        <span className="absolute right-0 top-0 text-sm text-gray-400">
          Refreshing…
        </span>
      )}
      <div className="grid grid-cols-3 gap-6">
        {data?.data.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  )
}

The distinction between isLoading (first load, no cached data) and isFetching (background refetch while cached data is still shown) lets you avoid layout jumps on subsequent visits.


Step 5: Infinite Scroll with useInfinitePosts

For a blog feed or news listing, infinite scroll is often a better UX than paginated navigation. The useInfinitePosts hook handles this directly:

import { useInfinitePosts } from '@atomic-solutions/react-wordpress'
import { useInView } from 'react-intersection-observer'
import { useEffect } from 'react'

function InfiniteBlogFeed() {
  const { ref, inView } = useInView()
  const {
    data,
    isLoading,
    isFetchingNextPage,
    hasNextPage,
    fetchNextPage,
  } = useInfinitePosts({ per_page: 10 })

  useEffect(() => {
    if (inView && hasNextPage) fetchNextPage()
  }, [inView, hasNextPage, fetchNextPage])

  if (isLoading) return <div>Loading…</div>

  const posts = data?.pages.flatMap(page => page.data) ?? []

  return (
    <>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <a href={`/blog/${post.slug}`}>{post.title.rendered}</a>
          </li>
        ))}
      </ul>
      <div ref={ref} />
      {isFetchingNextPage && <div>Loading more…</div>}
    </>
  )
}

useInfinitePosts returns paginated data in data.pages — an array of responses, one per loaded page. Flattening with .flatMap(page => page.data) gives you the full list of posts across all loaded pages. The sentinel <div ref={ref} /> at the bottom triggers the next page load when it scrolls into view.


Authentication Hooks

If you are building a member area, a preview mode for draft posts, or a headless storefront with WooCommerce, you will need authenticated requests. The package ships three hooks for this:

  • useWPAuth — returns the current auth state (authenticated user or null)
  • useWPLogin — returns a login mutation function; call it with { username, password }
  • useWPLogout — returns a logout mutation function
import { useWPAuth, useWPLogin } from '@atomic-solutions/react-wordpress'

function LoginForm() {
  const { data: auth } = useWPAuth()
  const login = useWPLogin()

  if (auth) return <p>Logged in as {auth.user_display_name}</p>

  return (
    <button
      onClick={() => login.mutate({ username: 'admin', password: 'secret' })}
      disabled={login.isPending}
    >
      {login.isPending ? 'Signing in…' : 'Sign in'}
    </button>
  )
}

Authentication uses the WordPress Application Passwords feature (available since WP 5.6) or a JWT plugin, depending on your server setup. The hooks handle token storage and attach the Authorization header to subsequent requests automatically.


React Native

All of the hooks described above work in React Native without any changes. The only difference is rendering — swap <div> for <View>, <p> for <Text>, and so on. The WordPressProvider setup is identical.

This makes @atomic-solutions/react-wordpress a solid choice if you are building both a web frontend and a mobile app against the same WordPress backend, since you share the same data-fetching layer across both targets.


Further Reading

The full API surface — including usePages, usePage, and all query parameter types — is documented on the WordPress React hooks reference page. The reference covers every hook signature, the shape of returned data objects, and configuration options for the provider.


Conclusion

Going headless with WordPress no longer means writing a wall of boilerplate. With @atomic-solutions/react-wordpress, you get React Query-powered data fetching for posts, pages, and authentication out of the box — type-safe, cache-aware, and ready for both web and React Native. Install the package, wrap your app with WordPressProvider, and your first usePosts call is three lines of code.

Need help shipping a headless WordPress project in production? Atomic Solutions builds headless WordPress storefronts for clients.