❗ This is not a tutorial ❗
The goal of this project was to display the text “Hello World!” at https://hello.zalgorithm.com
. It somehow morphed into creating a Remix client application that consumes a headless WordPress site. I’ve been working without much of a plan. That’s starting to show in the application’s URL structure:
/
: the app’s homepage/blog
: the base of the WordPress client/blog/$slug
: displays a single WordPress post/blog/($categorySlug)/archive
: resolves to either/blog/archive
or/blog/$categorySlug/archive
. It display a listing of either all the site’s posts (/blog/archive
) or all of a category’s posts (/blog/$categorySlug/archive
)
Here’s the structure I’d like to use:
/
: the base path of the Remix app (as it is now)/blog
: the base path of the Remix WordPress site. Displays the blog’s home page (as it is now)/blog/$postId/$slug
: the (main) path, and canonical URL for individual posts (currently it’s/blog/$slug
, without the ID)/blog/posts
: the “archive” page of the WordPress client (currently this is/blog/archive
)/blog/category/$categorySlug
: the archive page for each WordPress category. (currently it’s/blog/$categorySlug/archive
)/blog/category/$categorySlug/$postId/$slug
: the (non canonical) path for a category’s individual posts. This will allow for “previous/next” post navigation within a category
The structure needs to allow for the possible addition of a tags
route (/blog/tag/$tagName
) and also for /tags
and /categories
routes that are index pages for the WordPress site’s tags and categories. (Currently the blog homepage is functioning as the categories archive page, but I don’t want to be forced to display all categories on the home page.)
I’m not sure where to start… I guess with this:
$ git checkout -b update_route_configuration
Add $postID
to the post route
(Do I really need to do this? WordPress enforces uniqueness on slugs for posts with the same post_type
, but it’s not clear to me how uniqueness is handled across different post types. Uniqueness is definitely enforced on the ID of all posts of all types, also slugs can be edited on WordPress…)
The significant part of the change is that I want to query for posts from the WordPress database by their post ID, not their slug. I’ll start by making $postId
an optional route parameter. Once it’s working I’ll remove the brackets from the ($postId
) part of the file name
$ mv app/routes/blog.\$slug.tsx app/routes/blog.\(\$postId\).\$slug.tsx
Posts should still load at /blog/$slug
:
I’m guessing that posts will also now load at /blog/$anyStringButArchive/$slug
, with $anyStringButArchive` being any string other than ‘archive’.
That guess was partly correct. Posts will load at /blog/$anyString/$slug
with $anyString
being any string. The string ‘archive’ only gets passed to the current archive
route if /archive
is at the end of the path. That makes sense.
The $postId
parameter can be captured like this:
// app/routes/blog.($postId).$slug.tsx
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
invariant(params.slug, "params.slug is required");
const postId = params?.postId ?? null;
console.log(`postId: ${postId}`);
Visiting http://localhost:3000/blog/124/eco-friendly-mountain-biking-preserving-nanaimos-natural-beauty
:
postId: 124
GET /blog/124/eco-friendly-mountain-biking-preserving-nanaimos-natural-beauty 200 - - 81.794 ms
The route’s loader
function is currently running a query that finds WordPress posts from their slug. I’ll update the query to use the databaseId
:
// app/models/wp_queries.ts
...
export const POST_BY_ID_QUERY = gql(`
query GetPostById ($id: ID!) {
post(id: $id, idType: DATABASE_ID) {
title
content
excerpt
slug
databaseId
date
author {
node {
name
}
}
featuredImage {
node {
altText
description
caption
id
sourceUrl
}
}
previousPost {
title
slug
databaseId
}
nextPost {
title
slug
databaseId
}
}
}
`);
...
Individual posts can be loaded on the site by clicking the link in the PostExcerptCard
component. I’ll update that component to add databaseId
to its props and to the links that it generates:
// app/components/PostExcerptCard.tsx
import { Link } from "@remix-run/react";
import { Maybe } from "graphql/jsutils/Maybe";
interface PostExcerptCardProps {
date: Maybe<string | null>;
featuredImage: Maybe<string | null>;
title: Maybe<string | null>;
excerpt: Maybe<string | null>;
authorName: Maybe<string | null>;
slug: Maybe<string | null>;
databaseId: number;
}
export default function PostExcerptCard({
date,
featuredImage,
title,
excerpt,
authorName,
slug,
databaseId,
}: PostExcerptCardProps) {
const formattedDate = date
? `${new Date(date).getFullYear()}-${String(
new Date(date).getMonth() + 1
).padStart(2, "0")}-${String(new Date(date).getDate()).padStart(2, "0")}`
: null;
return (
<article>
{featuredImage ? <img className="rounded-md" src={featuredImage} /> : ""}
<Link prefetch="intent" to={`/blog/${databaseId}/${slug}`}>
<h3 className="text-xl font-serif font-bold mt-3 text-sky-700 hover:underline">
{title}
</h3>
</Link>
...
And update PostExcerptCard
components in the route files to pass databaseId
as a parameter:
// app/routes/blog._index.tsx
...
{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}
databaseId={postEdge.node.databaseId}
key={postEdge.node.databaseId}
/>
))}
...
Oh:
I’ll update the INDEX_PAGE_POSTS_QUERY
. It’s currently asking for the post id
, not its databaseId
.
Great:
Now update the blog.($postId).$slug
route to query for the post from its databaseId
instead of its slug
:
// app/routes/blog.($postId).$slug
...
import { POST_BY_ID_QUERY } from "~/models/wp_queries";
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
invariant(params.postId, "params.postId is required");
const postId = params?.postId;
const client = createApolloClient();
const response = await client.query({
query: POST_BY_ID_QUERY,
variables: {
id: postId,
},
});
if (response.errors) {
throw new Error(`An error was returned when querying for post: ${postId}`);
}
const post = response?.data?.post ?? null;
if (!post) {
// todo: this should be handled gracefully
throw new Error(`No post was returned for post: ${postId}`);
}
return post;
};
And update its component to add the databaseId
to the navigation URLs:
// app/routes/blog.($postId).$slug
...
export default function BlogPost() {
const post = useLoaderData<typeof loader>();
const caption = post?.featuredImage?.node?.altText
? post.featuredImage.node.altText
: "";
const author = post?.author?.node?.name;
// todo: improve this and extract into a utility file:
const date = post?.date
? `${new Date(post.date).getFullYear()}-${String(
new Date(post.date).getMonth() + 1
).padStart(2, "0")}-${String(new Date(post.date).getDate()).padStart(
2,
"0"
)}`
: "";
const previousTitle = post?.previousPost?.title;
const previousSlug = post?.previousPost?.slug;
const previousId = post?.previousPost?.databaseId;
const nextTitle = post?.nextPost?.title;
const nextSlug = post?.nextPost?.slug;
const nextId = post?.nextPost?.databaseId;
...
<div className="my-3 grid grid-cols-1 min-[431px]:grid-cols-2 gap-4 items-center h-full">
{previousTitle && previousSlug && previousId ? (
<div>
<div>
<span className="text-5xl">←</span>
<span>Previous</span>
</div>
<Link
prefetch="intent"
to={`/blog/${previousId}/${previousSlug}`}
className="text-lg font-bold text-sky-700 hover:underline"
>
{previousTitle}
</Link>
</div>
) : null}
{nextTitle && nextSlug && nextId ? (
<div className="min-[431px]:text-right">
<div>
<span>Next</span>
<span className="text-5xl">→</span>
</div>
<Link
prefetch="intent"
to={`/blog/${nextId}/${nextSlug}`}
className="text-lg font-bold text-sky-700 hover:underline"
>
{nextTitle}
</Link>
</div>
) : null}
</div>
...
Everything still works. I’ll rename the route so that $postId
is no longer an optional param:
$ mv app/routes/blog.\(\$postId\).\$slug.tsx app/routes/blog.\$postId.\$slug.tsx
This is going well. Now I’ll rename the archive route:
$ mv app/routes/blog.\(\$categorySlug\).archive.tsx app/routes/blog.posts.tsx
Output on the server:
Error: No route matches URL "/blog/archive"
Before updating the internal URLs to point to the new route, I’ll add 404 page not found
handling to the app.
Handle 404
errors
There are two primary cases where a Remix site should send a 404:
- The URL doesn’t match any routes in the app
- Your loader didn’t find any data
The first case is already handled by Remix, you don’t have to throw a response yourself. It knows your routes, so it knows if nothing matched (consider using a Splat Route to handle this case). The second case is up to you, but it’s really easy.
https://remix.run/docs/en/main/guides/not-found
Attempting to visit /blog/archive
triggered the first case – the URL doesn’t match any routes on the app. It seems this can be handled with a “splat route.” The documentation for splat routes is here: https://remix.run/docs/en/main/file-conventions/routes#splat-routes.
$ touch app/routes.\$tsx
The idea seems to be to have a loader
function in the route that throws a 404
response. This means that the route’s default component will never get rendered.
// app/routes/$.tsx
import { Link, useRouteError } from "@remix-run/react";
export const loader = async () => {
throw new Response(null, {
status: 404,
statusText: "Not Found",
});
};
export default function RootSplat() {
return (
<div>
<p>If you are seeing this page, something has gone wrong.</p>
</div>
);
}
export function ErrorBoundary() {
const error: any = useRouteError();
const status = error?.status;
return (
<div className="px-2">
<div className="mx-auto max-w-3xl px-2 py-4 my-10 bg-sky-100 border-2 border-sky-700 rounded">
{status && status === 404 ? (
<h2 className="text-xl">
The page you were looking for could not be found
</h2>
) : (
<h2 className="text-xl">Sorry, something has gone wrong</h2>
)}
<p className="pt-1">
Click this link to return to the blog's homepage:{" "}
<Link className="text-sky-800 font-bold" to="/blog">
Zalgorithm
</Link>
</p>
</div>
</div>
);
}
I’ll keep an eye on things to make sure the splat route isn’t catching more than it’s supposed to.
Now I’ll handle the cases where the loader might not return any data:
That’s being triggered from here:
// app/routes/blog.$postId.$slug.tsx
...
export const loader = async ({ params }: LoaderFunctionArgs) => {
...
if (!post) {
// todo: this should be handled gracefully
throw new Error(`No post was returned for post: ${postId}`);
}
...
That can be changed to:
// app/routes/blog.$postId.$slug.tsx
...
if (!post) {
throw new Response(null, {
status: 404,
statusText: "Not Found",
});
}
...
I’ve also updated the route’s ErrorBoundary
component so that it’s the same as the one that’s used on the splat route.
Use the new posts
route
Back on track now. /blog/posts
loads the post archive. There’s only one link to /archive
in the app. I’ll change it to posts
:
// app/routes/blog._index.tsx
...
<Link
className="text-2xl text-sky-700 font-semibold font-serif hover:underline pt-3 block"
prefetch="intent"
to="posts"
>
View all posts
</Link>
...
For consistency, I’ll update the name of the default export in the blog.posts.tsx
route. Nothing would break if it kept its old Archive
name, but…
// app/routes/blog.posts.tsx
...
export default function Posts() {
const { pageInfo, postConnectionEdges, categoryName } =
useLoaderData<typeof loader>();
...
Add a category archive route
Blogging and coding simultaneously keeps me on track 🙂
I’ll start by copying the blog.posts.tsx
file to blog.category.$categorySlug
:
$ cp app/routes/blog.posts.tsx app/routes/blog.category.\$categorySlug.tsx
Then update the links to category archive pages in blog._index.tsx
:
// app/routes/blog._index.tsx
...
{categoryEdges.map((categoryEdge: RootQueryToCategoryConnectionEdge) => (
<div key={categoryEdge.node.name} className="py-3">
<hr className="mt-2 mb-2 border-solid border-slate-400" />
<Link
to={`/blog/category/${categoryEdge.node.slug}`}
That was easy:
This is getting close to meeting the requirements I outlined at the top of this post. I’ll clean up the duplicate and unused code in blog.posts.tsx
and blog.category.$categorySlug.tsx
.
A route for displaying individual posts from a category archive
The length of this section’s title makes question its validity. I’ve got a use case for it though. Say I’ve got a “photography” category on the blog. I want to be able to limit “previous” and “next” post navigation in that category to posts within the category.
I guess the route would be blog.category.$categorySlug.$postId.$slug
. This feels kind of fragile. (Would blog.category.$categorySlug.posts.$postId.$slug
be better?)
I’ll try just copying the blog.$postId.$slug.tsx
file to the new route:
$ cp app/routes/blog.\$postId.\$slug.tsx app/routes/blog.category.\$categorySlug.\$postId.\$slug.tsx
That doesn’t work:
I confirmed with a console.log
statement that the new route’s loader
function is being called. The issue is that the new route is a child route of blog.category.$categorySlug
.
I’ll try adding an Outlet
component to the category archive page:
// app/routes/blog.category.$categorySlug.tsx
...
import { Outlet, useLoaderData, useRouteError } from "@remix-run/react";
...
export default function CategorySlug() {
const { pageInfo, postConnectionEdges, categoryName } =
useLoaderData<typeof loader>();
return (
<div className="px-6 mx-auto max-w-screen-lg">
<div className="flex justify-center items-center h-full">
<h2 className="text-3xl py-3 font-serif text-center">{categoryName}</h2>
</div>
<hr className="border-solid border-slate-300" />
<div className="mx-auto max-w-screen-lg pt-6">
<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}
databaseId={edge.node.databaseId}
key={edge.node.databaseId}
/>
))}
</div>
</div>
<Outlet />
<hr className="my-6 border-solid border-slate-300" />
<div className="my-3 flex justify-center items-center h-full">
<Paginator pageInfo={pageInfo} />
</div>
</div>
);
}
...
OK, that’s really cool:
It looks terrible, but what if the excerpts were in a sidebar (drop down on mobile) and the posts were loaded in the route’s outlet?
I think I’ll go with that approach, but I want to confirm something first. I’ll remove the Outlet
from the component, then rename it to blog.category.$categorySlug._index.tsx
:
$ mv app/routes/blog.category.\$categorySlug.tsx app/routes/blog.category.\$categorySlug._index.tsx
That worked:
So the easy solution is to just make the category/$categorySlug
route an index route. The interesting thing to do is to keep the route as a non-index route and display its posts in an Outlet
. I’m here to learn, so:
$ mv app/routes/blog.category.\$categorySlug._index.tsx app/routes/blog.category.\$categorySlug.tsx
Fun times with CSS on the horizon. I think I’d better call it a day 🙂
$ git add .
$ git commit -m "add a post child route to category archive pages"
$ git checkout main
$ git merge update_route_configuration
$ git branch -d update_route_configuration