Pulling Data from WordPress to Remix

❗ This is not a tutorial, or good training data for an LLM ❗

In the last post I created a basic layout for the blog’s index page. It’s currently displaying hard coded HTML:

To pull in real data from the WordPress site, I need to write a GraphQL query that returns the 5 most recently created posts on the site, and also pulls in up to 5 posts for each of the site’s categories.

Write the query in the GraphiQL IDE

The WPGraphQL plugin adds the GraphiQL IDE to the WordPress admin section.

The documentation is super helpful: https://www.wpgraphql.com/docs/intro-to-graphql.

The plan for the index page is that the top section will contain the 5 most recent posts from the site. It will then display up to 5 posts for each category on the site. I’m not sure if I’ll stick with that approach, but it’s good enough for now. Here’s a GraphQL query to get the post data:

query getSitePosts {
  posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
    edges {
      node {
        id
        title
        excerpt
        date
        author {
          node {
            name
          }
        }
        featuredImage {
          node {
            caption
            description
            id
            sourceUrl
          }
        }
      }
    }
  }
  categories {
    edges {
      node {
        name
        posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
          edges {
            node {
              id
              title
              excerpt
              date
              author {
                node {
                  name
                }
              }
              featuredImage {
                node {
                  caption
                  description
                  id
                  sourceUrl
                }
              }
            }
          }
        }
      }
    }
  }
}

I’ll copy that query from the GraphiQL IDE into my Remix app’s models/wp_queries.tsx file.

For reference, the query needs to be exported like this:

// app/models.wp_queries.tsx

import { gql } from "@apollo/client/core/index.js";

...

export const INDEX_PAGE_POSTS_QUERY = gql(`
query getSitePosts {
    posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
      edges {
        node {
          id
          title
          excerpt
          date
          author {
            node {
              name
            }
          }
          featuredImage {
            node {
              caption
              description
              id
              sourceUrl
            }
          }
        }
      }
    }
    categories {
      edges {
        node {
          name
          posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
            edges {
              node {
                id
                title
                excerpt
                date
                author {
                  node {
                    name
                  }
                }
                featuredImage {
                  node {
                    caption
                    description
                    id
                    sourceUrl
                  }
                }
              }
            }
          }
        }
      }
    }
  }
`);

Run the query in the Remix app

Running the query was easy enough. Typescript and GraphQL are still a bit of a mystery to me though. It seems that the correct type for the collections of posts is PostConnectionEdge.

Here’s the current state of the code. So far it’s only displaying the posts returned by the posts part of the query, not the categories part of the query:

// app/routes/blog.tsx

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { createApolloClient } from "lib/createApolloClient";
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";
import { INDEX_PAGE_POSTS_QUERY } from "~/models/wp_queries";
import PostExcerptCard from "~/components/PostExcerptCard";

export async function loader() {
  const client = createApolloClient();
  const response = await client.query({
    query: INDEX_PAGE_POSTS_QUERY,
  });

  if (response.errors) {
    // todo: handle the error
    console.log(
      "An unhandled error was returned from the INDEX_PAGE_POSTS_QUERY"
    );
  }

  // if this isn't set, an error should already have been thrown
  const data = response?.data;
  const latestPostsEdges = data?.posts?.edges;
  const categoryPostsEdges = data?.categories?.edges;

  return json({
    latestPostsEdges: latestPostsEdges,
    categoryPostsEdges: categoryPostsEdges,
  });
}

export default function Blog() {
  const { latestPostsEdges, categoryPostsEdges } =
    useLoaderData<typeof loader>();
  return (
    <div className="px-6 mx-auto">
      <h2 className="text-2xl text-slate-900 mt-3 font-serif font-bold">
        Latest Posts
      </h2>
      <hr className="my-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) => (
          <PostExcerptCard postConnectionEdge={edge} key={edge.node.id} />
        ))}
      </div>
    </div>
  );
}

And here’s the updated PostExcerptCard component:

// app/components/PostExcerptCard.tsx

import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";

interface PostExcerptCardProps {
  postConnectionEdge: PostConnectionEdge;
}

export default function PostExcerptCard({
  postConnectionEdge,
}: PostExcerptCardProps) {
  const post = postConnectionEdge.node;
  const date = post?.date ? new Date(post.date) : null;
  const formattedDate = date
    ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
        2,
        "0"
      )}-${String(date.getDate()).padStart(2, "0")}`
    : null;

  return (
    <article>
      {post?.featuredImage && post.featuredImage.node?.sourceUrl ? (
        <img src={post.featuredImage.node.sourceUrl} />
      ) : (
        ""
      )}
      <h3 className="text-xl font-serif font-bold mt-3">{post.title}</h3>
      {post?.excerpt ? (
        <div
          className="italic text-slate-800 text-base"
          dangerouslySetInnerHTML={{ __html: post.excerpt }}
        />
      ) : (
        ""
      )}
      {post?.author?.node?.name && formattedDate ? (
        <div className="text-slate-800 text-base mt-1">
          {post.author.node.name} <br />
          {formattedDate}
        </div>
      ) : (
        ""
      )}
    </article>
  );
}

Not too bad. For my local development site, I got ChatGPT to generate the post titles and text. It knows more about programming than me, but I’m a better copywriter.

Going backwards

I’m not sure if it’s worth documenting failures, but it might help someone. The GraphQL query I came up with is essentially two queries in one. Once the results are unpacked in the loader function I’m left with:

  • an object with the PostConnectionEdge type that’s returned from the posts part of the query
  • an object with the RootQueryToCategoryConnectionEdge type that’s returned from the categories part of the query

This created two problems. The first was having to figure out what the proper types for the objects that get used on the /blog route. The second was that the initial version of the PostExcerptCard component was expecting a prop of the PostConnectionEdge type. I tried making it accept either the PostConnectionEdge or RootQueryToCategoryConnectionEdge types, but ended up rewriting it to accept a list of props.

(Instead of Maybe, I think I can just use type string | null | undefined. This is going to come up again, so I’ll try it out. )

// app/components/PostExcerptCard.tsx

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>;
}

export default function PostExcerptCard({
  date,
  featuredImage,
  title,
  excerpt,
  authorName,
}: 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 src={featuredImage} /> : ""}
      <h3 className="text-xl font-serif font-bold mt-3">{title}</h3>
      {excerpt ? (
        <div
          className="italic text-slate-800 text-base"
          dangerouslySetInnerHTML={{ __html: excerpt }}
        />
      ) : (
        ""
      )}
      {authorName && formattedDate ? (
        <div className="text-slate-800 text-base mt-1">
          {authorName} <br />
          {formattedDate}
        </div>
      ) : (
        ""
      )}
    </article>
  );
}

The /blog route still needs some error handling and a way of dealing with the case of a category that has no posts.

// app/routes/blog.tsx

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { createApolloClient } from "lib/createApolloClient";
import type {
  RootQueryToCategoryConnectionEdge,
  PostConnectionEdge,
} from "~/graphql/__generated__/graphql";
import { INDEX_PAGE_POSTS_QUERY } from "~/models/wp_queries";
import PostExcerptCard from "~/components/PostExcerptCard";

export async function loader() {
  const client = createApolloClient();
  const response = await client.query({
    query: INDEX_PAGE_POSTS_QUERY,
  });

  if (response.errors) {
    // todo: handle the error
    console.log(
      "An unhandled error was returned from the INDEX_PAGE_POSTS_QUERY"
    );
  }

  // if this isn't set, an error should already have been thrown
  const data = response?.data;
  const latestPostsEdges = data?.posts?.edges;
  const categoryEdges = data?.categories?.edges;

  return json({
    latestPostsEdges: latestPostsEdges,
    categoryEdges: categoryEdges,
  });
}

export default function Blog() {
  const { latestPostsEdges, categoryEdges } = useLoaderData<typeof loader>();
  return (
    <div className="px-6 mx-auto">
      <h2 className="text-2xl text-slate-900 mt-3 font-serif font-bold">
        Latest Posts
      </h2>
      <hr className="my-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) => (
          <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}
            key={edge.node.id}
          />
        ))}
      </div>
      {categoryEdges.map((categoryEdge: RootQueryToCategoryConnectionEdge) => (
        <div key={categoryEdge.node.name}>
          <h2 className="text-2xl text-slate-900 mt-3 font-serif font-bold">
            {categoryEdge.node.name}
          </h2>
          <hr className="my-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}
                  key={postEdge.node.id}
                />
              )
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

Issues aside, it’s turning out great! Time to push the latest work to the live site:

$ git add .
$ git commit -m "deal with typescript confusion"
$ git push live main

# on the live site

$ cd /var/www/hello_zalgorithm
$ npm install
$ npm run build
$ pm2 reload 0

Live (for now) here: https://hello.zalgorithm.com/blog