Contentful offers a great tagging system. In this story, I will show you how we can use it to cluster content pages by tags and expose new " tag pages,” which show content related to those tags. We will add a new content type and route to fetch the tags from Contentful. I will rely on Contentful’s Graphql API for content fetching and use TailwindCss for styling.
This example leverages the initial project, which I built up from scratch, and every step was showcased in a step-by-step guide. You will find the corresponding stories here -> https://www.cloudapp.dev/search/nextjs
In a previous story, I showed how to use Algolia as an external data engine for tag management and how to create a nice-looking tag cloud on the front end.
Now, I will use the powerful API’s from Contentful. Let’s start.
The complete code is available in this GitHub repo.
Here's a spoiler for the finished website -> https://nextjs14-contentful-tags-cloudapp-devs-projects.vercel.app/
New Contentful Content Type
I start with the creation of a new Content type “tag page” within Contentful.

Then, we create three new content records based on this content type

New Menuitem in Header Navigation
Since we would like to add a new menu item in the header area

We have to add a new content entry, “Tags” of type “Nav item” and then add it to the HomeNav of type “NavItemGroup” so that it will be shown on the front end.

We can start coding as soon as we finish the preparation within Contentful. As a first step, we add a new route to retrieve the tags from the Contentful GraphQL API.
New Route “Tags” added
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 });
}New Translations
We add new translations in the corresponding common.json files
src/app/i18n/locales/de-DE/common.json
src/app/i18n/locales/en-US/common.json
New Component for Tag Pages
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" />
);
};New GraphQL Queries
Since we created a new content type in Contentful, we have to add the queries to our project:
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
}
}
}Regeneration of Schema (GraphQL)
Now we run
npm run graphql-codegen:generateto regenerate the graphql.schema.json, graphql.schema.graphql, and sdk.ts under src/lib/__generated/.
New Dynamic Route Segment “Tag”
The code snippets contain all needed imports, SEO logic, Interfaces, etc.
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 this section
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;
}we fetch the data from the new Api route, which gets the data from Contentful. And last but not least, we create the tag overview page -> src/app/[locale]/tags/page.tsx
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;If you have already cloned the Git Repo (here is the link, where you find the URL), you can install all the needed packages with the command
npm iAfter the preparation of the needed 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=xxxxxyou can launch the project with the command
npm run devThat’s it. Now, you can dynamically categorize your posts via Tags. You can add as many category pages as needed and create good SEO content. Don’t forget to add new tags to the content type “tag pages” in the tag select field.
You can find all that you need on my Medium account or my blog “Cloudapp.dev”
There is a post/story for every step, starting from scratch -> Zero to Hero ;-)
Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:
Please consider clapping and following the writer! 👏 on our Medium Account




