❗ This is not a tutorial ❗
Reading https://www.wpgraphql.com/2020/03/26/forward-and-backward-pagination-with-wpgraphql, I realized the problem I’m having with pagination is that I’m trying to implement page based pagination but WPGraphQL has been optimized for cursor based pagination. The best work around I’ve found is to store the cursor
data in a PaginationCursors
table in the app’s database. Note, I haven’t implemented this:
Table: PaginationCursors
- id (Primary Key)
- archiveType (e.g., 'all', 'category', 'tag')
- archiveId (Nullable, can store categoryId or tagId depending on the archiveType)
- pageNumber (The page number in the pagination sequence)
- cursor (The actual cursor value)
- createdAt (Timestamp to track when the cursor was stored)
- updatedAt (Timestamp to track when the cursor was last updated)
This would technically work, but it’s overly complex. The article I linked to makes a good case against page based pagination…
I’m going to try adding cursor based “Previous” and “Next” buttons to the app’s archive page.
Cursor based pagination (take two)
I’ll start this on a new branch:
$ git checkout -b cursor_based_pagination
The app’s existing routes, with the pagination UI rendering on the blog.archive.tsx
route and the pagination results rendering on blog.archive._index.tsx
, should be able to stay as they are. Paginator
is also a good enough name for the updated pagination component. I’ll make copies of all these files for later reference:
$ cp app/routes/blog.archive.tsx app/routes/blog.archiveBak.tsx
$ cp app/routes/blog.archive._index.tsx app/routes/blog.archiveBak._index.tsx
$ cp app/components/Paginator.tsx app/components/PaginatorBak.tsx
Copying files in this way isn’t a “best practice.”
Get cursor data and post details in a single query
This query from the WPGraphQL pagination blog post is useful:
query GET_PAGINATED_POSTS(
$first: Int
$last: Int
$after: String
$before: String
) {
posts(first: $first, last: $last, after: $after, before: $before) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
postId
title
}
}
}
}
Loading the query into the GraphiQL IDE and hovering over the variable names reveals descriptions of what each variable does:
first
: the number of items to return after the referenced “after” cursorlast
: the number of items to return after the referenced “before” cursorafter
: cursor used along with the “first” argument to reference where in the dataset to get databefore
: cursor used along with the “last” argument to reference where in the dataset to get data
The IDE also provides details about each property that’s returned by the query. For pagination, I’m interested in:
hasNextPage
: when paginating forward, are there more items?hasPreviousPage
: when paginating backwards, are there more items?startCursor
: when paginating backwards, the cursor to continue (to use as the value of the next query’sbefore
argument.)endCursor
: when paginating forwards, the cursor to continue (to use as the value of the next query’safter
argument.)
Note that an individual post’s cursor
can be returned in the RootQueryToPostConnectionEdge
results:
but for creating the “Previous” and “Next” UI elements, it seems that pageInfo.startCursor
and pageInfo.endCursor
will provide all the necessary information.
Also note that postId
is deprecated in favour of databaseId
.
The GraphiQL IDE is super useful. Thanks to whoever made it!
This query gets the details required to create a pagination “page” and a page’s pagination UI with a single request:
// app/models/wp_queries.ts
...
// todo: consider renaming this:
export const ARCHIVE_QUERY = gql(`
query ArchiveQuery (
$first: Int
$last: Int
$after: String
$before: String
) {
posts(first: $first, last: $last, after: $after, before: $before) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
databaseId
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
`);
...
Pull in data with the ARCHIVE_QUERY
Querying for the first three posts:
// app/routes/blog.archive.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { Outlet, useLoaderData, useRouteError } from "@remix-run/react";
import { createApolloClient } from "lib/createApolloClient";
import { ARCHIVE_QUERY } from "~/models/wp_queries";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get("cursor") || null;
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_QUERY,
variables: {
first: 3,
after: cursor,
},
});
// note: the case of `response.data.posts.edges` being empty should be handled by the UI
if (response.errors || !response?.data?.posts?.pageInfo) {
throw new Error("An error was returned loading the posts.");
}
// data for the Paginator component:
const pageInfo = response.data.posts.pageInfo;
// data for the PostExcerptCard components:
// if no RootQueryToPostConnectionEdge objects are returned, deal with it in the UI?
const postConnectionEdges = response.data.posts?.edges || [];
return json({ pageInfo: pageInfo, postConnectionEdges: postConnectionEdges });
};
export default function Archive() {
const { pageInfo, postConnectionEdges } = useLoaderData<typeof loader>();
console.log(`pageInfo: ${JSON.stringify(pageInfo, null, 2)}`);
console.log(
`postConnectionEdges: ${JSON.stringify(postConnectionEdges, null, 2)}`
);
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl py-3">Post Archive</h2>
<Outlet />
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return (
<div className="mx-auto max-w-3xl px-5 py-4 my-10 bg-red-200 border-2 border-red-700 rounded break-all">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
);
}
Here’s the value of pageInfo
:
pageInfo: {
"__typename": "RootQueryToPostConnectionPageInfo",
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "YXJyYXljb25uZWN0aW9uOjE0Ng==",
"endCursor": "YXJyYXljb25uZWN0aW9uOjEyNA=="
}
That data can be used to tell the Paginator
component to create a clickable “Next” link ("hasNextPage": true
) with a cursor
(or endCursor
) URL param set to "YXJyYXljb25uZWN0aW9uOjEyNA=="
("endCursor": "YXJyYXljb25uZWN0aW9uOjEyNA=="
.) It can also tell the component to not create a clickable “Previous” link ("hasPreviousPage": false
.)
Here’s where the updated Paginator
component is at:
// app/components/Paginator.tsx
import type { RootQueryToPostConnectionPageInfo } from "~/graphql/__generated__/graphql";
// todo: left over from previous implementation
// delete this file if it doesn't get used:
//import { Page } from "~/types/Page";
interface PaginatorProps {
pageInfo: RootQueryToPostConnectionPageInfo;
}
export default function Paginator({ pageInfo }: PaginatorProps) {
const hasNextPage = pageInfo.hasNextPage;
const hasPreviousPage = pageInfo.hasPreviousPage;
const startCursor = pageInfo.startCursor;
const endCursor = pageInfo.endCursor;
console.log(`hasNextPage: ${hasNextPage}`);
console.log(`hasPreviousPage: ${hasPreviousPage}`);
console.log(`startCursor: ${startCursor}`);
console.log(`endCursor: ${endCursor}`);
return (
<div>
<p className="text-3xl">Paginator Component</p>
</div>
);
}
It’s included on blog.archive.tsx
like this:
// app/routes/blog.archive.tsx
...
export default function Archive() {
const { pageInfo, postConnectionEdges } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl py-3">Post Archive</h2>
<Outlet />
<Paginator pageInfo={pageInfo} />
</div>
);
}
...
With the current route structure, visiting /blog/archive
runs the blog.archive
loader function, renders the Paginator
component, then renders the blog.archive._index
.tsx component in the blog.archive.tsx
outlet. This might be wrong, but I’ll deal with that later.
Using the RootQueryToPostConnectionPageInfo
prop in the Paginator
component:
// app/components/Paginator.tsx
import { Link } from "@remix-run/react";
import type { RootQueryToPostConnectionPageInfo } from "~/graphql/__generated__/graphql";
// todo: left over from previous implementation
// delete this file if it doesn't get used:
//import { Page } from "~/types/Page";
interface PaginatorProps {
pageInfo: RootQueryToPostConnectionPageInfo;
}
export default function Paginator({ pageInfo }: PaginatorProps) {
const hasNextPage = pageInfo.hasNextPage;
const hasPreviousPage = pageInfo.hasPreviousPage;
const startCursor = pageInfo.startCursor;
const endCursor = pageInfo.endCursor;
return (
<div>
{hasPreviousPage && startCursor ? (
<Link to={`?hasPreviousPage=true&startCursor=${startCursor}`}>
Previous
</Link>
) : (
""
)}
{hasNextPage && endCursor ? (
<Link to={`?hasNextPage=true&endCursor=${endCursor}`}>Next</Link>
) : (
""
)}
</div>
);
}
I like the way things are getting simplified. The next trick is to figure out how to render the posts. Maybe the blog.archive._index.tsx
route can be removed?
This is getting silly, but it’s for a quick test:
$ mv app/routes/blog.archive._index.tsx app/routes/blog.archiveBakTmp._index.tsx
With the _index
route removed, I’ll try getting the Paginator
search params in the blog.archive.tsx
loader:
And just like that it works! This seems like a big improvement from yesterday’s code:
// app/routes/blog.archive.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useRouteError } from "@remix-run/react";
import { createApolloClient } from "lib/createApolloClient";
import { ARCHIVE_QUERY } from "~/models/wp_queries";
import Paginator from "~/components/Paginator";
import { Maybe } from "graphql/jsutils/Maybe";
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";
import PostExcerptCard from "~/components/PostExcerptCard";
interface ArchiveQueryVariables {
first: Maybe<number>;
last: Maybe<number>;
after: Maybe<string>;
before: Maybe<string>;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { searchParams } = new URL(request.url);
const startCursor = searchParams.get("startCursor") ?? null;
const endCursor = searchParams.get("endCursor") ?? null;
const chunkSize = Number(process.env?.ARCHIVE_CHUNK_SIZE) || 15;
let queryVariables: ArchiveQueryVariables = {
first: null,
last: null,
after: null,
before: null,
};
if (endCursor) {
queryVariables.first = chunkSize;
queryVariables.after = endCursor;
} else if (startCursor) {
queryVariables.last = chunkSize;
queryVariables.before = startCursor;
} else {
queryVariables.first = chunkSize;
}
const client = createApolloClient();
const response = await client.query({
query: ARCHIVE_QUERY,
variables: queryVariables,
});
// note: the case of `response.data.posts.edges` being empty should be handled by the UI
if (response.errors || !response?.data?.posts?.pageInfo) {
throw new Error("An error was returned loading the posts.");
}
// data for the Paginator component:
const pageInfo = response.data.posts.pageInfo;
// data for the PostExcerptCard components:
// if no RootQueryToPostConnectionEdge objects are returned, deal with it in the UI?
const postConnectionEdges = response.data.posts?.edges || [];
return json({ pageInfo: pageInfo, postConnectionEdges: postConnectionEdges });
};
export default function Archive() {
const { pageInfo, postConnectionEdges } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl py-3">Post Archive</h2>
<div className="px-6 mx-auto max-w-screen-lg">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{postConnectionEdges.map((edge: PostConnectionEdge) => (
<PostExcerptCard
title={edge.node?.title}
date={edge.node?.date}
featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
excerpt={edge.node?.excerpt}
authorName={edge.node?.author?.node?.name}
slug={edge.node?.slug}
key={edge.node.id}
/>
))}
</div>
</div>
<div className="my-3 flex justify-center items-center h-full ">
<Paginator pageInfo={pageInfo} />
</div>
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return (
<div className="mx-auto max-w-3xl px-5 py-4 my-10 bg-red-200 border-2 border-red-700 rounded break-all">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
);
}
Note that this is simplified from the version I posted above. There’s no need to set hasPreviousPage=true
or hasNextPage=true
URL params on the links:
// app/components/Paginator.tsx
import { Link } from "@remix-run/react";
import type { RootQueryToPostConnectionPageInfo } from "~/graphql/__generated__/graphql";
interface PaginatorProps {
pageInfo: RootQueryToPostConnectionPageInfo;
}
export default function Paginator({ pageInfo }: PaginatorProps) {
const { hasNextPage, hasPreviousPage, startCursor, endCursor } = pageInfo;
return (
<div>
{hasPreviousPage && startCursor ? (
<Link to={`?startCursor=${startCursor}`}>Previous</Link>
) : (
""
)}
{hasNextPage && endCursor ? (
<Link to={`?endCursor=${endCursor}`}>Next</Link>
) : (
""
)}
</div>
);
}
It took five days, but I’m glad I stuck with it. All the previous versions worked, but felt off in one way or another.
$ git add .
$ git commit -m "it works!"
$ git checkout main
$ git merge cursor_based_pagination
I cleaned up the styles a bit and pushed the changes to the live site: https://hello.zalgorithm.com/blog/archive.
Next on the list is to display “featured posts” instead of “latest posts” in the top section of https://hello.zalgorithm.com/blog. Then add archive pages for each of the WordPress site’s categories. I think that can be accomplished by adding an optional $category
segment to the blog.archive
route (blog.($category).archive.tsx
): https://remix.run/docs/en/main/file-conventions/routes#optional-segments.