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