Link to the previous and next posts

❗ 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&#8217;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&#8217;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&#8217;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&#8217;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&#8217;s something for riders of all levels. The area is renowned for its [&hellip;]</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">&larr;</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">&rarr;</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.)