Route configuration

❗ 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">&larr;</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">&rarr;</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