Table of Contents
In a previous story, I highlighted the use of infinite scrolling to improve the UX of your blog or website. However, as we know, Google Crawler is not a big fan of such features because it can’t use Javascript. Therefore, we need to add a crawler-friendly solution to find and index all pages properly.
Here is the GitHub repo with the full code, where you can see the two new components.
Beispielseite (Gehostet auf Vercel -> https://nextjs14-azureb2c-prisma.vercel.app/
Why You Should Use Pagination in Next.js for SEO
Using pagination in Next.js for SEO has several benefits. Here are some reasons why pagination is important and how it can improve SEO:
1. Improved Crawling Efficiency
Search engine crawlers like Googlebot have a limited crawl budget per website. Pagination helps guide the crawler efficiently through your site's content by breaking large amounts of data into manageable sections. This allows search engine crawlers to discover and index more content on your site.
2. Enhanced User Experience (UX)
A well-implemented pagination significantly improves the user experience by allowing users to easily navigate through large volumes of content. A better user experience can indirectly influence SEO rankings since search engines prefer websites that offer a positive user experience.
Utils File for Pagination
Let’s start with the creation of a new pagination.ts utils file under src/utils
//src/utils/pagination.ts
export const generatePagination = (currentPage: number, totalPages: number) => {
// If the total number of pages is 7 or less,
// display all pages without any ellipsis.
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// If the current page is among the first 3 pages,
// show the first 3, an ellipsis, and the last 2 pages.
if (currentPage <= 3) {
return [1, 2, 3, "...", totalPages - 1, totalPages];
}
// If the current page is among the last 3 pages,
// show the first 2, an ellipsis, and the last 3 pages.
if (currentPage >= totalPages - 2) {
return [1, 2, "...", totalPages - 2, totalPages - 1, totalPages];
}
// If the current page is somewhere in the middle,
// show the first page, an ellipsis, the current page and its neighbors,
// another ellipsis, and the last page.
return [
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages,
];
};New Pagination Component
I will use the utils file within the new component pagination.component.tsx. I will use the “usePathname” and “useSearchParams” import to get the page Searchparam from the URL, which I need to identify the current page.
"use client";
import Link from "next/link";
import clsx from "clsx";
import { generatePagination } from "@/utils/pagination";
import { usePathname, useSearchParams } from "next/navigation";
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get("page")) || 1;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
const allPages = generatePagination(currentPage, Math.ceil(totalPages / 10));
return (
<div className="flex justify-center my-4">
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>
<div className="flex -space-x-px">
{allPages.map((page, index) => {
let position: "first" | "last" | "single" | "middle" | undefined;
if (index === 0) position = "first";
if (index === allPages.length - 1) position = "last";
if (allPages.length === 1) position = "single";
if (page === "...") position = "middle";
return (
<PaginationNumber
key={`${page}-${index}`}
href={createPageURL(page)}
page={page}
position={position}
isActive={currentPage === page}
/>
);
})}
</div>
<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= totalPages / 10}
/>
</div>
);
}
function PaginationNumber({
page,
href,
isActive,
position,
}: {
page: number | string;
href: string;
position?: "first" | "last" | "middle" | "single";
isActive: boolean;
}) {
const className = clsx(
"flex h-10 w-10 items-center justify-center text-sm border",
{
"rounded-l-md": position === "first" || position === "single",
"rounded-r-md": position === "last" || position === "single",
"z-10 bg-blue-600 border-blue-600 text-white": isActive,
"hover:bg-gray-100": !isActive && position !== "middle",
"text-gray-300": position === "middle",
}
);
return isActive || position === "middle" ? (
<div className={className}>{page}</div>
) : (
<Link href={href} className={className}>
{page}
</Link>
);
}
function PaginationArrow({
href,
direction,
isDisabled,
}: {
href: string;
direction: "left" | "right";
isDisabled?: boolean;
}) {
const className = clsx(
"flex h-10 w-10 items-center justify-center rounded-md border",
{
"pointer-events-none text-gray-300": isDisabled,
"hover:bg-gray-100": !isDisabled,
"mr-2 md:mr-4": direction === "left",
"ml-2 md:ml-4": direction === "right",
}
);
const icon =
direction === "left" ? (
<ArrowLeftIcon className="w-4" />
) : (
<ArrowRightIcon className="w-4" />
);
return isDisabled ? (
<div className={className}>{icon}</div>
) : (
<Link className={className} href={href}>
{icon}
</Link>
);
}Integration of new Pagination Component into existing ArticleTileGrid Component
In this example, I use the “load more” functionality besides the pagination to show the use of server actions, but you can uncomment the corresponding parts in the src/components/contentful/ArticleTileGrid.tsx to make the infinite scroll work. There are three parts.
//Part1 - Import Section
import { useInView } from "react-intersection-observer";
//Part2 - Const Def
const { ref, inView } = useInView();
//Part3 - Use Effect prior return
useEffect(() => {
if (inView) {
loadMoreUsers();
}
}, [inView]);
// Please comment the load more functionality in case you decide
// to go for the infinite scroll"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";
import Pagination from "@/components/pagination/pagination.component";
import { useSearchParams } from "next/navigation";
interface ArticleTileGridProps extends HTMLProps<HTMLDivElement> {
articles?: Array<PageBlogPostFieldsFragment | null>;
postCount?: number;
slug: string | null | undefined;
locale: string;
source: string;
}
const NUMBER_OF_USERS_TO_FETCH = 10;
export default function ArticleTileGrid({
articles,
postCount,
className,
slug,
locale,
source,
...props
}: ArticleTileGridProps) {
const [offset, setOffset] = useState(NUMBER_OF_USERS_TO_FETCH);
const [posts, setPosts] = useState<any>(articles);
// Infinte scroll
// const { ref, inView } = useInView();
//Pagination
const totalPages = postCount || 0;
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get("page")) || 0;
const loadMorePosts = async () => {
const apiPosts = await getPosts(
offset,
NUMBER_OF_USERS_TO_FETCH,
locale || "",
false,
source || "",
slug || ""
);
setPosts([...posts, ...apiPosts.posts]);
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> */}
{/* Load More Button */}
{source !== "relatedposts" && (
<>
<div className="flex flex-col items-center">
<Button
onClick={loadMorePosts}
disabled={currentPage > 0 || offset >= totalPages}
size="lg"
className="mt-4 sm:flex sm:max-w-md"
>
Load more
</Button>
</div>
{source !== "tag" && <Pagination totalPages={totalPages} />}
</>
)}
</>
) : null;
}New Skeleton for Suspense
I created a skeleton file under src/components/pagination called skeleton.component.tsx
// Loading animation
const shimmer =
"before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent";
export function CardSkeleton() {
return (
<div
className={`${shimmer} relative overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`}
>
<div className="flex p-4">
<div className="h-5 w-5 rounded-md bg-gray-200" />
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" />
</div>
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8">
<div className="h-7 w-20 rounded-md bg-gray-200" />
</div>
</div>
);
}
export function CardsSkeleton() {
return (
<>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</>
);
}
export function RevenueChartSkeleton() {
return (
<div className={`${shimmer} relative w-full overflow-hidden md:col-span-4`}>
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
<div className="rounded-xl bg-gray-100 p-4">
<div className="sm:grid-cols-13 mt-0 grid h-[410px] grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4" />
<div className="flex items-center pb-2 pt-6">
<div className="h-5 w-5 rounded-full bg-gray-200" />
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
</div>
</div>
</div>
);
}
export function TableRowSkeleton() {
return (
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg">
{/* Customer Name and Image */}
<td className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-gray-100"></div>
<div className="h-6 w-24 rounded bg-gray-100"></div>
</div>
</td>
{/* Email */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-32 rounded bg-gray-100"></div>
</td>
{/* Amount */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Date */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Status */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Actions */}
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex justify-end gap-3">
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div>
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div>
</div>
</td>
</tr>
);
}
export function MobileSkeleton() {
return (
<div className="mb-2 w-full rounded-md bg-white p-4">
<div className="flex items-center justify-between border-b border-gray-100 pb-8">
<div className="flex items-center">
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100"></div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
</div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
</div>
<div className="flex w-full items-center justify-between pt-4">
<div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
<div className="mt-2 h-6 w-24 rounded bg-gray-100"></div>
</div>
<div className="flex justify-end gap-2">
<div className="h-10 w-10 rounded bg-gray-100"></div>
<div className="h-10 w-10 rounded bg-gray-100"></div>
</div>
</div>
</div>
);
}
export function TableSkeleton() {
return (
<div className="mt-6 flow-root">
<div className="inline-block min-w-full align-middle">
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
<MobileSkeleton />
<MobileSkeleton />
<MobileSkeleton />
<MobileSkeleton />
<MobileSkeleton />
<MobileSkeleton />
</div>
<table className="hidden min-w-full text-gray-900 md:table">
<thead className="rounded-lg text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Customer
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Amount
</th>
<th scope="col" className="px-3 py-5 font-medium">
Date
</th>
<th scope="col" className="px-3 py-5 font-medium">
Status
</th>
<th
scope="col"
className="relative pb-4 pl-3 pr-6 pt-2 sm:pr-6"
>
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white">
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
<TableRowSkeleton />
</tbody>
</table>
</div>
</div>
</div>
);
}Integration in page.tsx
New Imports
import { TableSkeleton } from "@/components/pagination/skeleton.component";
import { Suspense } from "react";New Interface SearchParamsProps and adaption of PageProps Interface
interface SearchParamsProps {
query?: string;
page?: string;
}
interface PageProps {
params: PageParams;
searchParams?: SearchParamsProps;
}Change Props main function
async function Home({ params, searchParams }: PageProps) {Defining Const for fetch and currentpage
const NUMBER_TO_FETCH = 10;
const currentPage = Number(searchParams?.page) || 1;Modifying Contentful GraphQL-Query
const newOffset = Number(currentPage - 1) * NUMBER_TO_FETCH;
const blogPostsData = await client.pageBlogPostCollection({
limit: 10,
locale: params.locale.toString(),
skip: newOffset,
preview: isEnabled,
order: PageBlogPostOrder.PublishedDateDesc,
where: {
slug_not: page?.featuredBlogPost?.slug,
},
});
const posts = blogPostsData.pageBlogPostCollection?.items;
const postCount = blogPostsData.pageBlogPostCollection?.total;Usage of Suspense and the Skeleton
<Suspense key={currentPage} fallback={<TableSkeleton />}>
<ArticleTileGrid
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
articles={posts}
postCount={postCount}
slug={page.featuredBlogPost.slug}
source="loadmore"
locale={params.locale.toString()}
/>
</Suspense>Complete page.tsx Code
page.tsx (Home) under src/app/[locale]/page.tsx
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";
import { TableSkeleton } from "@/components/pagination/skeleton.component";
import { Suspense } from "react";
export const revalidate = revalidateDuration; // revalidate at most every hour
export const dynamic = "force-dynamic";
interface PageParams {
slug: string;
locale: string;
}
interface SearchParamsProps {
query?: string;
page?: string;
}
interface PageProps {
params: PageParams;
searchParams?: SearchParamsProps;
}
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, searchParams }: 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 NUMBER_OF_USERS_TO_FETCH = 10;
const currentPage = Number(searchParams?.page) || 1;
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;
}
}
const newOffset = Number(currentPage - 1) * NUMBER_OF_USERS_TO_FETCH;
// Getting BlogPosts
const blogPostsData = await client.pageBlogPostCollection({
limit: 10,
locale: params.locale.toString(),
skip: newOffset,
preview: isEnabled,
order: PageBlogPostOrder.PublishedDateDesc,
where: {
slug_not: page?.featuredBlogPost?.slug,
},
});
const posts = blogPostsData.pageBlogPostCollection?.items;
const postCount = blogPostsData.pageBlogPostCollection?.total;
// 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>
)}
<Suspense key={currentPage} fallback={<TableSkeleton />}>
<ArticleTileGrid
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
articles={posts}
postCount={postCount}
slug={page.featuredBlogPost.slug}
source="loadmore"
locale={params.locale.toString()}
/>
</Suspense>
</Container>
</>
);
}
export default Home;Final Result

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




