❗ This is not a tutorial ❗
With pagiation out of the way (https://zalgorithm.com/loading-more-posts-from-an-archive-route/, https://zalgorithm.com/pagination-take-two/, https://zalgorithm.com/pagination-take-three/, https://zalgorithm.com/pagination-take-four/, https://zalgorithm.com/pagination-take-five-success/) it’s time to clean up the home page. Instead of displaying the 5 most recent posts at the top of the page, I want to display the site’s “featured” posts. A featured post will be a post that has the “featured” tag.
In the past I’ve used post meta data for this kind of thing. Meta queries can be expensive though:
Meta Queries can be expensive and have been known to actually take sites down, which is why they are not part of the core WPGraphQL plugin.
If you need meta queries for your WPGraphQL system, this plugin enables them, but use with caution. It might be better to hook into WPGraphQL and define specific meta queries that you know you need and are not going to take your system down instead of allowing just any meta_query via this plugin, but you could use this plugin as an example of how to hook into WPGraphQL to add inputs and map those inputs to the WP_Query that gets executed.
https://github.com/wp-graphql/wp-graphql-meta-query?tab=readme-ov-file#why-is-this-an-extension-and-not-part-of-wpgraphql
Mark some posts as featured
That’ll do the trick.
I’ll add the “featured” tag to some posts on my development site as well.
Query for the featured tag
query getHomepagePosts {
tags (where: {name: "featured"}) {
edges {
node {
posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
edges {
node {
id
title
slug
excerpt
date
author {
node {
name
}
}
categories {
nodes {
name
}
}
featuredImage {
node {
caption
description
id
sourceUrl
}
}
}
}
}
}
}
}
categories {
edges {
node {
name
posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
edges {
node {
id
title
slug
excerpt
date
author {
node {
name
}
}
featuredImage {
node {
caption
description
id
sourceUrl
}
}
}
}
}
}
}
}
}
That’s a modified version of the existing INDEX_PAGE_POSTS_QUERY
. I’m hard coding “featured” into the request for now. Eventually I’ll add an admin page and settings section to the app so that the code is reusable for other sites.
With the updated query, loading the blog’s index page (/blog
) should trigger some kind of error:
TypeError: Cannot read properties of undefined (reading 'map')
at BlogIndex (file:///home/scossar/remix/starting-over/hello_world/app/routes/blog._index.tsx:49:27)
The cause of the error:
// app/routes/blog._index.tsx
...
const data = response?.data;
const latestPostsEdges = data?.posts?.edges;
...
export default function BlogIndex() {
const { latestPostsEdges, categoryEdges } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
Latest Posts
</h2>
<hr className="mt-2 mb-3 border-solid border-slate-900" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{latestPostsEdges.map((edge: PostConnectionEdge) => (
...
The ErrorBoundary
did it’s job, but maybe there should be a condition in the loader
function that catches those types of errors earlier?
Here’s the fix:
// app/routes/blog._index.tsx
import { json, MetaFunction } from "@remix-run/node";
import { Link, useRouteError, useLoaderData } from "@remix-run/react";
import { createApolloClient } from "lib/createApolloClient";
import type {
RootQueryToCategoryConnectionEdge,
PostConnectionEdge,
TagToPostConnectionEdge,
} from "~/graphql/__generated__/graphql";
import { INDEX_PAGE_POSTS_QUERY } from "~/models/wp_queries";
import PostExcerptCard from "~/components/PostExcerptCard";
export const meta: MetaFunction = () => {
return [
{ title: "Zalgorithm" },
{ name: "description", content: "Simon's blog" },
];
};
export async function loader() {
const client = createApolloClient();
const response = await client.query({
query: INDEX_PAGE_POSTS_QUERY,
});
if (response.errors) {
throw new Error("Unable to load posts.");
}
const data = response?.data;
const featuredPosts = data?.tags?.edges?.[0]?.node?.posts?.edges ?? [];
const categoryEdges = data?.categories?.edges ?? [];
if (featuredPosts.length === 0 && categoryEdges.length === 0) {
throw new Error("No posts were returned for the homepage.");
}
return json({
featuredPosts: featuredPosts,
categoryEdges: categoryEdges,
});
}
export default function BlogIndex() {
const { featuredPosts, categoryEdges } = useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold text-center">
Simon's Blog
</h2>
<hr className="mt-2 mb-3 border-solid border-slate-900" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{featuredPosts.map((postEdge: TagToPostConnectionEdge) => (
<PostExcerptCard
title={postEdge.node?.title}
date={postEdge.node?.date}
featuredImage={postEdge.node?.featuredImage?.node?.sourceUrl}
excerpt={postEdge.node?.excerpt}
authorName={postEdge.node.author?.node?.name}
slug={postEdge.node?.slug}
key={postEdge.node.id}
/>
))}
</div>
<Link
className="text-2xl text-sky-700 font-medium hover:underline pt-3 block text-center"
prefetch="intent"
to="archive"
>
Read all Posts
</Link>
<hr className="mt-2 mb-2 border-solid border-slate-900" />
{categoryEdges.map((categoryEdge: RootQueryToCategoryConnectionEdge) => (
<div key={categoryEdge.node.name}>
<h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
{categoryEdge.node.name}
</h2>
<hr className="mt-2 mb-3 border-solid border-slate-900" />
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{categoryEdge.node?.posts?.edges.map(
(postEdge: PostConnectionEdge) => (
<PostExcerptCard
title={postEdge.node?.title}
date={postEdge.node?.date}
featuredImage={postEdge.node?.featuredImage?.node?.sourceUrl}
excerpt={postEdge.node?.excerpt}
authorName={postEdge.node.author?.node?.name}
slug={postEdge.node?.slug}
key={postEdge.node.id}
/>
)
)}
</div>
</div>
))}
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return (
<div className="mx-auto max-w-2xl px-20 py-4 my-10 bg-red-200 border-2 border-red-700 rounded">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
);
}
This is a new pattern for me:
const featuredPosts = data?.tags?.edges?.[0]?.node?.posts?.edges ?? [];
It’s using the nullish coalescing operator (??
). I’ve been assigning variables like this:
const featuredPosts = data?.tags?.edges?.[0]?.node?.posts?.edges
? data.tags.edges[0].node.posts.edges
: [];
The old version was safe, but overly complex.
I’ve added a condition to the loader to return an error if there are no featured posts and no category posts returned by the query:
if (featuredPosts.length === 0 && categoryEdges.length === 0) {
throw new Error("No posts were returned for the homepage.");
}
The UI is assuming there will be both featured posts and category posts though. I’ll deal with that later.
I should note that this is the sleepiest programming I’ve done in a while. I was stuck for a long time on why I couldn’t iterate through data.tags.edges.node
. It’s because node
is an object
, not an array
.
Category archive routes
While I’m cleaning up the blog’s homepage, I’d like to add archive routes for each category that’s listed on the page. I’ll give it a shot now. I’ll get some sleep and come back to it tomorrow if it doesn’t go smoothly.
$ git checkout -b add_category_archive_routes
I’ll start by adding an optional segment (https://remix.run/docs/en/main/file-conventions/routes#optional-segments) to the blog.archive.tsx
route:
// the escaping seems to be necessary here
$ mv app/routes/blog.archive.tsx app/routes/blog.\(\$categorySlug\).archive.tsx
If I’m understanding things correctly, the blog’s archive page should still load at /blog/archive
…
And it does 🙂
With no changes made to handle the optional path segment, I think the archive page will also load at /blog/anystring/archive
.
That also works!
I can capture the categorySlug
in the blog.($categorySlug).archive.tsx
loader:
// app/routes/blog.($categorySlug).archive.tsx
...
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const { searchParams } = new URL(request.url);
const categorySlug = params?.categorySlug ?? null;
console.log(`categorySlug: ${categorySlug}`);
...
}
// returns "categorySlug: anystring"
Links from the index page to category archive pages are going to need the category slug. I’ll add it to the INDEX_PAGE_POSTS_QUERY
:
// models/wp_queries.tsx
...
export const INDEX_PAGE_POSTS_QUERY = gql(`
query getHomepagePosts {
tags (where: {name: "featured"}) {
...
categories {
edges {
node {
name
slug
posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
edges {
node {
id
title
slug
excerpt
date
author {
node {
name
}
}
featuredImage {
node {
caption
description
id
sourceUrl
}
}
}
}
}
}
}
}
}
`);
Add the category archive links to the index page:
// app/routes/blog._index.tsx
...
export default function BlogIndex() {
const { featuredPosts, categoryEdges } = useLoaderData<typeof loader>();
...
<div className="my-3 flex justify-center items-center h-full">
<Link
to={`${categoryEdge.node.slug}/archive`}
className="text-2xl text-sky-700 font-medium hover:underline pt-3"
>
Read all {categoryEdge.node.name} posts
</Link>
</div>
</div>
))}
</div>
);
}
...
Clicking the category links on the index page now passes the category slug to the blog.($categorySlug).archive.tsx
loader
function.
The GraphiQL IDE surfaced this bit of helpful information:
I can use the category slug
and not its name
. That makes things easy.
Update the ARCHIVE_QUERY
to accept an optional categorySlug
variable:
// models/wp_queries.ts
...
export const ARCHIVE_QUERY = gql(`
query ArchiveQuery (
$first: Int
$last: Int
$after: String
$before: String
$categorySlug: String
) {
posts(first: $first, last: $last, after: $after, before: $before, where: {categoryName: $categorySlug}) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
databaseId
title
date
slug
excerpt
author {
node {
name
}
}
featuredImage {
node {
altText
sourceUrl
}
}
}
}
}
}
`);
...
Update the loader
function to add the categorySlug
param to the its query’s variables:
// app/routes/blog.($categorySlug).archive.tsx
...
interface ArchiveQueryVariables {
first: Maybe<number>;
last: Maybe<number>;
after: Maybe<string>;
before: Maybe<string>;
categorySlug: Maybe<string>;
}
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const { searchParams } = new URL(request.url);
const categorySlug = params?.categorySlug ?? null;
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,
categorySlug: null,
};
if (categorySlug) {
queryVariables.categorySlug = categorySlug;
}
...
This is working 🙂
I’d like to display the category name in the heading for each archive category. The loader
doesn’t have access to the name
, it’s only got the slug
. I should be able to deal with that.
Oops!
I’d called toUpperCase
with the parenthesis. This fixes it:
let categoryName;
if (categorySlug) {
categoryName = categorySlug
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
That’s enough for tonight:
$ git add .
$ git commit -m "add category archive route; do some css cleanup"
$ git checkout main
$ git merge add_category_archive_routes
Throwing caution to the wind:
$ git push origin main
$ git push live main
I’m wondering what path should be used when a post from an archive route is selected. Currently, the blog.$slug
route is used. That’s for another day…
The changes are live here: https://hello.zalgorithm.com/blog.
The code is here: https://github.com/scossar/wp-remix.