Headless Craft CMS with GraphQL and React, Part 3: Populating the Listing Page
November 4, 2025
The first step in querying our headless setup is to build a GraphQL query for a listing of our recipe entries. Craft includes a handy sandbox tool for testing your GraphQL queries. Access it in the control panel sidebar at GraphQL > GraphiQL.
By default the sandbox is set to the site's full schema, but for the most accurate results we'll want to switch to the custom "Recipes & Filter Categories" schema we created in Part 1. Do that by selecting the schema in the dropdown menu on the upper right.
Since our schema requires a token, we'll need to make sure that is included in our sandbox. Click on the "Headers" tab at the bottom of the sandbox text area and paste the following JSON code:
{
"Authorization": "Bearer XXXXXX" // <-- replace with your GraphQL token and delete this comment
}
Look familiar? That's the same header that gets passed by the Apollo client code in our /data/query-client.js file.
Write a GraphQL Query for the Recipe Listing
GraphQL queries are similar to javascript functions. We define a query with the MyQueryName() syntax and add some arguments that the query will use to filter the results. The GraphiQL sandbox offers suggestions as you type which can be helpful when learning GraphQL syntax and Craft's built-in keywords, so I recommend typing the queries out rather than copy/pasting. Type the following into the sandbox textarea:
query GetRecipeEntries(
$section: [String]
) {
entries(
section: $section
) {
id
title
uri
}
}
Let's break this down:
GetRecipeEntries() is the name of our GraphQL query. It has one attribute named $section. GraphQL is a "typed language", which means you have to explicitly define what kind (or "type") of data that can be assigned to a variable. For our $section attribute, we only want an array of strings (['recipes']), similar to how we filter by section in a craft.entries query, so the attribute type is [String].
GetRecipeEntries() queries our entire schema, but remember our schema allows for querying multiple types of content like entries, assets, and categories. We need to narrow down specifically what kind of data we want to retrieve within our schema which is where entries() comes in. This function is provided for us by Craft, you can learn about all available functions in the docs or by clicking the Docs icon in the upper left corner of the sandbox page. entries() works just like a craft.entries twig query, which means we can pass in filters. We'll be filtering our entries by section, and the section name will be passed in via our GraphQL container query.
Inside our entries() function is a list of the field data we want to return for each entry result. Click the play button on the sandbox and see that you get a list of all entries with their ID, title, and URI.
A note on terminology: In GraphQL, functions like entries() are referred to as "fields", which are different from the fields in our Craft CMS entries. This can get confusing when working with GraphQL and a CMS, so just be aware of the conflict.
Passing In Variables
You're probably wondering, "wait, we didn't actually tell the query what specific section we wanted". At the moment the query is actually returning data for every entry on the site, Recipes and all. Let's narrow the scope by telling the query we only want entries from the Recipes section. Click the "Variables" tab at the bottom of the sandbox textarea and paste in this code:
{
"section": ["recipes"]
}
Now we'll only get entries from the Recipes section.
Returning Craft Field Data
For our listing page we just need to display the recipe title along with a link and an image. Craft gives us id, title, and uri by default because they are universal to all entries; but for custom fields like image we need to drill down a little deeper. Add the following to your query:
entries(
section: $section
) {
id
title
uri
# add this
... on recipe_Entry {
image {
url
}
}
}
Since our image field is inside an entry type called "Recipe", we need to explicitly request fields on a per-entry-type basis. We use the GraphQL ... on keyword, then recipe is the name of our entry type, and finally Entry is a Craft-provided keyword for accessing an entry's data.
Then we access our image field and the field's url. Click play again to see the query return your recipe image URLs.
Adding the Query to the Framework
Create a new file in your /data directory called recipe-entries-query.js and paste in this code:
import { gql } from "@apollo/client";
const GET_RECIPE_ENTRIES = gql`
query GetRecipeEntries(
$section: [String]
) {
entries(
section: $section
) {
id
title
uri
... on recipe_Entry {
image {
url
}
}
}
}
`;
export default GET_RECIPE_ENTRIES;
You can see we've pasted in the GraphQL query from our sandbox inside Apollo's gql container (note that it uses backticks not quotes), then assigned it to a variable called GET_RECIPE_ENTRIES (screaming snake case is the recommended syntax for GraphQL queries in Apollo). We'll export this variable for use in our React component.
Now let's open the component file for our listing page. In my setup that is /pages/index.js and Home() is my homepage component. Add the following code:
import { useQuery } from "@apollo/client/react";
import GET_RECIPE_ENTRIES from "@/data/recipe-entries-query";
export default function Home() {
// variables to pass to the query
const queryVariables = {
section: ['recipes']
}
// call the useQuery hook and pass in the GraphQL query and variables
const { error, data } = useQuery(GET_RECIPE_ENTRIES, { variables: queryVariables });
// return a message on error
if (error) {
console.error(error);
return <p>There was an error fetching the entries.</p>;
}
// avoid an error if the component mounts before the query returns any data
if (!data) return null;
// ...
}
Inside our component function we call Apollo's useQuery hook and pass in our GraphQL query and any filter variables. This returns a data object on success, or an error object on error. Go ahead and run console.log(data) inside this component and you'll see we get the exact same JSON response as in our GraphiQL sandbox.
Now all that's left to do is light up our component with the returned data:
return (
<div className="listing">
{ data.entries.map( (entry) => (
<div className="item" key={entry.id}>
<Link href={entry.uri} className="recipe-card">
<div className="media">
<Image src={entry.image[0].url} alt={entry.title} width="500" height="250" />
</div>
<h6>{entry.title}</h6>
</Link>
</div>
)) }
</div>
);
Note: if you're using Next.js you may need to update your configuration file to allow for remote images.
<Link /> and <Image /> are Next.js-provided components, but plain old <a> and <img> tags will work fine too.
Notice that we're specifically using the entry's URI instead of a full URL because we want the link to route to our front-end site, not the back-end.
Load up your front-end in the browser and see your data populated on the page! We're currently loading all of our entries on the listing at once, in a future tutorial we'll add pagination for better performance.