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.

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

Neuer Menüpunkt in der Navigation
Da wir einen neuen Menüpunkt im Kopfbereich hinzufügen möchten

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.

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:generatezum 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 iNach 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=xxxxxkönnen Sie das Projekt mit dem Befehl
npm run devDas 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.




