As your blog grows, you need a function like “Load more” or even “Infinite scroll” so that your visitors can easily access all the valuable content. In this story, I will focus on that and show you how easy it is to implement such functionality with Next.js 14, Server Actions (to move the heavy lifting to the server), and Contentful as the underlying CMS, where we have all our data.
Here is the GitHub repo with the full code.
Here is the link to the final result, so you can already spoil it ;-)
https://nextjs14-azureb2c-prisma.vercel.app/
New NPM Package
Let’s install the new "react-intersection-observer" NPM package, which we need for the infinite scroll.
npm i react-intersection-observerNew Next.js 14 Server Action for fetching posts
Now, I create a new folder under “src/actions” and a new file, “getPosts.ts”
"use server";
import type { UserAPIResponse } from "@/types/userScroll";
import { PageBlogPostOrder } from "@/lib/__generated/sdk";
import { client } from "@/lib/client";
export const getPosts = async (
offset: number,
limit: number,
locale: string,
isEnabled: boolean,
slug_not: string
) => {
try {
// Getting BlogPosts
const blogPostsData = await client.pageBlogPostCollection({
limit: limit,
locale: locale,
skip: offset,
preview: isEnabled,
order: PageBlogPostOrder.PublishedDateDesc,
where: {
slug_not: slug_not,
},
});
const posts: any = blogPostsData.pageBlogPostCollection?.items;
return posts;
} catch (error: unknown) {
console.log(error);
throw new Error(`An error happened: ${error}`);
}
};As you can see, we use the “use server” directive, which tells Next to execute it only on the server.
Server Actions briefly explained
Server Actions are asynchronous functions that are executed on the server. They can be used in Server and Client Components to handle form submissions and data mutations in Next.js applications.
Having these special functions that only run on the server means that developers can offload responsibilities like data fetching and mutations to them, avoiding the vulnerabilities and security concerns of fetching and mutating data from the client.
Contentful GraphQL- Introducing Offset with Skip Parameter
Next, I adapt the underlying graphql file for the “posts” data fetch from Contentful. I added the “skip” variable so that we can work with an offset during the data fetch.
query pageBlogPostCollection(
$locale: String
$preview: Boolean
$limit: Int
$skip: Int
$order: [PageBlogPostOrder]
$where: PageBlogPostFilter
) {
pageBlogPostCollection(
limit: $limit
skip: $skip
locale: $locale
preview: $preview
order: $order
where: $where
) {
items {
...PageBlogPostFields
}
}
}Changing Logic for page.tsx files
The next file to adapt is the page.tsx under “src/app/[locale]” because we have
import { client } from "@/lib/client";
import { notFound } from "next/navigation";
import { ArticleHero } from "@/components/contentful/ArticleHero";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { Container } from "@/components/contentful/container/Container";
import { draftMode } from "next/headers";
// Internationalization
import { locales, LocaleTypes } from "@/app/i18n/settings";
import { createTranslation } from "@/app/i18n/server";
//SEO - JSON-LD
import { Article, WithContext } from "schema-dts";
import path from "path";
import Script from "next/script";
import { Metadata, ResolvingMetadata } from "next";
// New Fields Part 9 of tutorial
import { PageBlogPostOrder } from "@/lib/__generated/sdk";
import { TextHighLight } from "@/components/contentful/TextHighLight";
import { revalidateDuration } from "@/utils/constants";
import { TagCloudSimpleHome } from "@/components/search/tagcloudsimpleHome.component";
import Link from "next/link";
import { LandingContent } from "@/components/contentful/ArticleContentLanding";
export const revalidate = revalidateDuration; // revalidate at most every hour
interface PageParams {
slug: string;
locale: string;
}
interface PageProps {
params: PageParams;
}
const generateUrl = (locale: string, slug: string) => {
if (locale === "en-US") {
return new URL(slug, process.env.NEXT_PUBLIC_BASE_URL!).toString();
} else {
return new URL(locale, process.env.NEXT_PUBLIC_BASE_URL!).toString();
}
};
const WebUrl = process.env.NEXT_PUBLIC_BASE_URL as string;
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const [PagedataSeo] = await Promise.all([
client.pageLanding({
slug: "/",
locale: params.locale.toString(),
preview: draftMode().isEnabled,
}),
]);
const landingPage = PagedataSeo.pageLandingCollection?.items[0];
if (!landingPage) {
return notFound();
}
const url = generateUrl(params.locale || "", "");
return {
title: landingPage.seoFields?.pageTitle,
description: landingPage.seoFields?.pageDescription,
metadataBase: new URL(WebUrl),
alternates: {
canonical: url,
languages: {
"en-US": "/",
"de-DE": "/de-DE",
"x-default": "/",
},
},
openGraph: {
type: "website",
siteName: "Example.dev - Free Tutorials and Resources for Developers",
locale: params.locale,
url: url || "",
title: landingPage.seoFields?.pageTitle || undefined,
description: landingPage.seoFields?.pageDescription || undefined,
images: landingPage.seoFields?.shareImagesCollection?.items.map(
(item) => ({
url: item?.url || "",
width: item?.width || 0,
height: item?.height || 0,
alt: item?.description || "",
type: item?.contentType || "",
})
),
},
robots: {
follow: landingPage.seoFields?.follow || false,
index: landingPage.seoFields?.index || false,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
},
},
};
}
async function Home({ params }: PageProps) {
const { isEnabled } = draftMode();
//declare JSON-LD schema
let jsonLd: WithContext<Article> = {} as WithContext<Article>;
const [landingPageData] = await Promise.all([
client.pageLanding({
slug: "/",
locale: params.locale.toString(),
preview: isEnabled,
}),
]);
const page = landingPageData.pageLandingCollection?.items[0];
if (!page) {
// If a blog post can't be found,
// tell Next.js to render a 404 page.
return notFound();
}
// TagCloud
const showTagCloud = page.showTagCloud === "Yes";
let { datanew, minSize, maxSize } = {
datanew: [],
minSize: 0,
maxSize: 0,
};
if (showTagCloud) {
const searchFacets = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/search/facets`,
{}
)
.then((res) => res.json())
.catch((error) => {
console.log("No data found");
});
if (searchFacets) {
maxSize = searchFacets.maxSize;
minSize = searchFacets.minSize;
datanew = searchFacets.datanew;
}
}
// Getting BlogPosts
const blogPostsData = await client.pageBlogPostCollection({
limit: 10,
locale: params.locale.toString(),
skip: 0,
preview: isEnabled,
order: PageBlogPostOrder.PublishedDateDesc,
where: {
slug_not: page?.featuredBlogPost?.slug,
},
});
const posts = blogPostsData.pageBlogPostCollection?.items;
// Create JSON-LD schema only if blogPost is available
if (page) {
jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: page?.seoFields?.pageTitle || undefined,
author: {
"@type": "Person",
name: page?.featuredBlogPost?.author?.name || undefined,
// The full URL must be provided, including the website's domain.
url: new URL(
path.join(
params.locale.toString() || "",
params.slug?.toString() || ""
),
process.env.NEXT_PUBLIC_BASE_URL!
).toString(),
},
publisher: {
"@type": "Organization",
name: "Example.dev - Free Tutorials and Resources for Developers",
logo: {
"@type": "ImageObject",
url: "https://www.example.dev/favicons/icon-192x192.png",
},
},
image: page?.featuredBlogPost?.featuredImage?.url || undefined,
datePublished: page.sys.firstPublishedAt,
dateModified: page.sys.publishedAt,
};
}
// Internationalization, get the translation function
const { t } = await createTranslation(params.locale as LocaleTypes, "common");
const highLightHeadings: any = page.textHighlightCollection?.items[0];
if (!page?.featuredBlogPost || !posts) return;
return (
<>
{page && (
<Script
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd),
}}
/>
)}
<Container className="mt-5">
{highLightHeadings && <TextHighLight headings={highLightHeadings} />}
{showTagCloud && datanew.length > 0 && (
<TagCloudSimpleHome
datanew={datanew}
minSize={minSize * 10}
maxSize={maxSize * 5}
locale={params.locale.toString()}
source={"homepage"}
/>
)}
<Link
href={`/${params.locale.toString()}/${page.featuredBlogPost.slug}`}
>
<ArticleHero article={page.featuredBlogPost} isHomePage={true} />
</Link>
<div className="md:mx-24 md:my-24 sm:mx-16 sm:my-16">
<LandingContent landing={page} />
</div>
</Container>
<Container className="my-8 md:mb-10 lg:mb-16">
{posts.length > 0 && (
<h2 className="mb-4 md:mb-6">{t("landingPage.latestArticles")}</h2>
)}
<ArticleTileGrid
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
articles={posts}
slug={page.featuredBlogPost.slug}
locale={params.locale.toString()}
/>
</Container>
</>
);
}
export default Home;On line 154, we introduce the “skip” parameter, and in the last section of the file, we have to pass two new parameters (slug and locale) to our “ArticleTileGrid” component.
<ArticleTileGrid
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
articles={posts}
slug={page.featuredBlogPost.slug}
locale={params.locale.toString()}
/>Slug is needed to exclude the BlogPosts in the Hero section of the page so that we don’t show it twice, and a locale is needed to handle the multilanguage part.
ArticleTileGrid - Where the logic resides
Last but not least, we adapt the “ArticleTileGrid” component, where most of the magic happens.
"use client";
import { HTMLProps } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "@tremor/react";
import { ArticleTile } from "@/components/contentful/ArticleTile";
import { PageBlogPostFieldsFragment } from "@/lib/__generated/sdk";
import { useInView } from "react-intersection-observer";
import { useState, useEffect } from "react";
import { getPosts } from "@/actions/getPosts";
interface ArticleTileGridProps extends HTMLProps<HTMLDivElement> {
articles?: Array<PageBlogPostFieldsFragment | null>;
slug: string | null | undefined;
locale: string;
}
const NUMBER_OF_USERS_TO_FETCH = 10;
export const ArticleTileGrid = ({
articles,
className,
slug,
locale,
...props
}: ArticleTileGridProps) => {
const [offset, setOffset] = useState(NUMBER_OF_USERS_TO_FETCH);
const [posts, setPosts] = useState<any>(articles);
const { ref, inView } = useInView();
const loadMoreUsers = async () => {
const apiPosts = await getPosts(
offset,
NUMBER_OF_USERS_TO_FETCH,
locale || "",
false,
slug || ""
);
setPosts([...posts, ...apiPosts]);
setOffset(offset + NUMBER_OF_USERS_TO_FETCH);
};
//Infinte scroll
// useEffect(() => {
// if (inView) {
// loadMoreUsers();
// }
// }, [inView]);
return posts && posts.length > 0 ? (
<>
<div
className={twMerge(
"grid grid-cols-1 gap-y-4 gap-x-5 md:grid-cols-4 lg:gap-x-12 lg:gap-y-12",
className
)}
{...props}
>
{posts.map((article: any, index: number) => {
return article ? <ArticleTile key={index} article={article} /> : null;
})}
</div>
{/* Infinite Scroll */}
{/* <div ref={ref}>Loading...</div> */}
<div className="flex flex-col items-center">
<Button
onClick={loadMoreUsers}
size="lg"
className="mt-4 sm:flex sm:max-w-md"
>
Load more
</Button>
</div>
</>
) : null;
};In this component, you can decide whether to use the “Load more” button or the “Infinite Scroll” method.

Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:



