Building a Headless WordPress Site with Astro

21 minute read ยท Nov 18, 2022
Jeff Everhart
Jeff Everhart
Sr. Developer Advocate

The WP Engine developer relations team is always exploring new technologies to see how they can make your job as a developer easier or more enjoyable. Sometimes, that means choosing the tools you already know and use, but other times it means trying something new because of the benefits it can bring you.

In the vein of trying something new, I’ve been exploring the Astro framework over the last few weeks, and quite frankly there is a lot here to like for people interested in doing headless WordPress. By the end of this post, you should be able to do the following things:

  • explain the key performance benefits and philosophy behind the Astro framework
  • create a basic headless WordPress site using Astro components and WP GraphQL

To kick things off, let’s start by exploring what Astro does differently than some of the other frameworks we’ve looked at in the past. You can find the starter project that this tutorial references on GitHub.

What is Astro?

Astro bills itself as “an all-in-one web framework for building fast, content-focused websites.” Their emphasis, not ours. But there are two important terms in that statement that I want to discuss: fast and content-focused.

Astro indeed builds some fast sites, but it does so by narrowing the audience it’s trying to serve as a framework to be mostly content-focused sites. In their documentation, they draw the distinction between a content-rich site and a web application, which might require auth, support data mutations, and need to manage application state. All of those scenarios require lots of JavaScript to implement, but most content-focused sites don’t have the same requirements. Frameworks like Next.js, Nuxt.js, and SvelteKit all handle the web app requirements well, but all of this requires additional energy and lines of code to support.

By prioritizing this content-focused use case, Astro is able to double down on several key principles that might be foreign to anyone who’s worked in a recent JavaScript framework: zero JS by default, server-first paradigm, and component islands.

When looked at in this context, there is a lot of overlap in the audience Astro is trying to serve and the people who make headless WordPress sites. Let’s explore what this pairing looks like.

Getting Started with the WordPress CMS

While there are a ton of ways to work with WordPress locally, with tools like MAMP, XAMPP, Vagrant, and even Docker, the quickest way to spin up a local WordPress site is using the Local development environment.

After downloading the software, we can create a local WordPress site for this project in a few clicks:

If you have a pre-existing WordPress site, you can use that as well.

After you’ve got a basic WordPress site either locally or on a server somewhere, we can install the WPGraphQL plugin from the plugin repository and activate it.

After we install and activate the plugin, we gain access to a GraphQL menu option that provides us with an interactive IDE we can use to construct and test our GraphQL queries.

By default WPGraphQL makes a GraphQL endpoint available at the /graphql path of you site domain.

Getting Started with Astro

To help you get started using Astro and WordPress faster, we created a starter project with some good defaults that we’ll walk through in the steps below.

First, you’ll need to clone the repository to your machine. Run the following commands to clone the starter repo, change directories, and install dependencies:

$ git clone https://github.com/JEverhart383/astro-wordpress-starter.git
$ cd astro-wordpress-starter
$ npm install

Now that we have our project ready to go, let’s create an .env file in your root directory and add the following property:

WORDPRESS_API_URL = https://<your-wordpress-site>/graphql

After saving the .env file, run the following command in your terminal to start the project in development mode:

$ npm run dev

With the project running, you should be able to visit localhost:3000 and see a homepage that looks like this with your ten most recent posts:

From this starting point, we can look at a few of the different features of Astro as we examine a few different pages and how they are constructed.

Astro and File-based Routing

Like many other current meta-frameworks, Astro implements a file-based routing strategy using a pages directory that can contain Astro components, markdown, MDX, or even HTML. The structure of our pages directory looks like this:

/pages
     [...uri].astro
     index.astro

Let’s start by looking at our index.astro component to see how it is structured before we dig into our more complex dynamic route. Like other frameworks that enable file or page-based routing, the index.astro component is what gets rendered at the root of our site:

---
import Hero from "../components/Hero.astro";
import PostCard from "../components/PostCard.astro";
import MainLayout from "../layouts/MainLayout.astro";
import { homePagePostsQuery } from "../lib/api";

const data = await homePagePostsQuery();
const posts = data.posts.nodes;

---

<MainLayout title='Home Page'>
	<main>
		<Hero></Hero>
		<h2>Recent Posts</h2>
		{
			posts.map(post => {
				return (
				<PostCard post={post}></PostCard>
				)
			})
		}
	</main>
</MainLayout>

Astro components have two main parts to them: the component script, which is contained within the code fence (---), and the template which can contain other Astro components, HTML, or even other framework components. Astro components are super flexible: they can take props and implement slots, load other components, scope styles at the component level, and offer JSX-like expressions to manipulate markup programmatically.

It’s important to remember that Astro has no client-side run time though, so any code inside of the component script is executed on the server and the components all render HTML at build time.

Building a Homepage

Let’s take a look at what our index.astro component is doing in more detail. At the top of the file, we define the component script and import a few other Astro components, as well as load some data from our WordPress backend:

---
import Hero from "../components/Hero.astro";
import PostCard from "../components/PostCard.astro";
import MainLayout from "../layouts/MainLayout.astro";
import { homePagePostsQuery } from "../lib/api";

const data = await homePagePostsQuery();
const posts = data.posts.nodes;

---

When we declare variables in our component script, they are available inside of our template. So, when we initilize the posts variable from the result of our GraphQL query, we can use that in our template to create dynamic markup using JSX-like syntax. In the example below, we call posts.map and return a PostCard component that accepts the post data as a prop:

{
	posts.map(post => {
		return (
		<PostCard post={post}></PostCard>
		)
	})
}

The PostCard component does most of the heavy lifting in displaying the details of the post on in the list on the homepage, so let’s see what that looks like as well.

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

<article>
    <a class='post-link' href={post.uri} aria-label={post.title}>
        <h3>{post.title}</h3>    
        <section>
            <img src={post?.featuredImage?.node?.mediaItemUrl} alt="">
            <div>
                <p set:html={post.excerpt}></p> 
                { post.categories.nodes.map(category => {
                    return (<a class='term-link' href={category.uri}>{category.name}</a>)
                })}
                <span><time datetime={post.date}>{new Date(post.date).toLocaleDateString()}</time></span>
            </div>
        </section>
    </a>
</article>

To access the post prop we passed into PostCard, we create a new variable in the component script and destructure our values from the Astro.props helper. As you can see, we liberally use the values of the current post in the template to construct the UI for this feature.

In many instances with WordPress, you may want or need to inject HTML into an element, like is the case with the paragraph excerpt here:

<p set:html={post.excerpt}></p> 

This set:html directive is a great example of one of the many template directives that Astro provides to make these things easier for developers.

Additionally, if we look at other parts of this component, we see that we can even execute JavaScript expressions to render dynamic content on the fly. In this example, we format the date of our post to be more locale friendly:

<time datetime={post.date}>{new Date(post.date).toLocaleDateString()}</time>

As you can see, Astro components are pretty powerful on their own, which is one of the reasons why I chose to build this starter with them and not another framework. But with Astro you can have your dev cake, and eat it too since it supports all of the popular frameworks ๐ŸŽ‚

Now that we’ve seen what Astro components can do, let’s look at how we loaded the data from WordPress in the next step of this tutorial.

Loading Data from WPGraphQL

Astro itself is pretty unopinionated on how and where you fetch your data, but it does provide some helpful features: global fetch and top-level await.

If we look again at these few lines of our component script, we can see at least one of those features at play. Since most of our data fetching should happen inside of a component script, we can just await async functions inside of the code fence.

---
import { homePagePostsQuery } from "../lib/api";

const data = await homePagePostsQuery();
const posts = data.posts.nodes;
---

In these lines, we import homePagePostsQuery from an external file, call that function, and assign its return value to a variable for use inside of our template.

Personally, I find this pattern really appealing for both its simplicity and its flexibility. This function behind the scenes can be a REST API call or GraphQL, and Astro doesn’t really care. We can also decide where this data fetching code lives.

Do you want each component to fetch its own data? Cool, do that. Do you want to fetch all the data at the page component level and pass it via props? Awesome, have at it. Astro will let you be as simple or complex as your heart desires.

In our case, we’re using WPGraphQL to fetch data, so let’s look at what our homePagePostsQuery function looks like under the hood:

export async function homePagePostsQuery(){
    const response = await fetch(import.meta.env.WORDPRESS_API_URL, {
        method: 'post', 
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify({
            query: `{
                posts {
                  nodes {
                    date
                    uri
                    title
                    commentCount
                    excerpt
                    categories {
                      nodes {
                        name
                        uri
                      }
                    }
                    featuredImage {
                      node {
                        mediaItemUrl
                        altText
                      }
                    }
                  }
                }
              }
            `
        })
    });
    const{ data } = await response.json();
    return data;
}

Inside of this function, we’re doing some standard data fetching with fetch against our WPGraphQL endpoint. And, again, since this is meant to be a bland starter project, this code is intentionally vanilla and maybe not as DRY as it could be. From this starting point though, you could create a service that makes these fetch calls more elegant, or integrate other data fetching libraries like Apollo Client.

Astro is still providing us with some nice scaffolding though. If you look at our data fetching file, you can see we are accessing our WORDPRESS_API_URL environment variable directly on import.meta.env.WORDPRESS_API_URL without having to install any packages or do any processing. This is Astro using Vite’s built in env variable support under the hood. While not a huge deal, it does remove on step from our workflow in creating a new site ๐ŸŽ‰ 

If you want to modify the query that we’re sending to WPGraphQL, the plugin installs a powerful GraphQL playground and query builder inside of your WordPress site.

I use this environment to develop new queries, test them against my live data, and as a reference when I’m using the data in my code. Now that we’ve got a good understanding of how the homepage works, let’s move on to looking at how Astro handles our dynamic content.

Handling Dynamic Content

In the previous examples we looked at, we rendered a mostly static page using Astro components and some data from WPGraphQL. But WordPress handles a number of different content types natively, like posts, pages, and categories, but also supports creating custom post types and custom fields that can be much more complex: like a space launch post type, for example ๐Ÿš€

So, how do we handle the dynamic content that WordPress can manage using Astro in a way that doesn’t get overly complicated?

The answer is that we can actually lean into WordPress routing and templating conventions to simplify this for us.

WordPress URIs and Routing

Since WordPress core software serves both headless and non-headless use cases as a CMS, it already handles a lot of the leg work in defining routes as you naturally create your content. We can see some examples of typical route structures below:

#Regular post route
/awesome-astro-post

#Hierarchical page route
/parent/page-on-astro-feature

#Category archive route
/category/no-js-frameworks

#Custome post type
/spacelaunches/ma-8

When creating headless WordPress sites, many developers end up reimplementing this routing structure in their frontend apps using various dynamic route pages and with multiple different queries across these routes.

So, we might create routes like this in the Astro pages directory to handle displaying category archive pages:

/pages
     /category
          /[categorySlug].astro

As our content store grows to contain more post types or taxonomies, we have to reimplement frontend routes to handle those new content cases. But what if we could instead create a flexible catch-all route to handle all of the existing routes WordPress already knows about?

That way, as we add content, we can focus mainly on the display of the content and less on the plumbing.

By using the uri that WordPress assigns to each piece of content, we can simplify our apps to achieve some pretty cool workflows. To achieve this, we’re going to need to dig into how Astro handles dynamic routes.

Astro and Dynamic Routing

The way that Astro handles dynamic routing should look pretty similar to most meta-framework users, but by creating a file named with square brackets ([]), you create a dynamic route segment.

So, inside of pages/category/[categorySlug].astro we can access that route parameter in our component script like this:

---
const categorySlug = Astro.params.categorySlug;
//do fetching for categories
---

While this is familiar, this only helps us fetch data for one particular content type, and doesn’t really support some of the hierarchical route examples we saw above: a parent -> child relationship between pages, for example.

Luckily Astro gives us a mechanism to take more complicated paths as route params using rest parameters in our file name. Instead of creating a bunch of hardcoded routes, we create one route that will respond for all uris of any path depth.

The file pages/[...uri].astro will then handle requests for any of our paths, and we will use that uri to query WordPress for the content. This catch-all pattern ends up being very flexible. Because of Astro’s route priority, the rest parameter route is evaluated last, which means that it’s easy to override if you want to create a specific route to match a content type.

Now that we have a routing mechanism that can handle even our most complex uris, let’s look at how to incorporate that with WPGraphQL to query the data and Astro to render templates to HTML based on content type.

Dynamic Content Templates in [...uri].astro

Now that we have a conceptual understanding of WordPress uris and Astro route params, let’s look at how we can utilize these things in our Astro template:

---
import MainLayout from "../layouts/MainLayout.astro";
import Archive from "../components/templates/Archive.astro";
import Single from "../components/templates/Single.astro";
import { getNodeByURI, getAllUris } from "../lib/api";
const uri = `/${Astro.params.uri}/`;
const data = await getNodeByURI(uri);
const node = data.nodeByUri;

export async function getStaticPaths(){
    return await getAllUris();
}

function resolveContentTemplate(node){ 
    let template;
    switch(node.__typename){
        case 'Post':
            template = Single;
            break; 
        case 'Page':
            template = Single;
            break; 
        case 'Category':
            template = Archive;
            break; 
        case 'Tag':
            template = Archive;
            break; 
        default: 
            template = Single;
    }

    return template;
}

const Template = resolveContentTemplate(node)

---

<MainLayout 
 title={ node.title || node.name}
 description={ node.excerpt }
>
	<main>
        <Template node={node}></Template>
	</main>
</MainLayout>

There’s a lot going on in this code sample, so let’s spend some time looking at what’s going on here. First, you can see that this component script is much more robust than our previous examples, so let’s start by breaking out the data-fetching code.

Data Fetching with getNodeByUri

As we detailed in the previous section, this Astro component is going to catch all of our dynamic uris and then we’ll use those to query WordPress for the content:

---
import { getNodeByURI, getAllUris } from "../lib/api";
const uri = `/${Astro.params.uri}/`;
const data = await getNodeByURI(uri);
const node = data.nodeByUri;
---

In this snippet, we import some of our data-related functions from an external file and again use the Astro.params helper to extract our uri param. For WordPress, we use a template literal to add slashes onto both ends of what Astro considers the uri. This just ensures they match when we request our data.

From here we use that uri to make a particular GraphQL query that looks like this in lib/api.js:

export async function getNodeByURI(uri){
    const response = await fetch(import.meta.env.WORDPRESS_API_URL, {
        method: 'post', 
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify({
            query: `query GetNodeByURI($uri: String!) {
                nodeByUri(uri: $uri) {
                  __typename
                  isContentNode
                  isTermNode
                  ... on Post {
                    id
                    title
                    date
                    uri
                    excerpt
                    content
                    categories {
                      nodes {
                        name
                        uri
                      }
                    }
                    featuredImage {
                      node {
                        mediaItemUrl
                        altText
                      }
                    }
                  }
                  ... on Page {
                    id
                    title
                    uri
                    date
                    content
                  }
                  ... on Category {
                    id
                    name
                    posts {
                      nodes {
                        date
                        title
                        excerpt
                        uri
                        categories {
                          nodes {
                            name
                            uri
                          }
                        }
                        featuredImage {
                          node {
                            altText
                            mediaItemUrl
                          }
                        }
                      }
                    }
                  }
                }
              }
            `,
            variables: {
                uri: uri
            }
        })
    });
    const{ data } = await response.json();
    return data;
}

This query can be kind of long to read, but is worth looking over a few times because its pretty much serves all of our data fetching needs across all our content types.

The nodeByUri query in WPGraphQL is what facilitates most of this interaction. We pass a uri in as a variable, and then WPGraphQL queries all of the public nodes in your WordPress graph for that particular uri and then data for that particular node. This tutorial on the WPGraphQL site goes into more detail on this powerful pattern.

But even though all our public content is accessible as a node type, the data we need for a category or author is different from the data we need for a post or page.

This is where GraphQL fragments can help specify the data we need by type. For example, the following fragment tells WPGraphQL to return the specified data fields when the node that resolves to our uri is a Post type:

                ` ... on Post {
                    id
                    title
                    date
                    uri
                    excerpt
                    content
                    categories {
                      nodes {
                        name
                        uri
                      }
                    }
                    featuredImage {
                      node {
                        mediaItemUrl
                        altText
                      }
                    }
                  }`

The code in the starter project contains fragments for Category, Post, and Page types, but could easily be modified to accommodate more types of content.

Additonally, some developers choose to interpolate some of these fragments using template literals to keep the code a little bit more modular. But even without that, in about 80 lines of code we have a query powerful enough to power most basic use cases ๐Ÿ”ฅ

Now that we understand how Astro routing and WPGraphQL can work together to help you lean into WordPress CMS conventions, let’s round off our content templates.

Rendering Static Content

Astro has two primary methods for rendering you content: static or server rendered. This output method can be modified in the project config by adjusting the output, but for this tutorial we’ll assume you want to deploy your project statically.

For Astro to build our project statically, it will need to know all of the possible routes for our dynamic route paths. Like Next.js dynamic Astro components rendering statically will need to export a getStaticPaths function that returns the paths as an array of objects:

---
export async function getStaticPaths(){
    return await getAllUris();
}
---

While these few lines in our component script are fairly clean, the getAllUris does a bit more heavy lifting behind the scenes. The query itself isn’t interesting, but as we saw before we had to do some massaging to get Astro’s rest route params and WordPress’ uri to match.

export async function getAllUris(){
  const response = await fetch(import.meta.env.WORDPRESS_API_URL, {
      method: 'post', 
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({
          query: `query GetAllUris {
            terms {
              nodes {
                uri
              }
            }
            posts(first: 100) {
              nodes {
                uri
              }
            }
            pages(first: 100) {
              nodes {
                uri
              }
            }
          }
          `
      })
  });
  const{ data } = await response.json();
  const uris = Object.values(data)
    .reduce(function(acc, currentValue){
      return acc.concat(currentValue.nodes)
    }, [])
    .map(node => {
      let trimmedURI = node.uri.substring(1);
      trimmedURI = trimmedURI.substring(0, trimmedURI.length - 1)
      return {params: {
        uri: trimmedURI
      }}
    })

  return uris;

}

To unpack most of this function, we make a call to WPGraphQL for all of the uris for the various content types we want. As you can see, we’re requesting the first 100 uris for each content type, which is the default limit for each query in WPGraphQL. You can manually adjust this value using a PHP filter or implement some sort of pagination if you have a lot of content.

From there, we extract the top level properties using Object.values into an array, then reduce that array into one large array containing values from post, page, and category nodes. Finally, we map that combined array, trimming the beginning and ending slashes, and return an object for each path that is stuctured like this:

      { 
        params: {
           uri: trimmedURI
        } 
      }

This final array of the above formatted object is what we return in getStaticPaths so that Astro can build all of our uris. At this point, we can flexibly generate a page with Astro for all of the paths that WordPress manages ๐Ÿš€

Rendering the Content with Dynamic Templates

Now that we have our content set to render statically with getStaticPaths and data available for each content node using the nodeByUri query, we can finally turn our attention to how this content gets presented visually.

To refresh ourselves on what our catch-all page template looks like (with getStaticPaths removed for brevity):

---
import MainLayout from "../layouts/MainLayout.astro";
import Archive from "../components/templates/Archive.astro";
import Single from "../components/templates/Single.astro";
import { getNodeByURI, getAllUris } from "../lib/api";
const uri = `/${Astro.params.uri}/`;
const data = await getNodeByURI(uri);
const node = data.nodeByUri;

function resolveContentTemplate(node){ 
    let template;
    switch(node.__typename){
        case 'Post':
            template = Single;
            break; 
        case 'Page':
            template = Single;
            break; 
        case 'Category':
            template = Archive;
            break; 
        case 'Tag':
            template = Archive;
            break; 
        default: 
            template = Single;
    }

    return template;
}

const Template = resolveContentTemplate(node)

---

<MainLayout 
 title={ node.title || node.name}
 description={ node.excerpt }
>
	<main>
        <Template node={node}></Template>
	</main>
</MainLayout>

After we query WPGraphQL, we pass the resulting node into our resolveContentTemplate function, where a standard switch statement matches the node.__typename property to a particular content type.

Once matched, we assign a particular Astro component import to a scoped template variable and then return that variable. Astro’s docs call these dynamic tags, and have a few caveats if you want to do this with anything that will require hydration:

const Template = resolveContentTemplate(node)

This dyanmic tag can then be used in the component template where we pass the node data in as a prop:

<Template node={node}></Template>

At this point, it’s really up to the developer to choose now to implement their templating rules if they want to use this convention.

While traditional WordPress theme development is one of the reasons people choose a headless approach, lots of developers think fondly of the WordPress template hierarchy as a heuristic for organizing and prioritizing content rendering. It’s really cool to get to lean into the things WordPress does well while using JavaScript based tooling and components.

Rendering Single.astro

The last thing we’ll look at in our tour of this Astro starter is the content used for our single content types. The Single.astro component is rendered inside our catch-all route and takes our node variable as a prop:

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

 <h1>{node.title}</h1>
<!-- Display categories links if node has terms -->
 { node.categories ?
    node.categories.nodes.map(category => (<a class='term-link' href={category.uri}>{category.name}</a>)) :     
    null 
 }
<!-- Only show date if node is a Post -->
 {
    node.__typename === 'Post' ? 
    (<p class='post-details'>
        Posted on <time datetime={node.date}>{new Date(node.date).toLocaleDateString()}</time>
    </p>):
    null
 }
 <img class='featured-image' src={node.featuredImage?.node?.mediaItemUrl} alt="">
 
 <article set:html={node.content}>
 </article>

 <style is:global>
    .wp-block-image img {
            width: 100%;
            height: auto;  
    }
</style>
<style>
    img.featured-image {
        width:100%;
        border-radius:  10px;
    }
    .post-details {
        color: darkgrey;
        font-weight:300;
    }
    a.term-link {
        display: inline-block;
        height: 1.5rem;
        padding: .75rem;
        margin: .5rem .5rem .5rem 0;
        background: linear-gradient(90deg,#0076dc 0%,#7a1ba6 100%);
        color: #fff;
        border-radius: 10px;
        font-weight: bold;
    }
</style>

We’ve touched on a lot of the conventions in here already, like Astro.props and set:html, but there are still a few things Astro has left to teach us about being an HTML developer ๐Ÿ˜Ž

First, using the JSX-like syntax in Astro components, we can do some expressive conditional rendering in our templates:

<!-- Only show date if node is a Post -->
 {
    node.__typename === 'Post' ? 
    (<p class='post-details'>
        Posted on <time datetime={node.date}>{new Date(node.date).toLocaleDateString()}</time>
    </p>):
    null
 }

And we haven’t even touched on styling yet, but there is a lot to like about Astro here as well. As some who’s used single-file components a lot in Vue, I was excited to see Astro implement similar constructs in its components too. If you include a good old <style></style> tag in your Astro component, Astro will bundle those styles with your page and those styles will be scoped to that particular component. This makes it easy to write really clean and low-specificity CSS.

However, there are a lot of instances where we need global styles, like when we need to target markup for an image returned from WordPress. Astro makes this particularly elegant, and instead of having a separate CSS file full of disconnected rules, you can add a directive to a style tag in your component file and apply the rules globally:

 <article set:html={node.content}>
 </article>

 <style is:global>
    .wp-block-image img {
            width: 100%;
            height: auto;  
    }
</style>

In this example, we’re using the set:html directive to inject our CMS content into the article tag. Right below that we use the is:global directive on a style tag to add a global rule targeting the image markup contained in our WordPress content.

Now that we have a good understanding of all of the pieces of our Astro project, let’s get this rocket off the ground and deploy this thing ๐Ÿ‘ฉโ€๐Ÿš€

Deploying Astro Projects

The Astro docs have a ton of guides that cover different deployment targets if you have a service in mind already. But since your reading a headless WordPress tutorial, I’ll show you how to set your project up to deploy on Atlas, our headless WordPress hosting platform.

Since Astro builds output static assets, we’ll need to make two small changes to make it run inside of a Node container.

First, we’ll add http-server as a dependency to our project and install if you want to run locally:

$ npm install --save http-server

With that project installed, we can tweak the start command to run that after building. Update the start script in package.json to look like this:

"start": "http-server ./dist"

Now, if you run the following commands locally Astro will build all of your paths statically, and then fire up a lightweight http server to serve those assets:

$ npm run build && npm run start

Wrapping Up

Thanks for reading this far in the post. I’m really excited to share Astro with the headless WordPress community because I think the two technologies share a lot in common and pair so nicely together.

Along with that, the Astro community itself is super inviting, so be sure to check out these resources to learn more about the awesome humans building this tool: