Loading...
contentful-tags-nextjs14
Author Cloudapp
E.G.

Next.js 14 - Arbeiten mit Contentful Tags und TailwindCss

7. Mai 2024
Inhaltsverzeichnis

Contentful bietet ein großartiges Tagging-System. In diesem Beitrag zeige ich Ihnen, wie wir es nutzen können, um Inhaltsseiten nach Tags zu gruppieren und neue "Tag-Seiten" zu erstellen, die Inhalte zu diesen Tags anzeigen. Wir fügen einen neuen Inhaltstyp und eine Route hinzu, um die Tags von Contentful abzurufen. Ich werde mich auf die Graphql-API von Contentful für das Abrufen von Inhalten verlassen und TailwindCss für das Styling verwenden.

Dieses Beispiel basiert auf dem ursprünglichen Projekt, das ich von Grund auf neu aufgebaut habe, und jeder Schritt wurde in einer Schritt-für-Schritt-Anleitung dargestellt. Sie finden die entsprechenden Geschichten hier -> https://www.cloudapp.dev/search/nextjs

In einer früheren story habe ich gezeigt, wie man Algolia als externe Daten-Engine für die Tag-Verwaltung nutzt und wie man eine hübsche Tag-Cloud auf dem Frontend erstellt.

Jetzt werde ich die leistungsstarken APIs von Contentful verwenden. Fangen wir an.

Der vollständige Code ist in diesem GitHub Repo. verfügbar.

Hier ist ein Spoiler für die fertige Website -> https://nextjs14-contentful-tags-cloudapp-devs-projects.vercel.app/

Neuer Inhaltstyp in Contentful

Ich beginne mit der Erstellung eines neuen Inhaltstyps „Tag-Seite“ in Contentful.

tag-page-Content-Model
tag-page-Content-Model

Dann erstellen wir drei neue Inhaltsdatensätze, die auf diesem Inhaltstyp basieren

Tag-Page-Content-records
Tag-Page-Content-records

Da wir einen neuen Menüpunkt im Kopfbereich hinzufügen möchten

Tag-navitem-header
Tag-navitem-header

Wir müssen einen neuen Inhaltseintrag „Tags“ vom Typ „Nav item“ hinzufügen und ihn dann zum HomeNav vom Typ „NavItemGroup“ hinzufügen, damit er im Frontend angezeigt wird.

HomeNav-NavItemGroup
HomeNav-NavItemGroup

Sobald wir die Vorbereitungen in Contentful abgeschlossen haben, können wir mit der Codierung beginnen. In einem ersten Schritt fügen wir eine neue Route hinzu, um die Tags von der Contentful GraphQL API abzurufen.

Neue Route “Tags” hinzugefügt

src/app/api/tags/route.ts

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

const apikey = process.env.API_KEY;

export async function GET(request: Request) {
  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 landingPageData = await client.pageLanding({
    locale,
    preview: false,
    slug: "tags",
  });

  const page = landingPageData.pageLandingCollection?.items[0];
  const tags = page?.contentfulMetadata?.tags;

  const data: any = [];

  if (!tags) {
    return NextResponse.json({ data });
  }

  tags.map((tag) => {
    // console.log("tag", tag?.id, tag?.name);
    data.push({
      value: tag?.id,
      count: tag?.name,
    });
  });

  return NextResponse.json({ data });
}

Neue Übersetzungen

Wir fügen neue Übersetzungen in den entsprechenden common.json-Dateien hinzu

src/app/i18n/locales/de-DE/common.json

src/app/i18n/locales/en-US/common.json

Neue Komponente für

src/components/contentful/ArticleContentTagPage.tsx

import { CtfRichText } from "@/components/contentful/CtfRichText.component";
import { TagPageFieldsFragment } from "@/lib/__generated/sdk";

interface TagPageContentProps {
  landing: TagPageFieldsFragment;
}
export const TagPageContent = ({ landing }: TagPageContentProps) => {
  const { content } = landing;

  return (
    <CtfRichText json={content?.json} links={content?.links} source="article" />
  );
};

Neue GraphQL Abfragen

Da wir einen neuen Inhaltstyp in Contentful erstellt haben, müssen wir die Abfragen zu unserem Projekt hinzufügen:

src/lib/graphql/tagPage.graphql

fragment TagPageFields on TagPage {
  __typename
  sys {
    id
    spaceId
    publishedAt
    firstPublishedAt
  }
  internalName
  tag
  showTagCloud
  content {
    json
    links {
      assets {
        __typename
        block {
          sys {
            id
          }
          __typename
          title
          url
          contentType
          width
          height
        }
      }
    }
  }
  seoFields {
    ...SeoFields
  }
  textHighlightCollection(limit: 1) {
    items {
      ...TextHighlightFields
    }
  }
}

query tagPage($tag: String!, $locale: String, $preview: Boolean) {
  tagPageCollection(
    limit: 1
    locale: $locale
    where: { tag: $tag }
    preview: $preview
  ) {
    items {
      ...TagPageFields
    }
  }
}

src/lib/graphql/tagPageCollection.graphql

query tagPageCollection(
  $limit: Int
  $locale: String
  $preview: Boolean
  $order: [TagPageOrder]
  $where: TagPageFilter
) {
  tagPageCollection(
    limit: $limit
    locale: $locale
    preview: $preview
    order: $order
    where: $where
  ) {
    items {
      ...TagPageFields
    }
  }
}

src/lib/graphql/tagPageCollectionSmall.graphql

fragment TagPageFieldsSmall on TagPage {
  __typename
  sys {
    id
    publishedAt
    firstPublishedAt
  }
  internalName
  tag
}

query tagPageCollectionSmall(
  $limit: Int
  $locale: String
  $preview: Boolean
  $order: [TagPageOrder]
  $where: TagPageFilter
) {
  tagPageCollection(
    limit: $limit
    locale: $locale
    preview: $preview
    order: $order
    where: $where
  ) {
    items {
      ...TagPageFieldsSmall
    }
  }
}

Schema neu generieren (GraphQL)

Nun führen wir diesen Befehl aus

npm run graphql-codegen:generate

zum regenerieren der Dateien graphql.schema.json, graphql.schema.graphql, und sdk.ts im Ordner src/lib/__generated/.

Neues dynamisches Routensegment „Tag“

Die Codeschnipsel enthalten alle erforderlichen Importe, SEO-Logik, Schnittstellen usw.

src/app/[locale]/tags/[tag]/page.tsx

import { createTranslation } from "@/app/i18n/server";
import { locales, LocaleTypes } from "@/app/i18n/settings";
import { notFound } from "next/navigation";
import { draftMode } from "next/headers";
import { Metadata, ResolvingMetadata } from "next";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { PageBlogPostOrder } from "@/lib/__generated/sdk";
import { TagPageContent } from "@/components/contentful/ArticleContentTagPage";
import { client } from "@/lib/client";
import { Container } from "@/components/contentful/container/Container";
import { TextHighLight } from "@/components/contentful/TextHighLight";
import { revalidateDuration } from "@/utils/constants";
import { TagCloudSimpleHome } from "@/components/search/tagcloudsimpleHome.component";
import { Article, WithContext } from "schema-dts";
import path from "path";
import Script from "next/script";

export const revalidate = revalidateDuration; // revalidate at most every hour

const apikey = process.env.API_KEY;

interface PageParams {
  tag: string;
  locale: string;
}

interface PageProps {
  params: PageParams;
}

// Tell Next.js about all our blog posts so
// they can be statically generated at build time.
export async function generateStaticParams(): Promise<PageParams[]> {
  const dataPerLocale = locales
    ? await Promise.all(
        locales.map((locale) =>
          client.tagPageCollectionSmall({ locale, limit: 100 })
        )
      )
    : [];

  // If const dataPerLocale is empty, return an empty array
  if (!dataPerLocale) {
    return notFound();
  }

  const paths = dataPerLocale
    .flatMap((data, index) =>
      data.tagPageCollection?.items.map((tagPage) =>
        tagPage?.tag
          ? {
              tag: tagPage.tag,
              locale: locales?.[index] || "",
            }
          : undefined
      )
    )
    .filter(Boolean);

  return paths as PageParams[];
}

const generateUrl = (locale: string, tag: string) => {
  if (locale === "en-US") {
    return new URL(
      "/tags/" + tag,
      process.env.NEXT_PUBLIC_BASE_URL!
    ).toString();
  } else {
    return new URL(
      locale + "/tags/" + tag,
      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.tagPage({
      locale: params.locale.toString(),
      tag: params.tag.toString(),
      preview: draftMode().isEnabled,
    }),
  ]);

  const tagPage = PagedataSeo.tagPageCollection?.items[0];

  if (!tagPage) {
    // If the tag page data can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }

  const url = generateUrl(params.locale || "", params.tag || "");

  return {
    title: tagPage.seoFields?.pageTitle,
    description: tagPage.seoFields?.pageDescription,
    metadataBase: new URL(WebUrl),
    alternates: {
      canonical: url,
      languages: {
        "en-US": "/tags/" + params.tag,
        "de-DE": "/de-DE/tags/" + params.tag,
        "x-default": "/tags/" + params.tag,
      },
    },
    openGraph: {
      type: "website",
      siteName: "CloudApp.dev - Free Tutorials and Resources for Developers",
      locale: params.locale,
      url: url || "",
      title: tagPage.seoFields?.pageTitle || undefined,
      description: tagPage.seoFields?.pageDescription || undefined,
      images: tagPage.seoFields?.shareImagesCollection?.items.map((item) => ({
        url: item?.url || "",
        width: item?.width || 0,
        height: item?.height || 0,
        alt: item?.description || "",
        type: item?.contentType || "",
      })),
    },
    robots: {
      follow: tagPage.seoFields?.follow || false,
      index: tagPage.seoFields?.index || false,
      googleBot: {
        index: true,
        follow: true,
        "max-image-preview": "large",
      },
    },
  };
}

async function TagPage({ params }: PageProps) {
  const { isEnabled } = draftMode();

  let jsonLd: WithContext<Article> = {} as WithContext<Article>;

  const [PageData, blogPostsData] = await Promise.all([
    client.tagPage({
      tag: params.tag.toString(),
      locale: params.locale.toString(),
      preview: isEnabled,
    }),
    client.pageBlogPostCollection({
      limit: 12,
      locale: params.locale.toString(),
      preview: isEnabled,
      order: PageBlogPostOrder.PublishedDateDesc,
      where: {
        contentfulMetadata: {
          tags: { id_contains_all: [params.tag.toString()] },
        },
      },
    }),
  ]);

  const page = PageData.tagPageCollection?.items[0];

  const posts = blogPostsData.pageBlogPostCollection?.items;
  const selectedPost = blogPostsData.pageBlogPostCollection?.items[0];

  if (!PageData || !page) {
    // If the tag page data can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }

  const showTagCloud = page?.showTagCloud === "Yes";

  let { datanew } = {
    datanew: [],
  };

  if (showTagCloud) {
    const searchTags = await fetch(
      `${process.env.NEXT_PUBLIC_BASE_URL}/api/tags`,
      {
        method: "GET",
        headers: new Headers({
          "Content-Type": "application/json" || "",
          "x-api-key": apikey || "",
        }),
        next: { revalidate: 3600 }, // 1 h cache,
      }
    ).then((res) => res.json());

    datanew = searchTags.data;
  }

  const seoItem = page?.seoFields?.shareImagesCollection?.items[0];

  if (!page) {
    // If a blog post can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }

  // Create JSON-LD schema only if blogPost is available
  if (selectedPost) {
    jsonLd = {
      "@context": "https://schema.org",
      "@type": "Article",

      headline: page.seoFields?.pageTitle || undefined,
      author: {
        "@type": "Person",
        name: selectedPost?.author?.name || undefined,
        // The full URL must be provided, including the website's domain.
        url: new URL(
          path.join(params.locale.toString() || ""),
          process.env.NEXT_PUBLIC_BASE_URL!
        ).toString(),
      },
      publisher: {
        "@type": "Organization",
        name: "CloudApp.dev - Free Tutorials and Resources for Developers",
        logo: {
          "@type": "ImageObject",
          url: "https://www.cloudapp.dev/favicons/icon-192x192.png",
        },
      },
      image: seoItem?.url || undefined,
      datePublished: page.sys.firstPublishedAt,
      dateModified: page.sys.publishedAt,
    };
  }

  const { t } = await createTranslation(params.locale as LocaleTypes, "common");

  // if (!page?.featuredBlogPost || !posts) return;
  if (!PageData) {
    // If a blog post can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }
  const highLightHeadings: any = page.textHighlightCollection?.items[0];

  if (!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} />}
        <div className="md:mx-24 md:my-16 sm:mx-16 sm:my-8">
          <TagPageContent landing={page} />
        </div>
      </Container>

      <Container className="my-8 md:mb-10 lg:mb-16">
        {/* Tag Cloud Integration */}
        {showTagCloud && datanew.length > 0 && (
          <TagCloudSimpleHome
            datanew={datanew}
            minSize={10}
            maxSize={5}
            locale={params.locale}
            source={"tags"}
          />
        )}
      </Container>
      <Container className="mt-5">
        {posts.length > 0 && (
          <h2 className="mb-4 md:mb-6">{t("tagPage.relatedArticles")}</h2>
        )}
        <ArticleTileGrid
          className="md:grid-cols-2 lg:grid-cols-3"
          articles={posts}
        />
      </Container>
    </>
  );
}

export default TagPage;

in dieser Sektion

if (showTagCloud) {
    const searchTags = await fetch(
      `${process.env.NEXT_PUBLIC_BASE_URL}/api/tags`,
      {
        method: "GET",
        headers: new Headers({
          "Content-Type": "application/json" || "",
          "x-api-key": apikey || "",
        }),
        next: { revalidate: 3600 }, // 1 h cache,
      }
    ).then((res) => res.json());

    datanew = searchTags.data;
  }

holen wir uns die Daten von der neuen Api-Route, die die Daten von Contentful abruft. Und zu guter Letzt erstellen wir die Tag-Übersichtsseite -> src/app/[Gebietsschema]/tags/page.tsxx

import { createTranslation } from "@/app/i18n/server";
import { notFound } from "next/navigation";
import { locales, LocaleTypes } from "@/app/i18n/settings";
import { client } from "@/lib/client";
import { PageBlogPostOrder } from "@/lib/__generated/sdk";
import { draftMode } from "next/headers";
import { Metadata, ResolvingMetadata } from "next";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { LandingContent } from "@/components/contentful/ArticleContentLanding";
import { Container } from "@/components/contentful/container/Container";
import { TextHighLight } from "@/components/contentful/TextHighLight";
import { TagCloudSimpleHome } from "@/components/search/tagcloudsimpleHome.component";
import { revalidateDuration } from "@/utils/constants";
// Json Schema
import { Article, WithContext } from "schema-dts";
import path from "path";
import Script from "next/script";

export const revalidate = revalidateDuration; // revalidate at most every hour

const apikey = process.env.API_KEY;

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 + "/" + slug,
      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({
      locale: params.locale.toString(),
      slug: "tags",
      preview: draftMode().isEnabled,
    }),
  ]);

  const landingPage = PagedataSeo.pageLandingCollection?.items[0];

  const url = generateUrl(params.locale || "", params.slug || "");

  return {
    title: landingPage?.seoFields?.pageTitle,
    description: landingPage?.seoFields?.pageDescription,
    metadataBase: new URL(WebUrl),
    alternates: {
      canonical: url + landingPage?.slug,
      languages: {
        "en-US": "/" + landingPage?.slug,
        "de-DE": "/de-DE/" + landingPage?.slug,
        "x-default": "/" + landingPage?.slug,
      },
    },
    openGraph: {
      type: "website",
      siteName: "CloudApp.dev - Free Tutorials and Resources for Developers",
      locale: params.locale,
      url: url + landingPage?.slug || "",
      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 TagHomePage({ params }: PageProps) {
  // Make sure to use the correct namespace here.
  const { t } = await createTranslation(params.locale as LocaleTypes, "common");
  const { isEnabled } = draftMode();
  const landingPageData = await client.pageLanding({
    locale: params.locale.toString(),
    preview: isEnabled,
    slug: "tags",
  });
  const page = landingPageData.pageLandingCollection?.items[0];

  const blogPostsData = await client.pageBlogPostCollection({
    limit: 12,
    locale: params.locale.toString(),
    preview: isEnabled,
    order: PageBlogPostOrder.PublishedDateDesc,
  });

  const posts = blogPostsData.pageBlogPostCollection?.items;

  const seoItem = page?.seoFields?.shareImagesCollection?.items[0];

  const showTagCloud = page?.showTagCloud === "Yes";

  if (!page) {
    // If a blog post can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }

  const jsonLd: WithContext<Article> = {
    "@context": "https://schema.org",
    "@type": "Article",

    headline: page?.seoFields?.pageTitle || undefined,
    author: {
      "@type": "Person",
      name: "E.G.",
      // The full URL must be provided, including the website's domain.
      url: new URL(
        path.join(params.locale.toString() || ""),
        process.env.NEXT_PUBLIC_BASE_URL!
      ).toString(),
    },
    publisher: {
      "@type": "Organization",
      name: "CloudApp.dev - Free Tutorials and Resources for Developers",
      logo: {
        "@type": "ImageObject",
        url: "https://www.cloudapp.dev/favicons/icon-192x192.png",
      },
    },
    image: seoItem?.url || undefined,
    datePublished: page.sys.firstPublishedAt,
    dateModified: page.sys.publishedAt,
  };

  let { datanew } = {
    datanew: [],
  };

  if (showTagCloud) {
    const searchTags = await fetch(
      `${process.env.NEXT_PUBLIC_BASE_URL}/api/tags`,
      {
        method: "GET",
        headers: new Headers({
          "Content-Type": "application/json" || "",
          "x-api-key": apikey || "",
        }),
        next: { revalidate: 3600 }, // 1 h cache,
      }
    ).then((res) => res.json());

    datanew = searchTags.data;
  }
  if (!page) {
    // If a blog post can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }

  const highLightHeadings: any = page.textHighlightCollection?.items[0];

  if (!posts) return;

  return (
    <>
      <Script
        id="article-schema"
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd),
        }}
      />
      <Container className="my-8 md:mb-10 lg:mb-16">
        {highLightHeadings && <TextHighLight headings={highLightHeadings} />}
        {showTagCloud && datanew.length > 0 && (
          <TagCloudSimpleHome
            datanew={datanew}
            minSize={10}
            maxSize={5}
            locale={params.locale}
            source={"tags"}
          />
        )}
      </Container>
      <Container className="mt-5">
        <div className="md:mx-24 md:my-16 sm:mx-16 sm:my-8">
          <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="md:grid-cols-2 lg:grid-cols-3"
          articles={posts}
        />
      </Container>
    </>
  );
}

export default TagHomePage;

Wenn Sie das Git Repo bereits geklont haben (hier ist der Link, wo Sie die URL finden), können Sie alle benötigten Pakete mit dem Befehl

npm i

Nach der Erstellung der benötigten ENV-Vars in „.env.local“

# Contentful API Keys 
CONTENTFUL_SPACE_ID=xxxx
CONTENTFUL_ACCESS_TOKEN=xxxx
CONTENTFUL_PREVIEW_ACCESS_TOKEN=xxxx
CONTENTFUL_MANAGEMENT_TOKEN=xxxx
CONTENTFUL_PREVIEW_SECRET=xxxx
# Base URL
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Api Key for Route Auth
API_KEY=xxxxxx
# Api Keys for Algolia
NEXT_PUBLIC_ALGOLIA_API_KEY=xxxx
ALGOLIA_MASTER_KEY=xxxxx
NEXT_PUBLIC_ALGOLIA_APP_ID=xxxxx
NEXT_PUBLIC_ALGOLIA_INDEX_NAME=xxxxx
# Piwik Analytics & CMP
NEXT_PUBLIC_PIWIK_PRO_ID=xxxxxx
NEXT_PUBLIC_PIWIK_CONTAINER_NAME=xxxxx
# Revalidate Secret
CONTENTFUL_REVALIDATE_SECRET=xxxxx

können Sie das Projekt mit dem Befehl

npm run dev

Das war's. Jetzt können Sie Ihre Beiträge dynamisch über Tags kategorisieren. Sie können so viele Kategorieseiten wie nötig hinzufügen und gute SEO-Inhalte erstellen. Vergessen Sie nicht, neue Tags zum Inhaltstyp „Kategorieseiten“ im Tag-Auswahlfeld hinzuzufügen.

Sie können alles, was Sie brauchen, auf meinem Medium-Account oder meinem Blog „Cloudapp.dev“ finden.

Dort gibt es für jeden Schritt einen Beitrag -> Zero to Hero ;-)

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