A graphic depicting using Astro's content layer API to unify content collections from headless WordPress.
Headless WordPress

Andrew Kepson

September 16, 2024

Shattering Headless WordPress Build Times with Astro’s Content Layer API

The author of this blog post, Andrew Kepson, standing in front of the Rocky Mountains in Colorado.

Andrew Kepson

September 16, 2024

Like many headless WordPress developers, in the last year I have made the switch to building front ends with Astro. Marketing itself as “The web framework for content-driven websites,” Astro has emerged as – in my opinion – the best way to build websites today.

Why Use Astro for Headless WordPress?

What makes Astro so great? Compared with previous tools such as Gatsby or Next.js, Astro does not use React to achieve its high performance (though developers can still implement React components if they so choose). This drastically reduces the amount of JavaScript that is sent to the browser by default.

There are a number of other features that make Astro a pleasure to work with:

  • It is built on top of Vite, leveraging exceptionally fast hot-module replacement
  • It includes optional features such as prefetching
  • It can dynamically generate XML sitemaps for all static pages
  • There is a built in Image service to optimize and transform images
  • Server integrations with Vercel, Netlify, and others make implementing SSR a breeze
  • Supports TypeScript out of the box, and NPM packages can be imported into client-side scripts that are bundled at built time
  • Integration for Tailwind CSS

I could go on about the benefits of using Astro, but at the end of the day it gives developers a platform to choose whatever tooling they want to use, with the end result being a highly-optimized and performant website.

In version 4.14.0, Astro introduced a new feature called the Content Layer API, which is the focus of this article.

What is the Content Layer API?

The Content Layer API is the latest development in Astro’s efforts to drastically improve build times for static websites. In previous iterations, Astro introduced Content Collections, which added type-safety and build optimizations to content hosted locally (such as in Markdown files), not only making builds faster but also giving developers autocompletion when building out templates for specific content structures.

The Content Layer API expands these enhancements to content from any source – meaning we can now implement type-safe content collections at scale, pulling data from any external source through an API, including our headless WordPress websites.

Using Astro’s Content Layer API

As of this writing, the Content Layer is still an experimental feature in Astro. I have implemented it on several headless WordPress websites already without issue.

To activate the content layer in your Astro project, update your config to turn it on:

{
  experimental: {
    contentLayer: true,
  }
}

Implementing a Collection and adding it to the Content Layer essentially requires two steps:

  1. Fetching the data
  2. Processing the data and adding it to a collection

Fetching Headless WordPress Data

Keeping in mind that Astro allows you to fetch data however you want, for this post I will use a simplified example to fetch a group of pages from WordPress. While WordPress has a built-in REST API, personally I prefer to use WPGraphQL, a free plugin which adds a GraphQL API to your WordPress site, which I will use for this example.

First, I will set up a function to fetch WordPress data from my GraphQL endpoint:

const fetchWordPressAPI = async (query, variables = {}) => {
  const WP_API_URL = process.env.WPGRAPHQL_ENDPOINT;

  if (!WP_API_URL) {
    throw new Error("WPGRAPHQL_ENDPOINT is not set in the environment variables");
  }

  const response = await fetch(WP_API_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, variables }),
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const data = await response.json();

  if (data.errors) {
    throw new Error("GraphQL errors: " + JSON.stringify(data.errors));
  }

  return data.data;
};

Then I will add a getAllPages method, which returns a structured object with all of my WordPress pages and includes the basic page content, as well as the headless WordPress SEO data (in this example, we are using Rank Math – which means you will need to install the wp-graphql-rank-math plugin from AxeWP if you wish to use this pattern).

const getAllPages = async () => {
  const pageQuery = `
    query GetAllPages {
      pages {
        nodes {
          id
          slug
          title
          content(format: RENDERED)
          seo {
            description
            title
            openGraph {
              image {
                url
              }
            }
          }
        }
      }
    }
  `;

  const pageTransformer = (node) => ({
    id: node.id || "",
    slug: node.slug || "",
    title: node.title || "",
    content: node.content || "",
    seo: {
      description: node.seo?.description || "",
      title: node.seo?.title || "",
      openGraph: {
        image: {
          url: node.seo?.openGraph?.image?.url || "",
        },
      },
    },
  });

  try {
    const response = await fetchWordPressAPI(pageQuery);
    const pages = response.pages.nodes.map(pageTransformer);
    console.log(`Successfully fetched ${pages.length} pages`);
    return pages;
  } catch (error) {
    console.error("Error in getAllPages:", error);
    throw new Error(`Failed to fetch pages: ${error.message}`);
  }
};

Adding Headless WordPress Data to Astro’s Content Layer

Now that we have a way to fetch our WordPress page data, we need to define a collection by implementing a loader. This is done by creating a configuration file at src/content/config.ts.

Defining the Schema

We need to define a Zod schema that matches the content we are fetching from WordPress:

import { z } from "astro:content";

const pageSchema = z.object({
  id: z.string(),
  slug: z.string(),
  title: z.string(),
  content: z.string(),
  seo: z.object({
    description: z.string(),
    title: z.string(),
    openGraph: z.object({
      image: z.object({
        url: z.string(),
      }).optional(),
    }),
  }),
});

The schema is part of the magic of making the content layer so fast: it introduces type-safety to our data collections, and gives us autocompletion in our TypeScript files where we use the data.

Creating the Loader

Using Astro’s defineCollection method, we can create a data object that utilizes our pageSchema. The load function utilizes our getAllPages function to query for the page data from WordPress, and uses it to implement a content collection.

import { defineCollection } from "astro:content";

export const pagesCollection = defineCollection({
  type: 'data',
  schema: pageSchema,
  load: async () => {
    const pages = await getAllPages();
    return pages;
  },
});

Adding Our Collection to the Content Layer

Finally, our config.ts file must export a collections object, which can contain one or many different content collections. For example, we may have a Custom Post Type using Advanced Custom Fields – we can easily separate them in our Content Layer by using a separate collection and loader and exporting them in the collections object.

export const collections = {
  pages: pagesCollection,
};

Using the Content Layer in Our Template Components

Now that we have fetched the data from WordPress, created a Content Collection, and added the Collection to our Content Layer, the last step is to create pages with it and make it beautiful for users.

Since we have an array of page objects available to us in the Collection, we can easily leverage Astro’s getStaticPaths function to generate a page for each of them. Suppose we have a component at src/pages/[slug].astro. We can import our collection using the getCollection method, and once we have generated the page, by passing the individual page as props we can now use our data with type-safety and autocompletion.

---
import { getCollection } from 'astro:content';
import Layout from '../components/Layout.astro';

export async function getStaticPaths() {
  const pages = await getCollection('pages');
  return pages.map(page => ({
    params: { slug: page.slug },
    props: { page },
  }));
}

const { page } = Astro.props;
---

<Layout title={page.seo.title} description={page.seo.description}>
  <article>
    <h1>{page.title}</h1>
    <div set:html={page.content}></div>
  </article>
</Layout>

<style>
  article {
    max-width: 65ch;
    margin: 0 auto;
    padding: 1rem;
  }

  h1 {
    font-size: 2.5rem;
    margin-bottom: 1rem;
  }
</style>

The Content Layer in the Real World

Now that you have tried out the Content Layer API, you may wonder: this seems great for a practice exercise, but will it stand up in the real world?

I recently implemented the Content Layer on another website with 162 pages, which were a mix of Pages, Posts, and Custom Post Types built with ACF. I found that the build time was 6.63s – in other words, blazingly fast!

Are you interested in leveraging the power of Astro with headless WordPress for your next website? Feel free to contact me anytime to get started.

September 16, 2024

This post is powered by headless WordPress.