Loading...
Nextjs14-infinite-scroll-server-actions
Author Cloudapp
E.G.

Next.js 14 - Endless Scroll mit Contentful und Server Actions

8. Juni 2024
Inhaltsverzeichnis

Wenn Ihr Blog wächst, brauchen Sie eine Funktion wie "Mehr laden" oder sogar "Unendliches Scrollen", damit Ihre Besucher leicht auf alle wertvollen Inhalte zugreifen können. In diesem Beitrag werde ich mich darauf konzentrieren und Ihnen zeigen, wie einfach es ist, eine solche Funktion mit Next.js 14, Server Actions (um die schwere Arbeit auf den Server zu verlagern) und Contentful als zugrunde liegendes CMS, in dem wir alle unsere Daten haben, zu implementieren.

Hier ist das GitHub repo mit dem vollständigen Code.

Hier der Link zur Testseite, sodass Sie sich bereits einen Überblick verschaffen können.

https://nextjs14-azureb2c-prisma.vercel.app/

Neues NPM Paket

Lassen Sie uns das neue NPM-Paket "react-intersection-observer" installieren, welches wir für das "Endless Scroll" benötigen.

npm i react-intersection-observer

Next.js 14 Server Action - Fürs Abrufen der Posts aus Contentful

Nun erstelle ich einen neue Datei im Verzeichnis “src/actions” mit dem Namen “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}`);
  }
};

Wie Sie sehen können, verwenden wir die Direktive „use server“, die Next.js anweist, sie nur auf dem Server auszuführen.

Server Actions kurz erklärt

Server Actions sind asynchrone Funktionen, die auf dem Server ausgeführt werden. Sie können in Server- und Client-Komponenten verwendet werden, um Formularübermittlungen und Datenmutationen in Next.js-Anwendungen zu handhaben.

Da diese speziellen Funktionen nur auf dem Server ausgeführt werden, können Entwickler Verantwortlichkeiten wie das Abrufen und Mutieren von Daten an diese auslagern, wodurch die Schwachstellen und Sicherheitsbedenken vermieden werden, die beim Abrufen und Mutieren von Daten vom Client aus auftreten.

Contentful GraphQL- Nutzung eines Offsets mit Hilfe des Skip Parameters

Als Nächstes passe ich die zugrunde liegende GraphQL-Datei für das Abrufen der „posts“-Daten von Contentful an. Ich habe die Variable „skip“ hinzugefügt, damit wir beim Datenabruf mit einem Offset arbeiten können.

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
    }
  }
}

Anpassen der Logik der page.tsx Datei

Die nächste Datei, die angepasst werden muss, ist die page.tsx unter „src/app/[locale]“

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;

In Zeile 154 führen wir den Parameter „skip“ ein, und im letzten Abschnitt der Datei müssen wir zwei neue Parameter (slug und locale) an unsere „ArticleTileGrid“-Komponente übergeben.

        <ArticleTileGrid
          className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
          articles={posts}
          slug={page.featuredBlogPost.slug}
          locale={params.locale.toString()}
        />

Der Slug Parameter wird benötigt, um die BlogPosts im Hero-Abschnitt der Seite auszuschließen, damit wir sie nicht doppelt anzeigen, und ein Locale wird benötigt, um den mehrsprachigen Teil zu handhaben.

ArticleTileGrid - Wo die Logik beheimatet ist

Zu guter Letzt passen wir die Komponente „ArticleTileGrid“ an, wo das meiste der Magie passiert..

"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 dieser Komponente können Sie entscheiden, ob Sie die Schaltfläche „Mehr laden“ oder die Methode „Unendliches Scrollen“ verwenden möchten.

Load more Server Actions
Load more Server Actions

Cloudapp-dev und bevor Sie uns verlassen

Danke, dass Sie bis zum Ende gelesen haben. Noch eine Bitte bevor Sie gehen:

Wenn Ihnen gefallen hat was Sie gelesen haben oder wenn es Ihnen sogar geholfen hat, dann würden wir uns über einen "Clap" 👏 oder einen neuen Follower auf unseren Medium Account sehr freuen.

Verwandte Artikel