Loading...
Algolia-Resync
Author Cloudapp
E.G.

Next.js 14 -Datenmodell Erweiterung und Contentful Datensync mit API Route

13. August 2024
Inhaltsverzeichnis

Wir werden den Prozess einer vollständigen Datenneusynchronisierung zwischen Contentful und Algolia mit zwei benutzerdefinierten API-Routen in Nextjs 14 durchgehen und auch das Algolia-Datenmodell erweitern. Die Geschichte behandelt den vollständigen Code und die Konfiguration im Algolia-Backend.

Hier ist das GitHub Repo mit dem gesamten Code und darunter der Link zur Beispielwebsite.

Beispielseite für mit integrierter Datenerfassung -> https://nextjs14-advanced-algoliasearch.vercel.app/

Verwendeter Stack

Ich werde mit meinem Standard-Stack beginnen:

  • Next.js 14 als Web-Framework

  • TailwindCss for Styling

  • Contentful CMS (Kostenloses Abo)

  • Algolia als Such-Engine

  • Vercel für das Hosting

Neue API Route für das Abrufen der Contentful Daten

In einem ersten Schritt habe ich eine neue API-Route erstellt, die alle Blogbeiträge von der Contentful-GraphQL-Schnittstelle abruft. Der Einfachheit halber erhalte ich alle Beiträge ohne Filterung (Where-Klausel in der GraphQL-Query). Wenn jemand ein Beispiel aus der Praxis braucht, bitte kommentieren und ich werde es zur Verfügung stellen.

I used 40 as a limit, but you can use what you need.

import { NextRequest, NextResponse } from "next/server";
import { client } from "@/lib/client";
import { fallbackLng, locales } from "@/app/i18n/settings";
import { PageBlogPostOrder } from "@/lib/__generated/sdk";

const apikey = process.env.API_KEY;

export async function POST(request: NextRequest) {
  if (request.headers.get("x-api-key") !== apikey) {
    return new NextResponse(
      JSON.stringify({ status: "fail", message: "You are not authorized" }),
      { status: 401 }
    );
  }

//fallbackLng is in our case en-US
  const locale = fallbackLng as string;

  // Getting BlogPosts
  const blogPostsData = await client.pageBlogPostCollectionSmall({
    limit: 40,
    locale: locale,
    skip: 0,
    order: PageBlogPostOrder.PublishedDateDesc,
    preview: false,
  });
  const posts = blogPostsData.pageBlogPostCollection?.items;
//getting the total count 
  const postCount = blogPostsData.pageBlogPostCollection?.total;

  if (!posts) {
    return new NextResponse(
      JSON.stringify({ status: "fail", message: "Blog posts not found" }),
      { status: 404 }
    );
  }

  const UpdateUrl = process.env.NEXT_PUBLIC_BASE_URL + "/api/algoliasync";

  const headers = {
    "Content-Type": "application/json; charset=utf-8",
    "x-api-key": process.env.API_KEY || "",
  };

  posts.map(async (post) => {
  // new const with slug attribute
    const jsonData = {
      slug: post?.slug,
    };

    // passing slug attribute to AlgoliaSync Route

    const res = await fetch(UpdateUrl, {
      method: "POST",
      body: JSON.stringify(jsonData),
      headers: headers,
    });

    if (!res.ok) {
      const text = await res.text(); // get the response body for more information

      throw new Error(`
        Failed to fetch data
        Status: ${res.status}
        Response: ${text}
      `);
    }

    const result: any = await res.json();
  });

  return NextResponse.json(
    { posts: posts, postCount: postCount },
    { status: 200 }
  );
}

Neue leichtgewichtige GraphQL-Abfrage des Slug-Attributs

Um die Belastung der Contentful-API zu reduzieren, habe ich eine neue GraphQL-Query (pageBlogPostCollectionSmall.graphql) erstellt, die nur das Attribut "internalName„ und das Attribut "slug“ abfragt, so dass ich den Overhead stark reduzieren kann.

fragment PageBlogPostFieldsSmall on PageBlogPost {
  internalName
  slug
}

query pageBlogPostCollectionSmall(
  $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
  ) {
    total
    items {
      ...PageBlogPostFieldsSmall
    }
  }
}

Übergeben des Slug-Attributes an die AlgoliaSync API Route

Nach der Rückgabe der Daten von Contentful verwenden wir das Attribut „Slug“ aus der Antwort, um es an die zweite API-Route „/api/algoliasync“ weiterzugeben. Diese API verwendet das Slug-Attribut im POST-Body, um alle Details für jeden Blogpost zu erhalten, die wir dann verwenden, um eine PUT-Anfrage an den Algolia-Dienst zu stellen, um den entsprechenden Datensatz/Eintrag zu erstellen oder zu aktualisieren.

import { NextRequest, NextResponse } from "next/server";
import { documentToPlainTextString } from "@contentful/rich-text-plain-text-renderer";
import { client } from "@/lib/client";
import { fallbackLng, locales } from "@/app/i18n/settings";
import { title } from "process";

const apikey = process.env.API_KEY;

export async function POST(request: NextRequest) {
  const document = await request.json();
  let json_de: string = "";
  let json_en: string = "";

  if (request.headers.get("x-api-key") !== apikey) {
    return new NextResponse(
      JSON.stringify({ status: "fail", message: "You are not authorized" }),
      { status: 401 }
    );
  }

  const locale = fallbackLng as string;

  const blogPagedata = await client.pageBlogPost({
    locale,
    preview: false,
    slug: document.slug,
  });

  const blogPost = blogPagedata.pageBlogPostCollection?.items[0];

  if (!blogPost) {
    return new NextResponse(
      JSON.stringify({ status: "fail", message: "Blog post not found" }),
      { status: 404 }
    );
  }

  json_en = documentToPlainTextString(blogPost.content?.json);
  const tags = blogPost.contentfulMetadata?.tags;
  const tagsnew: any = [];

  tags.map((tag) => {
    // console.log("tag", tag.id, tag.name);
    if (tag) tagsnew.push(tag.id);
  });

  const blogPagedata_de = await client.pageBlogPost({
    locale: "de-DE",
    preview: false,
    slug: document.slug,
  });

  const blogPost_de = blogPagedata_de.pageBlogPostCollection?.items[0];

  if (!blogPost_de) {
    return new NextResponse(
      JSON.stringify({
        status: "fail",
        message: "Blog post for language de-DE not found",
      }),
      { status: 404 }
    );
  }
  json_de = documentToPlainTextString(blogPost_de.content?.json);

  const AlgoliaUrl =
    "https://" +
    process.env.NEXT_PUBLIC_ALGOLIA_APP_ID +
    ".algolia.net/1/indexes/" +
    process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME +
    "/" +
    blogPost.sys.id;

  const date = blogPost.publishedDate;
  const formatDate = new Date(date).toISOString().substring(0, 10);
  const timeStamp = new Date(date).getTime() / 1000;

  const jsonData: any = {
    entityId: blogPost.sys.id,
    height: blogPost?.featuredImage?.height?.toString() || "0",
    width: blogPost?.featuredImage?.width?.toString() || "0",
    image: blogPost?.featuredImage?.url,
    intName: blogPost.internalName,
    lang_de: json_de,
    lang_en: json_en,
    short_de: blogPost_de.shortDescription,
    short_en: blogPost.shortDescription,
    title_de: blogPost_de.title,
    title_en: blogPost.title,
    pubdate: formatDate,
    pubdatetimestamp: timeStamp,
    slug: document.slug,
    spaceId: blogPost.sys.spaceId,
    tags: tagsnew,
  };

  const headers = {
    "Content-Type": "application/json; charset=utf-8",
    "X-Algolia-API-Key": process.env.ALGOLIA_MASTER_KEY || "",
    "X-Algolia-Application-Id": process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || "",
  };

  const res = await fetch(AlgoliaUrl, {
    method: "PUT",
    body: JSON.stringify(jsonData),
    headers: headers,
  });

  if (!res.ok) {
    const text = await res.text(); // get the response body for more information

    throw new Error(`
      Failed to fetch data
      Status: ${res.status}
      Response: ${text}
    `);
  }

  const result: any = await res.json();
  // console.log("result", result);

  return NextResponse.json(
    { transformed: true, date: Date.now(), slug: document.slug },
    { status: 200 }
  );
}

Definition des JSON Data Body für die Algoliaindizierung

Wir sind völlig flexibel, was den Data Body betrifft, den wir für Algolia erstellen. Algolia ist ein schemaloser Index, so dass wir Attribute hinzufügen und entfernen können.

const jsonData: any = {
    entityId: blogPost.sys.id,
    height: blogPost?.featuredImage?.height?.toString() || "0",
    width: blogPost?.featuredImage?.width?.toString() || "0",
    image: blogPost?.featuredImage?.url,
    intName: blogPost.internalName,
    lang_de: json_de,
    lang_en: json_en,
    short_de: blogPost_de.shortDescription,
    short_en: blogPost.shortDescription,
    title_de: blogPost_de.title,
    title_en: blogPost.title,
    pubdate: formatDate,
    pubdatetimestamp: timeStamp,
    slug: document.slug,
    spaceId: blogPost.sys.spaceId,
    tags: tagsnew,
  };

Verwendung der neuen Attribute im Frontend

Wir passen unseren Typ CardProps an und fügen die sechs neuen Attribute hinzu,

    lang_de: string;
    lang_en: string;
    short_de: string;
    short_en: string;
    title_de: string;
    title_en: string;

dann überprüfe ich die Sprache mit fallbackLng („en-US“), so dass ich das richtige Gebietsschema anzeigen kann. Oben verwende ich das Widget „Highlight“ für das Attribut "Titel" und darunter das Attribut "short" für das Widget „Snippet“.

          {locale === fallbackLng ? (
            <Link href={`/${locale}/${result.slug}`}>
              <p className="mb-2 h3 line-clamp-2 text-gray-800 dark:text-[#AEC1CC] md:mb-3">
                <Highlight attribute="title_en" hit={result} />
              </p>
            </Link>
          ) : (
            <Link href={`/${locale}/${result.slug}`}>
              <p className="mb-2 h3 line-clamp-2 text-gray-800 dark:text-[#AEC1CC] md:mb-3">
                <Highlight attribute="title_de" hit={result} />
              </p>
            </Link>
          )}

          {locale === fallbackLng ? (
            <p className="mt-2 text-base line-clamp-2">
              <Snippet attribute="short_en" hit={result} />
            </p>
          ) : (
            <p className="mt-2 text-base line-clamp-2">
              <Snippet attribute="short_de" hit={result} />
            </p>
          )}

Kompletter Code der CardAlgolia Komponente

"use client";

import Image from "next/image";
import Link from "next/link";
import { FormatDate } from "@/components/contentful/format-date/FormatDate";
import { useParams } from "next/navigation";
import type { LocaleTypes } from "@/app/i18n/settings";
import { twMerge } from "tailwind-merge";
import { ArticleLabel } from "@/components/contentful/ArticleLabel";
import { Hit as AlgoliaHit } from "instantsearch.js";
import { Highlight, Snippet } from "react-instantsearch";
import { fallbackLng } from "@/app/i18n/settings";

type CardProps = {
  result: AlgoliaHit<{
    intName: string;
    image: string;
    pubdate: Date;
    slug: string;
    width: number;
    height: number;
    tags: string[];
    lang_de: string;
    lang_en: string;
    short_de: string;
    short_en: string;
    title_de: string;
    title_en: string;
  }>;
};

export default function CardAlgolia({ result }: CardProps) {
  const locale = useParams()?.locale as LocaleTypes;

  const className = "md:grid-cols-2 lg:grid-cols-3";
  const classNameImage = "object-cover aspect-[16/10] w-full";

  const blurURL = new URL(result.image);
  blurURL.searchParams.set("w", "10");

  return (
    // {/* group - wird benötigt damit man unten im Classname darauf verweisen kann mit group-hover:.... */}
    <div className="flex flex-col">
      <div
        className={twMerge(
          "flex flex-1 flex-col overflow-hidden dark:shadow-white shadow-lg dark:shadow-sm-light",
          className
        )}
      >
        <Link href={`/${locale}/${result.slug}`}>
          <Image
            src={result.image}
            width={result.width || 722}
            height={result.height || 590}
            sizes="(max-width: 1200px) 100vw, 50vw"
            placeholder="blur"
            blurDataURL={blurURL.toString()}
            alt={
              (locale == fallbackLng ? result.title_en : result.title_de) || ""
            }
            className={twMerge(classNameImage, "transition-all")}
          ></Image>
        </Link>
        <div className="flex flex-col flex-1 px-4 py-3 dark:bg-gray-800 md:px-5 md:py-4 lg:px-7 lg:py-5">
          {locale === fallbackLng ? (
            <Link href={`/${locale}/${result.slug}`}>
              <p className="mb-2 h3 line-clamp-2 text-gray-800 dark:text-[#AEC1CC] md:mb-3">
                <Highlight attribute="title_en" hit={result} />
              </p>
            </Link>
          ) : (
            <Link href={`/${locale}/${result.slug}`}>
              <p className="mb-2 h3 line-clamp-2 text-gray-800 dark:text-[#AEC1CC] md:mb-3">
                <Highlight attribute="title_de" hit={result} />
              </p>
            </Link>
          )}

          {locale === fallbackLng ? (
            <p className="mt-2 text-base line-clamp-2">
              <Snippet attribute="short_en" hit={result} />
            </p>
          ) : (
            <p className="mt-2 text-base line-clamp-2">
              <Snippet attribute="short_de" hit={result} />
            </p>
          )}

          <div className="flex flex-wrap max-w-2xl gap-2 mr-auto">
            {result.tags.map((tag: string, index) => (
              <Link href={`/${locale}/search/${tag}`} key={index}>
                <ArticleLabel className="flex items-center ml-1">
                  {tag}
                </ArticleLabel>
              </Link>
            ))}
            <div className={twMerge("ml-auto pl-2 text-xs text-gray-600")}>
              <FormatDate date={result.pubdate} />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

Frontend mit Highlight- und Snippet-Komponente

Im folgenden Screenshot sehen Sie die beiden Widgets mit unseren neuen Attributen in Aktion.

Example-Blog
Example-Blog

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.

Oder folgen Sie uns auf Twitter -> Cloudapp.dev

Verwandte Artikel