Loading...
Sitemap-html
Author Cloudapp
E.G.

Next.js 14 - Creating an HTML Sitemap in 5 minutes with Contentful

June 16, 2024
Table of Contents

An HTML sitemap is a web page that lists and links to all the other pages on a website, providing a clear and organized overview of its structure. It improves user experience by helping visitors quickly find the information they seek, especially on large websites with complex navigation. Search engines benefit from HTML sitemaps as they ensure all pages are discovered and indexed efficiently.

Why is the XML Sitemap not enough?

Unlike XML sitemaps, which are designed primarily for search engines, HTML sitemaps are designed for human users. They can enhance a website’s SEO by distributing page authority through internal links. Furthermore, HTML sitemaps support accessibility by aiding users with disabilities in navigating the site more easily.

Here is the GitHub repo with the full code.

Example page hosted on Vercel -> https://nextjs14-azureb2c-prisma.vercel.app/

If you have followed my previous stories regarding Next.js 14 and SEO, you may already know the basics of the most important SEO topics to be successful with your Next.js 14 project.

Used Stack (Nextjs 14, Contentful,Tailwind)

In all my examples, I used Contentful as a CMS, Next.js 14 as a framework, and Tailwind CSS for styling, and the deployment was done on Vercel.

Now, I will create an HTML Sitemap, which is quite easy. We already have built the foundation, and with the great GraphQL API from Contentful, it is really fun to implement it.

Contentful Project — Content Types

I focus on the main three content types that I have in my Contentful space

  • Landing Pages (pageLandingCollection)

  • Tag Pages (tagPageCollection)

  • Blog Post Pages (pageBlogPostCollection)

GraphQL-Query

Let’s start with the GraphQL-Query

fragment sitemapPagesFields on Query {
  pageBlogPostCollection(limit: 100, locale: $locale) {
    items {
      internalName
      slug
      sys {
        publishedAt
      }
    }
  }
  pageLandingCollection(limit: 50, locale: $locale) {
    items {
      internalName
      slug
      sys {
        publishedAt
      }
    }
  }
  tagPageCollection(limit: 50, locale: $locale) {
    items {
      internalName
      tag
      sys {
        publishedAt
      }
    }
  }
}

query sitemapPages($locale: String!) {
  ...sitemapPagesFields
}

After the change, we have to perform the command below to update the schemas and types (the file src/lib/__generated/sdk.ts will be updated).

npm run graphql-codegen:generate

New Page Route — Sitemap-html

Since we want to show the output on our front end, we create a new page.tsx under src/app/[locale]/sitemap-html

//src/app/[locale]/sitemap-html
import { client } from "@/lib/client";
import { draftMode } from "next/headers";
import { Metadata, ResolvingMetadata } from "next";
import { LandingContent } from "@/components/contentful/ArticleContentLanding";
import { notFound } from "next/navigation";
import { Container } from "@/components/contentful/container/Container";
import { TextHighLight } from "@/components/contentful/TextHighLight";
// Internationalization
import { LocaleTypes } from "@/app/i18n/settings";
import { createTranslation } from "@/app/i18n/server";
//SEO - JSON-LD
import { Article, WithContext } from "schema-dts";
import Script from "next/script";
import path from "path";
import Link from "next/link";
//GraphQL Types
import { PageLandingFieldsFragment } from "@/lib/__generated/sdk";
import { PageBlogPostFieldsFragment } from "@/lib/__generated/sdk";
import { TagPageFieldsFragment } from "@/lib/__generated/sdk";
import { TextHighlightFieldsFragment } from "@/lib/__generated/sdk";

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();
  }
};

export async function generateMetadata(
  { params }: PageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const [PagedataSeo] = await Promise.all([
    client.pageLanding({
      slug: "sitemap-html",
      locale: params.locale.toString(),
      preview: draftMode().isEnabled,
    }),
  ]);

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

  if (!landingPage) {
    return notFound();
  }

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

  const WebUrl = process.env.NEXT_PUBLIC_BASE_URL as string;

  return {
    title: landingPage.seoFields?.pageTitle,
    description: landingPage.seoFields?.pageDescription,
    metadataBase: new URL(WebUrl),
    alternates: {
      canonical: url,
      languages: {
        "en-US": "/",
        "de-DE": "/de-DE",
        "x-default": "/",
      },
    },
    openGraph: {
      type: "website",
      siteName: "Example.dev - Free Tutorials and Resources for Developers",
      locale: params.locale,
      url: url || "",

      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 Sitemap_html({ params }: PageProps) {
  const { isEnabled } = draftMode();
  //declare JSON-LD schema
  let jsonLd: WithContext<Article> = {} as WithContext<Article>;
  const [landingPageData, PagesSitemapHtml] = await Promise.all([
    client.pageLanding({
      slug: "sitemap-html",
      locale: params.locale.toString(),
      preview: isEnabled,
    }),
    client.sitemapPages({
      locale: params.locale.toString(),
    }),
  ]);

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

  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 (page) {
    jsonLd = {
      "@context": "https://schema.org",
      "@type": "Article",
      headline: page?.seoFields?.pageTitle || undefined,
      author: {
        "@type": "Person",
        name: page?.featuredBlogPost?.author?.name || undefined,
        // The full URL must be provided, including the website's domain.
        url: new URL(
          path.join(
            params.locale.toString() || "",
            params.slug?.toString() || ""
          ),
          process.env.NEXT_PUBLIC_BASE_URL!
        ).toString(),
      },
      publisher: {
        "@type": "Organization",
        name: "Example.dev - Free Tutorials and Resources for Developers",
        logo: {
          "@type": "ImageObject",
          url: "https://www.example.dev/favicons/icon-192x192.png",
        },
      },
      image: seoItem?.url || undefined,
      datePublished: page.sys.firstPublishedAt,
      dateModified: page.sys.publishedAt,
    };
  }

  const highLightHeadings: TextHighlightFieldsFragment | undefined | null =
    page.textHighlightCollection?.items[0];
  const sitemapPageLandingUrls: PageLandingFieldsFragment | any =
    PagesSitemapHtml?.pageLandingCollection?.items;
  const sitemapPageTagpageUrls: TagPageFieldsFragment | any =
    PagesSitemapHtml?.tagPageCollection?.items;
  const sitemapPageBlogPostUrls: PageBlogPostFieldsFragment | any =
    PagesSitemapHtml?.pageBlogPostCollection?.items;

  // Internationalization, get the translation function
  const { t } = await createTranslation(params.locale as LocaleTypes, "common");

  return (
    <>
      {page && (
        <Script
          id="article-schema"
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(jsonLd),
          }}
        />
      )}
      <Container className="mt-5">
        {highLightHeadings && <TextHighLight headings={highLightHeadings} />}
        <LandingContent landing={page} />
        <div className="mx-auto max-w-8xl  mt-5 text-base">
          <div className="text-2xl font-bold mb-2">
            {" "}
            {t("sitemaphtml.landingpages")}
          </div>
          {sitemapPageLandingUrls.map((field: any, index: number) => {
            return field ? (
              <div key={index}>
                <Link href={field.slug}>{field.internalName}</Link>
              </div>
            ) : null;
          })}
          <div className="text-2xl font-bold mb-2">
            {t("sitemaphtml.tagpages")}
          </div>
          {sitemapPageTagpageUrls.map((field: any, index: number) => {
            return field ? (
              <div key={index}>
                <Link href={field.tag}>{field.internalName}</Link>
              </div>
            ) : null;
          })}
          <div className="text-2xl font-bold mb-2">
            {" "}
            {t("sitemaphtml.blogpostpages")}
          </div>
          {sitemapPageBlogPostUrls.map((field: any, index: number) => {
            return field ? (
              <div key={index}>
                <Link href={field.slug}>{field.internalName}</Link>
              </div>
            ) : null;
          })}
        </div>
      </Container>
    </>
  );
}

export default Sitemap_html;

New Typescript Types

On the top, I import the new types

//GraphQL Types
import { PageLandingFieldsFragment } from "@/lib/__generated/sdk";
import { PageBlogPostFieldsFragment } from "@/lib/__generated/sdk";
import { TagPageFieldsFragment } from "@/lib/__generated/sdk";
import { TextHighlightFieldsFragment } from "@/lib/__generated/sdk";

Here, I do the query

const [landingPageData, PagesSitemapHtml] = await Promise.all([
    client.pageLanding({
      slug: "sitemap-html",
      locale: params.locale.toString(),
      preview: isEnabled,
    }),
    client.sitemapPages({
      locale: params.locale.toString(),
    }),
  ]);

Here I create the const variables

const sitemapPageLandingUrls: PageLandingFieldsFragment | any =
    PagesSitemapHtml?.pageLandingCollection?.items;
  const sitemapPageTagpageUrls: TagPageFieldsFragment | any =
    PagesSitemapHtml?.tagPageCollection?.items;
  const sitemapPageBlogPostUrls: PageBlogPostFieldsFragment | any =
    PagesSitemapHtml?.pageBlogPostCollection?.items;

And finally, I show the links on the fronted

<div className="mx-auto max-w-8xl  mt-5 text-base">
          <div className="text-2xl font-bold mb-2">
            {" "}
            {t("sitemaphtml.landingpages")}
          </div>
          {sitemapPageLandingUrls.map((field: any, index: number) => {
            return field ? (
              <div key={index}>
                <Link href={field.slug}>{field.internalName}</Link>
              </div>
            ) : null;
          })}
          <div className="text-2xl font-bold mb-2">
            {t("sitemaphtml.tagpages")}
          </div>
          {sitemapPageTagpageUrls.map((field: any, index: number) => {
            return field ? (
              <div key={index}>
                <Link href={field.tag}>{field.internalName}</Link>
              </div>
            ) : null;
          })}
          <div className="text-2xl font-bold mb-2">
            {" "}
            {t("sitemaphtml.blogpostpages")}
          </div>
          {sitemapPageBlogPostUrls.map((field: any, index: number) => {
            return field ? (
              <div key={index}>
                <Link href={field.slug}>{field.internalName}</Link>
              </div>
            ) : null;
          })}
        </div>

Multilanguage handling

To use the multilanguage feature as well I adapt my common.json files for DE and US under src/app/[locale]/de-DE and en-US

US

"sitemaphtml": {
    "landingpages": "Landing pages",
    "blogpostpages": "Blog Post Pages",
    "tagpages": "Tag - Category Pages"
  },

DE

 "sitemaphtml": {
    "landingpages": "Hauptseiten",
    "blogpostpages": "Blogseiten",
    "tagpages": "Kategorieseiten"
  },

Here is the final result

Sitemap-Html-frontend
Sitemap-Html-frontend
Footer
Footer

That's it. Now we have created an HTML sitemap, which is directly linked to the footer. As we have seen, this is very easy with the combination of Contentful and Next.js 14.

Let's sum it up

Every page should have an HTML sitemap for several reasons:

1. Improved Navigation for Users; An HTML sitemap provides a clear overview of the entire website, allowing users to easily find the content they seek. This can enhance the user experience, especially on large websites with complex structures.

2. Better SEO: Search engines use sitemaps to understand the structure of a website and to index its pages more effectively. An HTML sitemap can help search engines discover all the pages on a site, which can improve the site's visibility and ranking in search engine results.

3. Quick Access to All Pages: Users and search engines can quickly access any page on the website through the HTML sitemap, which can be particularly useful for finding deep-linked or less frequently accessed pages.

4. Enhanced Internal Linking: An HTML sitemap creates additional internal links to all the pages on the website. This can improve the overall link structure, distribute link equity more evenly, and help with the SEO value of the site.

5. Accessibility: An HTML sitemap can aid in website accessibility by providing a simple, text-based navigation option. This can be particularly useful for users with disabilities who may rely on screen readers.

6. Content Discovery: Visitors can easily discover the full range of content available on the site, which can encourage them to explore more pages and spend more time on the site.

7. Error Identification: By regularly reviewing the HTML sitemap, website administrators can quickly identify and rectify broken links or pages that may have been unintentionally omitted from the site's main navigation.

In summary, an HTML sitemap is a valuable tool for enhancing user experience, improving SEO, and ensuring the overall accessibility and discoverability of a website's content.

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