❗ This is not a tutorial ❗
Last night I found out I can serve images and audio files from a DigitalOcean space. Then I created a bunch of WordPress posts for audio files. Browsing through them got me wanting “previous” and “next” buttons at the bottom of each post.
This documentation looks hopeful: https://wpengine.com/builders/pagination-headless-wordpress-wpgraphql-apollo-next-js/ (details are in the “Previous and Next Posts” section).
The suggestion is to modify the WPGraphQL schema so that it returns previousPost
and nextPost
fields. This code is given as an example of how to do it: https://github.com/kellenmace/pagination-station/blob/main/pagination-fields.php.
I’ll go through the file to make sure I understand what it’s doing, then convert it into a proper WordPress plugin.
A bit of PHP
It’s been a while.
I’ll start by creating a file for the plugin in my local WordPress site’s wp-content/plugins
directory:
$ touch wpgraphql-previous-next.php
// wpgraphql-previous-next.php
<?php
/**
* Plugin Name: WPGraphQL Previous Next
* Description: Adds previousPost and nextPost fields to the data returned by the WPGraphQL plugin
* Version: 0.1
* Author: scossar
*/
That worked. A plugin that does nothing:
I’ll go ahead and activate it. I’ll also open a terminal on the debug.log
file that I’ve added to the WordPress site’s wp-content
directory:
$ tail -f debug.log
Details about this approach to debugging are here: https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/. I intentionally print data to the debug.log
file with this WordPress plugin:
// log-error.php
<?php
/**
* Plugin Name: log error
* Version: 0.1
* Author: scossar
*/
function write_log( ...$log_items ) {
if ( true === WP_DEBUG ) {
foreach ( $log_items as $log_item ) {
if ( is_array( $log_item ) || is_object( $log_item ) ) {
error_log( print_r( $log_item, true ) );
} else {
error_log( $log_item );
}
}
}
}
With that plugin activated, I can do things like this:
// wpgraphql-previous-next.php
<?php
/**
* Plugin Name: WPGraphQL Previous Next
* Description: Adds previousPost and nextPost fields to the data returned by the WPGraphQL plugin
* Version: 0.1
* Author: scossar
*/
write_log("this is a string");
$test_array = [
"foo" => "bar",
"bar" => "foo",
];
write_log("this function call has two arguments - a string and an array", $test_array);
Outputs the following to to debug.log
:
[06-Mar-2024 23:47:57 UTC] this is a string
[06-Mar-2024 23:47:57 UTC] this function call has two arguments - a string and an array
[06-Mar-2024 23:47:57 UTC] Array
(
[foo] => bar
[bar] => foo
)
[06-Mar-2024 23:47:57 UTC] this is a string
[06-Mar-2024 23:47:57 UTC] this function call has two arguments - a string and an array
[06-Mar-2024 23:47:57 UTC] Array
(
[foo] => bar
[bar] => foo
)
[06-Mar-2024 23:48:58 UTC] this is a string
[06-Mar-2024 23:48:58 UTC] this function call has two arguments - a string and an array
[06-Mar-2024 23:48:58 UTC] Array
(
[foo] => bar
[bar] => foo
)
A downside is that assuming the plugin is only installed on a development site, pushing code with calls to write_log
to production will crash the production site…
Starting from the top of https://github.com/kellenmace/pagination-station/blob/main/pagination-fields.php, my IDE and debug.log
file are both showing the warning: “the use statement with non-compounded name ‘WP_Post’ has no effect.” Thinking about it a bit, the warning is correct. WP_Post
is a core WordPress class. There’s no need to import it. I’ll leave off that statement and copy the rest of the code:
// wpgraphql-previous-next.php
<?php
/**
* Plugin Name: WPGraphQL Previous Next
* Description: Adds previousPost and nextPost fields to the data returned by the WPGraphQL plugin
* Version: 0.1
*/
use WPGraphQL\Model\Post;
class PaginationFields {
public function register_hooks() {
add_action( 'graphql_register_types', [ $this, 'register_post_fields' ] );
}
public function register_post_fields() {
register_graphql_fields('Post', [
'previousPost' => [
'type' => 'Post',
'description' => __( 'Previous post', 'hwp-rockers' ),
'resolve' => function( Post $resolving_post ) {
if ( is_post_type_hierarchical( $resolving_post->post_type ) ) {
$previous_post_id = get_previous_page_id( $resolving_post );
return $previous_post_id ? new Post( $previous_post_id ) : null;
}
$post = get_post( $resolving_post->postId );
$GLOBALS['post'] = $post;
setup_postdata( $post );
$previous_post = get_previous_post();
wp_reset_postdata();
return $previous_post ? new Post( $previous_post ) : null;
}
],
'nextPost' => [
'type' => 'Post',
'description' => __( 'Next post', 'hwp-rockers' ),
'resolve' => function( Post $resolving_post ) {
if ( is_post_type_hierarchical( $resolving_post->post_type ) ) {
$next_post_id = get_next_page_id( $resolving_post );
return $next_post_id ? new Post( $next_post_id ) : null;
}
$post = get_post( $resolving_post->postId );
$GLOBALS['post'] = $post;
setup_postdata( $post );
$next_post = get_next_post();
wp_reset_postdata();
return $next_post ? new Post( $next_post ) : null;
}
],
]);
}
private function get_previous_page_id( Post $page ): int {
return get_adjacent_page_id( $page, -1 );
}
private function get_next_page_id( Post $page ): int {
return get_adjacent_page_id( $page, 1 );
}
/*
* @param WP_Post $page Page Object.
* @param int $direction Integer -1 or 1 indicating next or previous post.
*
* @return int Adjacent page id, or 0 if none.
*/
private function get_adjacent_page_id( WP_Post $page, int $direction ): int {
$args = [
'post_type' => $page->post_type,
'order' => 'ASC',
'orderby' => 'menu_order',
'post_parent' => $page->post_parent,
'fields' => 'ids',
'posts_per_page' => -1
];
$pages = get_posts( $args );
$current_key = array_search( $page->ID, $pages );
$does_adjacent_page_exist = isset( $pages[ $current_key + $direction ] );
if ( $does_adjacent_page_exist ) {
return $pages[ $current_key + $direction ];
}
return 0;
}
}
add_action( 'plugins_loaded', function() {
$pagination_fields = new PaginationFields();
$pagination_fields->register_hooks();
} );
Looking at the plugin from the call to add_action
at the bottom of the file:
add_action
: “Actions are the hooks that the WordPress core launches at specific points during execution, or when specific events occur. Plugins can specify that one or more of its PHP functions are executed at these points, using the Action API.”
'plugins_loaded'
: the hook that’s fired by WordPress once all activated plugins have loaded.
So once all the site’s plugins have loaded, the anonymous function that’s the second argument to the add_action
hook will be called. That function assigns an instance of the PaginationFields
class to $pagination_fields
.
// wpgraphql-previous-next.php
class PaginationFields {
public function register_hooks() {
add_action( 'graphql_register_types', [ $this, 'register_post_fields' ] );
}
...
That method is just saying that when the graphql_register_types
hook is called, run the PaginationFields::register_post_fields
method.
// wpgraphql-previous-next.php
...
public function register_post_fields() {
register_graphql_fields('Post', [
'previousPost' => [
'type' => 'Post',
'description' => __( 'Previous post', 'hwp-rockers' ),
'resolve' => function( Post $resolving_post ) {
if ( is_post_type_hierarchical( $resolving_post->post_type ) ) {
$previous_post_id = get_previous_page_id( $resolving_post );
return $previous_post_id ? new Post( $previous_post_id ) : null;
}
$post = get_post( $resolving_post->postId );
$GLOBALS['post'] = $post;
setup_postdata( $post );
$previous_post = get_previous_post();
wp_reset_postdata();
return $previous_post ? new Post( $previous_post ) : null;
}
],
'nextPost' => [
'type' => 'Post',
'description' => __( 'Next post', 'hwp-rockers' ),
'resolve' => function( Post $resolving_post ) {
if ( is_post_type_hierarchical( $resolving_post->post_type ) ) {
$next_post_id = get_next_page_id( $resolving_post );
return $next_post_id ? new Post( $next_post_id ) : null;
}
$post = get_post( $resolving_post->postId );
$GLOBALS['post'] = $post;
setup_postdata( $post );
$next_post = get_next_post();
wp_reset_postdata();
return $next_post ? new Post( $next_post ) : null;
}
],
]);
}
...
Going down the rabbit hole, register_posts_fields
calls the register_graphql_fields
method that’s defined in the WPGraphQL plugin:
// wpgraphql/access-functions.php
/**
* Given a Type Name and an array of field configs, this adds the fields to the registered type in
* the TypeRegistry
*
* @param string $type_name The name of the Type to add the fields to
* @param array<string,array<string,mixed>> $fields An array of field configs
*
* @return void
* @throws \Exception
* @since 0.1.0
*/
function register_graphql_fields( string $type_name, array $fields ) {
add_action(
get_graphql_register_action(),
static function ( TypeRegistry $type_registry ) use ( $type_name, $fields ) {
$type_registry->register_fields( $type_name, $fields );
},
10
);
}
The register_graphql_fields
method takes a Type Name and an array of field configuration values. Looking at the call to register_graphql_fields
in PaginationFields::register_post_fields
, it accepts an array of arrays as its second argument (previousPost
and nextPost
.)
I’m only going to trace this so far. I’ll assume that the call to $type_registry->register_type( $type_name, $config );
in the access-functions.php
file makes the newly registered type available to WPGraphQL queries.
One thing that slightly concerns me is that my IDE is warning me that the following functions in PaginationFields
are undefined:
get_previous_page_id
get_next_page_id
get_adjacent_page_id
Looking at the code, it seems those functions should be called with $this->method_name(
), for example $this->get_adjacent_page_id( $page, -1 );
I’m stumped by the resolve
function in register_post_fields
:
'resolve' => function( Post $resolving_post ) {
if ( is_post_type_hierarchical( $resolving_post->post_type ) ) {
$previous_post_id = $this->get_previous_page_id( $resolving_post );
return $previous_post_id ? new Post( $previous_post_id ) : null;
}
$post = get_post( $resolving_post->postId );
$GLOBALS['post'] = $post;
setup_postdata( $post );
$previous_post = get_previous_post();
wp_reset_postdata();
return $previous_post ? new Post( $previous_post ) : null;
}
Why is it calling new Post ( $previous_post_id )
?
I think it’s because Post
is referring to the use
statement at the top of the file and not the WP_Post
type, but $previous_post_id
?
…
There are a few things I’m not understanding in the code. I’ll leave it for now.
I’m going to try implementing navigation without extending the WPGraphQL plugin. If it doesn’t work, I’ll go through this tutorial: https://www.wpgraphql.com/docs/build-your-first-wpgraphql-extension, then see if I can write a plugin from scratch that returns the previous and next page information.
Getting previous and next posts from the cursor
The blog.$slug.tsx
route is currently getting posts with this query:
// app/models/wp_queries.tsx
...
query GetPostBySlug ($id: ID!) {
post(id: $id, idType: SLUG) {
id
title
content
excerpt
slug
date
author {
node {
name
}
}
featuredImage {
node {
altText
description
caption
id
sourceUrl
}
}
}
}
...
The same thing could be accomplished with a posts
query instead of a post
query:
// in the GraphiQL IDE
query GetPostBySlug ($slug: String!) {
posts(first: 1, where: {name: $slug}) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
databaseId
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
The condition on the query is posts(first: 1, where: {name: $slug, categoryName: $categorySlug})
. That means that only one post will be returned – the post that has the required slug
value. (Note that I’m assuming that post slugs on my site will always be distinct. I don’t think WordPress enforces uniqueness on post slugs. I’ll deal with that later.)
Given that only one post is returned by the query, posts.pageInfo.hasNextPage
and posts.pageInfo.hasPreviousPage
will always be false
. The values of posts.pageInfo.startCursor
, posts.pageInfo.endCursor
and posts.edges[0].cursor
will always be equal. That means that the pageInfo
part of the query can be removed:
// in the GraphiQL IDE
query GetPostBySlug ($slug: String!) {
posts(first: 1, where: {name: $slug}) {
edges {
cursor
node {
id
databaseId
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
The cursor that’s returned can be used to get the previous post:
// in the GraphiQL IDE
query GetPreviousPostByCursor ($previousCursor: String!, $categorySlug: String) {
posts(first: 1, before: $previousCursor, where: {categoryName: $categorySlug}) {
edges {
cursor
node {
id
databaseId
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
and the next post:
// in the GraphiQL IDE
query GetNextPostByCursor ($nextCursor: String!, $categorySlug: String) {
posts(first: 1, after: $nextCursor, where: {categoryName: $categorySlug}) {
edges {
cursor
node {
id
databaseId
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
I’m getting a bit ahead of myself here by adding an optional categorySlug
variable to the query. It will make it possible to find the previous and next posts within a category.
The issue I’m seeing with this approach is that it won’t provide the post titles or slugs. I’ll keep going with it anyway:
$ git checkout -b cursor_based_previous_next_post_navigation
Update POST_BY_SLUG_QUERY
The updated query:
// app/models/wp_queries.ts
...
export const POST_BY_SLUG_QUERY = gql(`
query GetPostBySlug ($slug: String!) {
posts(first: 1, where: {name: $slug}) {
edges {
cursor
node {
id
title
content
excerpt
slug
date
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
`);
...
Visiting the blog.$slug.tsx
route should now return an error:
graphQLErrors: [
{
message: 'Variable "$slug" of required type "String!" was not provided.',
extensions: [Object],
locations: [Array]
}
],
protocolErrors: [],
clientErrors: [],
networkError: null,
extraInfo: undefined
It’s displayed on the page too. Nice, but I’m not sure I want to expose this level of detail to the site’s users:
That was easy:
// app/routes/blog.$slug.tsx
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
invariant(params.slug, "params.slug is required");
const slug = params.slug;
const client = createApolloClient();
const response = await client.query({
query: POST_BY_SLUG_QUERY,
variables: {
slug: slug, // changed id to slug
},
});
Now I’m getting an error that I didn’t expect:
oh, that was expected, I just thought it would be trigger later
// app/routes/blog.$slug.tsx
...
if (response.errors || !response?.data?.post) {
throw new Error(`Post not found for slug: ${slug}`);
}
...
I think that error condition is too broad. It’s making it seem that nothing was returned by the query. It should be limited to checking for response.errors
.
This is the actual error:
There’s a formatting issue in the ErrorBoundary
message. Anyway, this is what’s triggering the error:
// app/routes/blog.$slug.tsx
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
...
return response?.data?.post;
};
With the updated query, the post data is at response.data.posts.edges[0].node
.
This should work:
// app/routes/blog.$slug.tsx
export const loader = async ({ params }: LoaderFunctionArgs) => {
...
const post = response?.data?.posts?.edges?.[0]?.node ?? null;
const cursor = response?.data?.posts?.edges?.[0]?.cursor ?? null;
console.log(`post: ${JSON.stringify(post, null, 2)}`);
console.log(`cursor: ${cursor}`);
return []; // return an empty array for now
};
Success!
GET /blog/exploring-nanaimo-a-mountain-bikers-travel-guide?_data=routes%2Fblog.%24slug - - - - ms
post: {
"__typename": "Post",
"id": "cG9zdDoxMjI=",
"title": "Exploring Nanaimo: A Mountain Biker’s Travel Guide",
"content": "\n<p>Mountain biking in Nanaimo offers an exhilarating blend of adventure and natural beauty. Nestled on Vancouver Island, Nanaimo’s diverse landscapes provide the perfect backdrop for mountain biking enthusiasts. From challenging technical trails and steep descents to scenic forest routes and coastal paths, there’s something for riders of all levels. The area is renowned for its well-maintained trails, community-driven maintenance, and commitment to sustainable riding practices.</p>\n\n\n\n<p>Artists who are drawn to the outdoors will find mountain biking in Nanaimo a thrilling way to explore and connect with nature. The physical challenge of navigating trails offers a unique perspective on the landscape, inspiring creativity and providing a refreshing break from the studio. Mountain biking in this beautiful region is not just about the sport; it’s about experiencing the vibrant ecosystems and stunning vistas that make Nanaimo a special place for adventurers and artists alike.</p>\n",
"excerpt": "<p>Mountain biking in Nanaimo offers an exhilarating blend of adventure and natural beauty. Nestled on Vancouver Island, Nanaimo’s diverse landscapes provide the perfect backdrop for mountain biking enthusiasts. From challenging technical trails and steep descents to scenic forest routes and coastal paths, there’s something for riders of all levels. The area is renowned for its […]</p>\n",
"slug": "exploring-nanaimo-a-mountain-bikers-travel-guide",
"date": "2024-02-25T01:34:44",
"author": {
"__typename": "NodeWithAuthorToUserConnectionEdge",
"node": {
"__typename": "User",
"name": "scossar"
}
},
"featuredImage": {
"__typename": "NodeWithFeaturedImageToMediaItemConnectionEdge",
"node": {
"__typename": "MediaItem",
"altText": "South Nanaimo",
"sourceUrl": "http://wp-discourse.test/wp-content/uploads/2024/02/20230409_173035-scaled.jpg"
}
}
}
I’ll clean up the assignment and add some error handling:
// app/routes/blog.$slug.tsx
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
invariant(params.slug, "params.slug is required");
const slug = params.slug;
const client = createApolloClient();
const response = await client.query({
query: POST_BY_SLUG_QUERY,
variables: {
slug: slug,
},
});
if (response.errors) {
throw new Error(`An error was returned from the server`);
}
const firstPostEdge = response?.data?.posts?.edges?.[0] ?? null;
const post = firstPostEdge?.node ?? null;
if (!firstPostEdge || !post) {
throw new Error(`No post data was returned for ${slug}`);
}
const cursor = firstPostEdge?.cursor ?? null;
return json({ post: post, cursor: cursor });
};
export default function BlogPost() {
const { post, cursor } = useLoaderData<typeof loader>();
...
Great, except:
The problem was here:
// app/routes/blog.$slug.tsx
...
export const meta: MetaFunction = ({ data }) => {
const post = data as Post;
// Without this condition, errors in the loader function will cause an unhandled
// error in the meta function.
if (!post || !post?.title) {
return [
{ title: "Error Page" },
{ description: "An error occurred while loading the post." },
];
}
That worked when the loader
function was returning a Post
object. The loader
function is now returning json: ({ post, cursor })
I can get this to work with:
// app/routes/blog.$slug.tsx
...
interface LoaderData {
post: Post;
cursor: string | null;
}
export const meta: MetaFunction = ({ data }) => {
const { post } = data as LoaderData;
// Without this condition, errors in the loader function will cause an unhandled
// error in the meta function.
if (!post || !post?.title) {
return [
{ title: "Error Page" },
{ description: "An error occurred while loading the post." },
];
}
...
This points to an issue with using type assertions (as Post
or as LoaderData
.) I’ll leave a “todo” comment in the code to figure out how to remove the assertion.
Use the cursor
value to create previous and next post links
What if there isn’t a previous or next post? Also, what route would the posts load on?
… one hour later
I can deal with both of those issues. The solution isn’t complicated, but it adds two additional requests to the route’s loader
function.
Here are the new queries (it would be technically possible to have a single query cover both cases, but this is easier to understand):
// app/models/wp_queries.ts
...
export const PREVIOUS_POST_FOR_CURSOR = gql(`
query GetPreviousPostForCursor ($before: String!, $categorySlug: String) {
posts(last: 1, before: $before, where: {categoryName: $categorySlug}) {
edges {
node {
id
title
date
slug
}
}
}
}
`);
export const NEXT_POST_FOR_CURSOR = gql(`
query GetNextPostForCursor ($after: String!, $categorySlug: String) {
posts(first: 1, after: $after, where: {categoryName: $categorySlug}) {
edges {
node {
id
title
date
slug
}
}
}
}
`);
...
Use the cursor
returned from the POST_BY_SLUG_QUERY
to get previous and next post slugs and titles (if they exist):
// app/routes/blog.$slug.tsx
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
...
const cursor = firstPostEdge?.cursor ?? null;
let previousTitle, previousSlug, nextTitle, nextSlug;
if (cursor) {
const previousPostResponse = await client.query({
query: PREVIOUS_POST_FOR_CURSOR,
variables: {
before: cursor,
},
});
if (previousPostResponse.errors) {
throw new Error(
"An error was returned when querying for the previous post"
);
}
const previousPostEdge =
previousPostResponse?.data?.posts?.edges?.[0] ?? null;
if (previousPostEdge) {
previousTitle = previousPostEdge?.node?.title ?? null;
previousSlug = previousPostEdge?.node?.slug ?? null;
}
const nextPostResponse = await client.query({
query: NEXT_POST_FOR_CURSOR,
variables: {
after: cursor,
},
});
if (nextPostResponse.errors) {
throw new Error("An error was returned when querying for the next post");
}
const nextPostEdge = nextPostResponse?.data?.posts?.edges?.[0] ?? null;
if (nextPostEdge) {
nextTitle = nextPostEdge?.node?.title ?? null;
nextSlug = nextPostEdge?.node?.slug ?? null;
}
}
return json({
post: post,
previousTitle: previousTitle,
previousSlug: previousSlug,
nextTitle: nextTitle,
nextSlug: nextSlug,
});
};
Then use that data to create the links:
// app/routes/blog.$slug.tsx
...
export default function BlogPost() {
const { post, previousTitle, previousSlug, nextTitle, nextSlug } =
useLoaderData<typeof loader>();
...
<div className="my-3 flex justify-center items-center h-full">
{previousTitle && previousSlug ? (
<Link prefetch="intent" to={`/blog/${previousSlug}`}>
{previousTitle}
</Link>
) : null}
{nextTitle && nextSlug ? (
<Link prefetch="intent" to={`/blog/${nextSlug}`}>
{nextTitle}
</Link>
) : null}
</div>
</div>
);
}
Adding some style to the links. (This isn’t perfect):
// app/routes/blog.$slug.tsx
...
<div className="my-3 grid grid-cols-1 min-[431px]:grid-cols-2 gap-4 items-center h-full">
{previousTitle && previousSlug ? (
<div>
<div>
<span className="text-5xl">←</span>
<span>Previous</span>
</div>
<Link
prefetch="intent"
to={`/blog/${previousSlug}`}
className="text-lg font-bold text-sky-700 hover:underline"
>
{previousTitle}
</Link>
</div>
) : null}
{nextTitle && nextSlug ? (
<div className="min-[431px]:text-right">
<div>
<span>Next</span>
<span className="text-5xl">→</span>
</div>
<Link
prefetch="intent"
to={`/blog/${nextSlug}`}
className="text-lg font-bold text-sky-700 hover:underline"
>
{nextTitle}
</Link>
</div>
) : null}
</div>
...
Success?
This accomplishes what I was trying to do, but the extra API requests that are being made in the loader
function bother me. I’ll try something else tomorrow.
(Reminding here that this version of pagination is on the cursor_based_previous_next_post_navigation
branch. I’m going to checkout the main
branch now.)