Using NextJs for your blog

NextJs is a great framework to leverage for your blog. Let's give some recommendations about how to get the best of VaporCMS by using NextJS.

Pages Dir

For the index of your blog, we recommend using SSG, so you can also add pagination to your blog. Here is a barebone implementation. Please adjust according to your needs.

JavaScript Client

We recommend using our JavaScript client when using NextJs, because it's type safe and makes it easier for you to fetch all relevant resources.

lib/client.ts

import { V0Client } from '@vaporcms/client-sdk-js'

const vaporCmsClient = new V0Client({
  auth: process.env.VAPORCMS_API_KEY ?? '',
  blogId: process.env.VAPORCMS_BLOG_ID ?? '',
})

export { vaporCmsClient }

Blog Index Page

This page is the entry point of your blog, example: https://vaporcms.com/blog. In this page, visitors are able to see all your blog articles, along with the ability to navigate between pages.

blog/index.tsx

import {
  GetServerSideProps,
  GetServerSidePropsContext,
  InferGetServerSidePropsType,
} from 'next'

type Props = InferGetServerSidePropsType<typeof getServerSideProps>

function BlogPage({ posts, pagination, locale }: Props) {
  // Render the blog posts here, along with the pagination
}

export const getServerSideProps: GetServerSideProps = async (
  context: GetServerSidePropsContext,
) => {
  const locale = context.locale || context.defaultLocale || 'en'

  const pageNumber = parseInt(context.query.page as string) || 1
  const postsPerPage = 12

  const res = await vaporCmsClient.articles.list({
    page: pageNumber,
    pageSize: postsPerPage,
  })

  if (!res.data) throw new Error('Failed to fetch blog posts')

  const posts = res.data.articles.map((article) => {
    const localizedContent =
      article.localized[locale] || article.localized['en'] // Fallback to English if locale not available
    return localizedContent
  })

  const pagination = res.data.pagination

  return {
    props: {
      locale,
      posts,
      pagination,
    },
  }
}

export default BlogPage

Article page (blog slug)

This is the specific page for each blog article, example: https://vaporcms.com/blog/explore-vaporcms.

blog/[slug.tsx]

If you use NextJS 12, we recommend you use Static Side Generation to build all your articles during the build phase, so your articles are blazingly fast and you won't have to do unnecessary API calls to fetch an article.

import { GetStaticPropsContext, InferGetStaticPropsType } from 'next';
import ErrorPage from 'next/error';
import { vaporCmsClient } from "@/lib/client/vaporcms/client";


type Props = InferGetStaticPropsType<typeof getStaticProps>;

function BlogPostPage({ post }: Props) {
  if (!post) return <ErrorPage statusCode={404} />;

  // use post.html and other post attributes to render your article
  return (
    <>
      <PostSEO post={post} />
      <Post post={post} />
    </>
  )
}

export const getStaticPaths = async () => {
  const res = await vaporCmsClient.articles.list({});
  if (!res.data) throw new Error("Failed to fetch blog posts");

  const allArticles = res.data?.articles;

  // Collect all paths for each article and its localized versions
  const paths = allArticles.flatMap((article) => {
    return Object.keys(article.localized).map((locale) => {
      const localizedContent = article.localized[locale];
      return {
        params: { slug: localizedContent.content.slug },
        locale: locale, // This ensures we generate paths for each locale
      };
    });
  });

  return {
    paths,
    fallback: false, // Adjust according to your needs; fallback: false means any non-predefined paths will 404.
  };
};

export const getStaticProps = async (context: GetStaticPropsContext) => {
  const slug = context.params?.slug;
  if (!slug || typeof slug !== "string") throw new Error("Invalid slug param");
  const locale = context.locale || context.defaultLocale || "en";

  const res = await vaporCmsClient.articles.get({
    slug,
    localeCode: locale,
  });

  if (!res.data) throw new Error("Failed to fetch blog posts");

  return {
    props: {
      post: res.data,
    },
    revalidate: 3600, // revalidate every 1 hour
  };
};

export default BlogPostPage;


Table of Contents

If you wish, you can also render a table of contents, provided by the /articles/:slug endpoint. Take a look at our blog's Table Of Contents to see how we recommend rendering it: https://vaporcms.com/blog/explore-vaporcms.

Note that each heading (h1, h2, h3, etc) in the html field returned by the /articles/:slug has an id attribute that matches the id attribute of each table of contents element. This means that you can render each Table Of Contents item as a link, so when the user clicks an item, the page scrolls to the relevant heading.

Example: https://vaporcms.com/blog/explore-vaporcms#n1-built-for-multi-locale-content

components/table-of-contents.tsx

import Link from "next/link";
import React, { useEffect, useState } from "react";
import useMediaQuery, { screenSizes } from "@/hooks/useMediaQuery";
import { classNames } from "@/utils/css";
import { Disclosure } from "@headlessui/react";
import { useTranslation } from "next-i18next";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { MinusCircle, PlusCircle } from "lucide-react";
import { TableOfContents as TableOfContentsType } from "@vaporcms/client-sdk-js";


interface TableOfContentsProps {
  sections: TableOfContentsType[];
}

export const TableOfContents: React.FC<TableOfContentsProps> = ({
  sections,
}) => {
  const [clickedTOC, setClickedTOC] = useState<string | null>(null);
  const [isTOCOpen, setIsTocOpen] = useState<boolean | null>(null);
  const isLargeScreen = useMediaQuery(`(min-width: ${screenSizes.sm})`);
  const { t } = useTranslation("blog-post");

  useEffect(() => {
    setIsTocOpen(isLargeScreen);
  }, [isLargeScreen]);

  return (
    <div className="mt-0 flex flex-col space-y-2 sm:mt-10">
      <div
        className={classNames(
          "flex items-center justify-between",
          isTOCOpen ? "border-b border-solid border-gray-200 pb-4" : ""
        )}
      >
        <h2 className="text-lg font-semibold">{t("tableOfContents")}</h2>
        {isTOCOpen !== null &&
          (isTOCOpen ? (
            <button
              onClick={() => setIsTocOpen(false)}
              aria-label={
                t("closeTableOfContents") ?? "Close Table Of Contents Section"
              }
            >
              <MinusCircle className="h-6 w-6 text-sky-600" />
            </button>
          ) : (
            <button
              onClick={() => setIsTocOpen(true)}
              aria-label={
                t("openTableOfContents") ?? "Open Table Of Contents Section"
              }
            >
              <PlusCircle className="h-6 w-6 text-sky-600" />
            </button>
          ))}
      </div>

      <div
        className={classNames(
          "hidden max-h-[220px] overflow-y-auto sm:block",
          isTOCOpen !== null && isTOCOpen ? "!block" : "!hidden"
        )}
      >
        {sections.map((section, index) => (
          <Disclosure key={index}>
            {({ open }) => (
              <>
                <Disclosure.Button
                  className={classNames(
                    "items-top my-3 flex w-full cursor-pointer justify-between text-left text-[13px] font-medium hover:text-sky-600",
                    clickedTOC === section.text ? "text-sky-600" : ""
                  )}
                >
                  <a
                    href={`#${section.id}`}
                    onClick={() => setClickedTOC(section.text)}
                    className="flex-grow"
                  >
                    {section.text}
                  </a>
                  {section.subHeadings.length > 0 && (
                    <ChevronDownIcon
                      className={classNames(
                        "h-4 w-4 flex-none",
                        open ? "rotate-180 transform" : ""
                      )}
                    />
                  )}
                </Disclosure.Button>
                <Disclosure.Panel className="pl-4">
                  <ul className="space-y-3">
                    {section.subHeadings.map((subHeading, subIndex) => (
                      <li
                        key={subIndex}
                        className={classNames(
                          "cursor-pointer text-[13px] font-light hover:text-sky-600",
                          clickedTOC === subHeading.text ? "text-sky-600" : ""
                        )}
                      >
                        <Link
                          href={`#${subHeading.id}`}
                          onClick={() => setClickedTOC(subHeading.text)}
                        >
                          {subHeading.text}
                        </Link>
                      </li>
                    ))}
                  </ul>
                </Disclosure.Panel>
              </>
            )}
          </Disclosure>
        ))}
      </div>
    </div>
  );
};


Was this page helpful?