Inhaltsverzeichnis
- Warum Sie Pagination in Next.js für SEO verwenden sollten
- Utils Datei für die Paginierung
- Neue Paginierung Komponente
- Integration der neuen Paginierungskomponente in die bestehende ArticleTileGrid-Komponente
- Neues Gerüst für Suspense
- Integration in die page.tsx
- Kompletter Code der page.tsx
- Endergebnis
- Cloudapp-dev und bevor Sie uns verlassen
In einem früheren Artikel habe ich die Verwendung des unendlichen Scrollens zur Verbesserung der UX Ihres Blogs oder Ihrer Website hervorgehoben. Wie wir jedoch wissen, ist Google Crawler kein großer Fan von solchen Funktionen, da er kein Javascript verwenden kann. Daher müssen wir eine Crawler-freundliche Lösung hinzufügen, um alle Seiten richtig zu finden und zu indizieren.
Hier ist das GitHub repo mit dem vollständigen Code, in dem Sie die beiden neuen Komponenten sehen können.
Example page hosted on Vercel -> https://nextjs14-azureb2c-prisma.vercel.app/
Warum Sie Pagination in Next.js für SEO verwenden sollten
Paginierung in Next.js für SEO zu verwenden, hat mehrere Vorteile. Hier sind einige Gründe, warum Paginierung wichtig ist:
1. Verbesserte Crawling-Effizienz
Suchmaschinen-Crawler wie Googlebot haben ein begrenztes Crawl-Budget pro Website. Paginierung hilft, den Crawler effizient durch die Inhalte Ihrer Website zu führen, indem sie große Datenmengen in handhabbare Abschnitte aufteilt. Dadurch können Suchmaschinen-Crawler mehr Inhalte Ihrer Website entdecken und indexieren.
2. Verbesserte Nutzererfahrung (UX)
Eine gut implementierte Paginierung verbessert die Benutzererfahrung erheblich, indem sie es den Nutzern ermöglicht, leicht durch große Mengen an Inhalten zu navigieren. Eine bessere Benutzererfahrung kann indirekt die SEO-Rankings beeinflussen, da Suchmaschinen Websites bevorzugen, die den Nutzern eine positive Erfahrung bieten.
Utils Datei für die Paginierung
Beginnen wir mit der Erstellung einer neuen Datei pagination.ts utils unter 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,
];
};Neue Paginierung Komponente
Ich werde die utils-Datei innerhalb der neuen Komponente pagination.component.tsx verwenden. Ich werde die "usePathname" und "useSearchParams" Import verwenden, um die Seite Searchparam aus der URL zu erhalten, die ich brauche, um die aktuelle Seite zu identifizieren.
"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 der neuen Paginierungskomponente in die bestehende ArticleTileGrid-Komponente
In diesem Beispiel verwende ich neben der Paginierung die Funktion "Mehr laden", um die Verwendung von "Server Actions" zu zeigen, aber Sie können die entsprechenden Codeabschnitte in src/components/contentful/ArticleTileGrid.tsx auskommentieren, um das unendliche Scrollen zu ermöglichen. Es gibt drei Teile.
//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;
}Neues Gerüst für Suspense
Ich habe eine Skelettdatei unter src/components/pagination mit dem Namen skeleton.component.tsx erstellt
// 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 die page.tsx
Neue Importe
import { TableSkeleton } from "@/components/pagination/skeleton.component";
import { Suspense } from "react";Neue Schnittstelle SearchParamsProps und Anpassung der PageProps Schnittstelle
interface SearchParamsProps {
query?: string;
page?: string;
}
interface PageProps {
params: PageParams;
searchParams?: SearchParamsProps;
}Anpassen der Übergabe Parameter an die main function
async function Home({ params, searchParams }: PageProps) {Definieren der Variablen für fetch and currentpage
const NUMBER_TO_FETCH = 10;
const currentPage = Number(searchParams?.page) || 1;Anpassung der Contentful GraphQL-Abfrage
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;Verwendung von Suspense und dem Fallback (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>Kompletter Code der page.tsx
page.tsx (Home) im Verzeichnis 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;Endergebnis

Cloudapp-dev und bevor Sie uns verlassen
Danke, dass Sie bis zum Ende gelesen haben. Noch eine Bitte bevor Sie gehen:




