Easily restore your project to a previous version with our new Instant One-click Backup Recovery

Implementing lazy load content with Astro.js and Hygraph pagination

In the demo, we’ll load a set of posts as static HTML created during the site build and then implement a load more button in React to query Hygraph for more content and load that onto the page.
Bryan Robinson

Written by Bryan

Jan 18, 2023
 lazy load content with Astro and Hygraph pagination

With the ever-tumultuous nature of social media, sometimes owning your own content and publishing on your own platform is better. In this article, we’re going to take a look at implementing a standard pattern of the social media world: the load more button.

In the demo, we’ll load a set of posts as static HTML created during the site build and then implement a load more button in React to query Hygraph for more content and load that onto the page.

#Watch the full video

#Why Astro.js?

Astro is a relatively new player in the front-end framework world. Their underlying philosophy is that while we may want to author our websites in JavaScript, we don’t need to ship all that JavaScript to the browser just to have a bit of interactivity.

Astro is an HTML-first platform that statically builds the HTML during a deployment process and then selectively rehydrates parts of the user interface based on client-side needs. This is often referred to as “Islands Architecture” and differs from traditional meta frameworks in that Astro doesn’t want to take over the entire page for simple hydration of one area.

Astro also isn’t beholden to any singular framework like React. While we’ll be using React in this example, it could also be built from Vue, Svelte, or JavaScript.

#Starting point

To get things started, I’ve put together a relatively simple Astro site that is querying Hygraph for posts in a Post schema. If you’d like to follow along, you can use Hygraph’s Blog starter schema or create a blank project and add a Post schema with two fields: slug and content (rich text). Since this is a “microblog,” we don’t need more than that. If you’re using the blog starter, you’ll have that by default (along with other fields we won’t use in this project).

You can get the starting point project from this GitHub repository on the “start” branch. This gives you all the functionality you need to connect your Hygraph project to Astro and see all the posts on the homepage.

Clone the repository and create a .env file in the root and add the HYGRAPH_ENDPOINT environment variable with a value of the Hygraph project’s API endpoint.

Now, we’re ready to get rolling.

#Getting pages of content instead of simply the content

To begin, the project is currently pulling a set number of posts from the Hygraph API to the homepage in the src/index.astro file. It’s using a JavaScript fetch in the frontmatter to query and get the first 10 posts from the API and return back their ID, createdAt date, HTML content, and slug.

It then gets the posts off the response and sends that to the Astro template to render each post with that information.

---
import Main from "../layouts/Main.astro";
import Post from "../components/Post.astro";
const pageSize = 10;
const response = await fetch(
"THE-LINK-TO-YOUR-ENDPOINT",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
query: `query MyQuery($size: Int) {
posts(first: $size, orderBy: createdAt_DESC) {
id
createdAt
slug
content {
html
}
}
}
`,
variables: {size: pageSize}
}),
}
);
const data = await response.json();
const {posts} = data.data;
---
<Main title="My Microblog">
<div>
{posts.map((post) => {
return <Post post={post} />
})}
</div>
</Main>

This is fine when you have a small number of posts, but if you want 100 posts on the homepage, the build time and load time are going to get longer and longer. Not great for developer experience or user experience. While using a posts() query is great for getting started, we’ll want to switch to a method more suited for larger projects.

For each model you create, Hygraph creates a standard query and a connections query for it. This specialized connection query gives us all the information we need to properly paginate and request content at various points in the model’s content. Hygraph follows the Relay cursor connection specification and does all the API work for you to make sure it’s ready for use in the postsConnections query.

This new query let’s us query pages of data instead of the data itself. While it adds a bit of overhead, it makes up for that with all the data we need to properly work:

  • Posts are edges in our query
  • Each post has a unique cursor point and all the original post data
  • Each query will also return a unique pageInfo object which gives us a start and end cursor, booleans for next and previous pages, and the total page size
  • The start and end cursors can be used to get the next or previous set of posts

Let’s restructure the homepage query to get a page of content instead of just a number of posts. To do this, we need to change from using the posts() query to postsConnections() and restructure the returned data. We still use the pageSize variable to define how many items each page should hold and then pass that data as pages instead of posts to the Astro template to loop over.

---
import Main from "../layouts/Main.astro";
import Post from "../components/Post.astro";
const pageSize = 3;
const response = await fetch(
"THE-LINK-TO-YOUR-ENDPOINT",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
query: `query MyQuery($size: Int) {
pages:postsConnection(first: $size, orderBy: createdAt_DESC) {
posts:edges {
cursor
post: node {
content {
html
}
id
slug
createdAt
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`,
variables: {size: pageSize}
}),
}
);
const data = await response.json();
const {pages} = data.data;
---
<Main title="My Microblog">
<div>
{pages.posts.map((post) => {
return <Post post={post.post} />
})}
</div>
</Main>

Now, we have 3 posts pulling into the homepage. How do we get the rest? We need a new component to create a “Load More” button and provide a landing zone for the new posts in our interactive island.

#Adding the Load More component

In the src/components directory, create a new file named More.jsx. We’ll start by just adding the load more button when the component is added to the homepage.

Since this little island of interactivity is going to be a React component, we start by importing React. Astro is already configured to support React in the starter code, but if you were starting from scratch, you’d need to configure Astro to be ready for React components.

Adding the button

Screenshot showing the last post with a Get More button beneath

First, we’ll create our component with two properties — currentCursor and size. These props will get passed to this component when it’s added to the index file.

From there, we need to set up a few pieces of state that will be useful throughout this process: a blank posts array, the cursor set to the currentCursor prop, a boolean for hasNext set to true and a loading boolean set to false.

We can then return a little markup to display a button with a click handler set to a getMore async function. This function is where much of the code will live in the next step. As a bit of added flair, let’s also make sure to have a small Loading indicator for when those posts are loaded. Check to see if loading is true and display the section if it is.

import React, { useState } from "react";
const More = ({currentCursor, size=1}) => {
const [posts, setPosts] = useState([])
const [cursor, setCursor] = useState(currentCursor)
const [hasNext, setHasNext] = useState(true)
const [loading, setLoading] = useState(false)
const getMore = async () => {
// upcoming handler
}
return (
<>
{loading && <div className="bg-white mb-4 p-4 rounded-md text-center">Loading...</div>}
{hasNext && <button className="bg-white mb-4 p-4 rounded-md" onClick={getMore}>Get More </button>}
</>
);
}
export default More

Now that this component is created, we can add it to the page. In the src/index.astro file, import the component at the top and add the component after the loop through the posts. Since there may not be more posts to fetch, we want to make sure to only load the More component if the hasNextPage metadata is set to True.

When initializing the component, pass it the size and currentCursor props, but then we need to decide when we want to make this button ready. Astro provides a few directives for when to load our islands into the DOM. In this case, since the button will be near the bottom of the page and is not important for the user in how they interact with the first couple blog posts, I’ve set it to client:visible which will load this using the browser Intersection Observer API to know when the user scrolls the component into view. This will save on load time and keep page performance at a premium.

---
import Main from "../layouts/Main.astro";
import Post from "../components/Post.astro";
**import More from "../components/More.jsx";**
// ... More frontmatter
---
<Main title="My Microblog">
<div>
{pages.posts.map((post) => {
return <Post post={post.post} />
})}
</div>
**{pages.pageInfo.hasNextPage && <More client:visible size={pageSize} currentCursor={pages.pageInfo.endCursor} />}**
</Main>

Now we should have a “Get More” button at the bottom of the page. The button doesn’t do anything yet. Let’s fix that.

Fetching more posts

As mentioned before, we need to handle what happens when a user clicks “Get More” so that new posts are fetched and added to the end of the list.

This query needs to be slightly different than the query from the initial Astro build for the index page. In this case, we need to also set the after property to the end item cursor we passed to our component. This is where the query will start and get a number of posts equal to the size value we also passed to the component. In other words, this gets the next 3 posts after the 3 we fetched for the index page.

const query = `query MyQuery($cursor: String!, $size: Int!) {
postsConnection(after:$cursor, first:$size, orderBy: createdAt_DESC) {
postsArray:edges {
cursor
post: node {
content {
html
}
id
slug
createdAt
}
}
pageInfo {
endCursor
hasNextPage
}
}
}`

We can use this new query in the getMore function.

To start the function, we set the loading state to true to display the loading information. Then we can fire off a new fetch request to Hygraph with the query we just wrote. From there, we can destructure the postsArray off the query and get the new pageInfo object. The pageInfo object has the same type of information as the homepage had. This will let us set a new endCursor and hasNext state for the new position.

Alongside these variables, we need to also set a state on the empty array of the posts variable. Even though the first state was an empty array, we want to set the state equal to the current state of posts — spread into the array — and the new set of posts in the postsArray variable. This ensures as we click the Get More button more, we keep the posts we’ve already fetched and just append new ones to the state.

Once the posts state has been updated, we can set the loading state back to false to remove the loading component. With that, we can now map over the posts array to display the content of the posts with the React PostContent component.

import React, { useState } from "react";
import PostContent from "./PostContent";
const query = `query MyQuery($cursor: String!, $size: Int!) {
postsConnection(after:$cursor, first:$size, orderBy: createdAt_DESC) {
postsArray:edges {
cursor
post: node {
content {
html
}
id
createdAt
}
}
pageInfo {
endCursor
hasNextPage
}
}
}`
const More = ({currentCursor, size=1}) => {
const [posts, setPosts] = useState([])
const [cursor, setCursor] = useState(currentCursor)
const [hasNext, setHasNext] = useState(true)
const [loading, setLoading] = useState(false)
const getMore = async () => {
setLoading(true)
const response = await fetch(
"THE-LINK-TO-YOUR-ENDPOINT",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
query: query,
variables: {
size: size,
cursor: cursor
}
}
)});
const json = await response.json();
const { data } = json
const { postsArray, pageInfo } = data.postsConnection
setPosts([...posts, ...postsArray])
setCursor(pageInfo.endCursor)
setHasNext(pageInfo.hasNextPage)
setLoading(false)
}
return (
<>
**{posts.map((post) => (
<div key={post.cursor} className="prose bg-white max-w-4xl mb-4 p-4 rounded-md">
<PostContent post={post.post} />
</div>
))}**
{loading && <div className="bg-white mb-4 p-4 rounded-md text-center">Loading...</div>}
{hasNext && <button className="bg-white mb-4 p-4 rounded-md" onClick={getMore}>Get More </button>}
</>
);
}
export default More

We can now load all the posts one click at a time. When we reach a point where there are no more pages, the hasNext state will switch to false and the button will no longer be available for clicking.

Blog Author

Bryan Robinson

Bryan Robinson

Head of Developer Relations

Bryan is Hygraph's Head of Developer Relations. He has a strong passion for developer education and experience as well as decoupled architectures, frontend development, and clean design.

Share with others

Sign up for our newsletter!

Be the first to know about releases and industry news and insights.