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

Next.js 14 - Working with Contentful Tags and TailwindCss

May 7, 2024
Table of Contents

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.

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

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

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

Since we would like to add a new menu item in the header area

Tag-navitem-header
Tag-navitem-header

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.

HomeNav-NavItemGroup
HomeNav-NavItemGroup

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:generate

to 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 i

After 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=xxxxx

you can launch the project with the command

npm run dev

That’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

Related articles