CMS-Driven Content Structure for a Static Next.js Site

Lara Aigmüller

Posted in How To

In a previous article, I described my favorite setup for building static websites: using Contentful as a headless content management system, and Next.js (with TypeScript enabled) to build the site.

Side note: if you like to read more about the resilience and flexibility of headless systems in general, I recommend the corresponding article by Stefan Baumgartner on Smashing Magazine.

It can be challenging to create sites for clients who are used to drag & drop page building like with the Elementor plugin for WordPress or services like Wix:

  • clients want to move components around on the page and nest them arbitrarily
  • clients look for options to add spacings here and there (“Why doesn’t it have any effect when I insert a couple of blank lines here?”)
  • clients become creative and change font sizes and colors (“How can I make this very important paragraph red and the font size bigger?”)

I had to build a couple of websites to find a good approach on how to provide flexibility for my clients but still maintain control over the design and rely on a resilient and type-safe codebase behind the scenes. In this article, I would like to describe how I build static websites these days and how I got there.

The code examples in this guide are built on top of the code from my article about a static website dreamteam, still using:

  • Node 16.14
  • Next.js 12.1
  • TypeScript 4.5

Recap: the blog example website

We’ve built a small blog example website (with posts about Harry Potter 🪄) consisting of a homepage that lists all articles and the article detail pages. For simplicity’s sake we hardcoded some text on the homepage.

Now what if there should be additional editable sections on the homepage like an introduction text, a newsletter subscription form, or another call to action? What if we need more pages than just the homepage? For this, we need to start thinking in sections and components. For each component we add in Contentful, we create a component in our Next.js project. Let’s start with implementing the article component we’ve previously configured.

A React component for articles

Last time we added a grid of links to articles to the index.tsx page. Today we’re going to move the few lines of code into a separate component file. In src/components/Article, we create three files: Article.tsx, Article.module.css, and index.ts with the following content:

/* Article.tsx */
import React from "react";
import { IArticleFields } from "../../@types/contentful";
import styles from "./Article.module.css";

const Article = ({ slug, title, description }: IArticleFields) => (
  <a href={`/${slug}`} className={styles.card}>
    <h2>{title} &rarr;</h2>
    <p>{description}</p>
  </a>
);

export default Article;

/* Article.module.css */
/* TODO: Move the .card styles from Home.module.css into this file. */
/* index.ts */
export { default } from "./Article";

I got used to creating one folder for each component containing the actual component code (.tsx) and the styles (.css). To avoid imports like import Article from "../components/Article/Article"; (with the component name duplicated), I create the extra index file with just a default export.

In the index.tsx file—the one where we find all the code to render the homepage—we replace the part rendering a link for every article with our newly created component:

import Article from "../src/components/Article";

/* All the code for the Home page component */

<div className={styles.grid}>
  {articles.map((article) => (
    <Article key={article.slug} {...article} />
  ))}
</div>

Text block and article list components

For every website I build, I always need some kind of text block component, often with the option to add an image. Additionally, components to group and list other components (like articles, portfolio entries, partner logos,…) are useful as well.

For extending the example blog website, I go to my Contentful space’s content model section and create a text block component with the following properties:

  • an internal title (internalTitle; I use this to find the element in Contentful, but won’t render the title on the page): short text, required
  • the content (content): rich text, required

I create a “Text block” and fill it with the text currently on the homepage:

  • Internal title: “Homepage introduction”
  • Content: “This is a blog with many interesting articles about Harry Potter.”

Next, let’s add an article list component. This one consists of:

  • a title (title), which should be a short text and required
  • elements (ID: elements), which are references to selected articles, so I select the field type “Reference” and set it to “Many references”. In the validation settings I activate “Accept only specified entry type” and choose “Article”.

When the content model is ready, let’s navigate to “Content” in Contentful and create such a list. I set the title to “Latest articles” and link the first three Harry Potter articles in the “Elements” section.

A screenshot of Contentful with the created article list as described above.

Don’t forget to run npm run generate:types every time you make updates to your content model in Contentful to keep your types up to date!

Implementing the components in Next.js

For both components we need to create their equivalents in our Next.js project: one folder containing three files for each of them:

src/
  components/
    ArticleList/
      ArticleList.module.css
      ArticleList.tsx
      index.ts
    TextBlock/
      index.ts
      TextBlock.module.css
      TextBlock.tsx

The article list looks like this (we’re using the previously created article component here):

import React from "react";
import { IArticleListFields } from "../../@types/contentful";
import Article from "../Article";
import styles from "./ArticleList.module.css";

const ArticleList = ({ title, elements }: IArticleListFields) => {
  if (!elements || elements.length < 1) {
    return null;
  }

  return (
    <>
      <h2>{title}</h2>
      <div className={styles.grid}>
        {elements.map((el) => (
          <Article key={el.sys.id} {...el.fields} />
        ))}
      </div>
    </>
  );
};

export default ArticleList;

Move the styles for the .grid selector from the current homepage styles to this component’s stylesheet. Following is the code for the text block component:

import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
import React from "react";
import { ITextBlockFields } from "../../@types/contentful";

const TextBlock = ({ content }: ITextBlockFields) => {
  return <div>{documentToReactComponents(content)}</div>;
};

export default TextBlock;

The index.ts files export the component. Styles can be added another time, but I create the file anyway to be prepared.

Add components to the homepage

Now that we have all components we need in place, let’s put them on the homepage. Let me quickly go back in time and tell you how I’ve built my first websites with this setup:

I created a React component for each Contentful component (as described above) with matching types, which was a good first step. Then, I created all the content elements required based on the website design and the client’s needs and filled them with texts and images.

Back in the code, I fetched the elements from Contentful based on their ID at exactly the place where I needed them. You can imagine which big problem this lead to: when the client wanted to add another text block, or shift elements around, or temporarily hide elements, or create a new page, they had to contact me and several code updates were required. 😬

This was frustrating both for me and my clients, so obviously, I had to think of a better solution…

The page component

To make it possible to create new pages easily and modify the content structure on a page in Contentful (and not in the Next.js codebase) we need an additional component: a page component.

The page component consists of:

  • a title (required)
  • a slug (required to generate the page’s URL, must be unique)
  • content modules: a one-to-many reference where text blocks and article lists are accepted entry types (see validation settings)

This is how the generated interface should look like after running npm run generate:types:

export interface IPageFields {
  /** Title */
  title: string;

  /** Slug */
  slug: string;

  /** Content modules */
  contentModules: (IArticleList | ITextBlock)[];
}

We now create the index page in Contentful and set the title to “Welcome to my Harry Potter blog!”, the slug to index and add the homepage introduction text block and the latest articles list as content modules.

A screenshot of Contentful with the edited index page opened displaying what’s been described in the paragraph above.

Using the page component for the homepage

In the homepage’s code (index.tsx), we need to make some updates to actually use the recently created page component as a content source. We are going to remove the hardcoded text and instead of fetching the articles directly, we fetch the page we created instead. Note that for the index page only, I’m going to fall back to my “fetch-element-by-ID” approach because there should always be one dedicated index page available in Contentful that should never be deleted by the client. We are going to build a component for generic pages—which can be used for an about me page, a contact page, etc.—later.

In the content-service.ts file, we add another function to the ContentService class:

getEntryById<T>(id: string) {
  return this.client.getEntry<T>(id, {
    // Including more levels is necessary if there are nested entries in the Contentful content model
    include: 3,
  });
}

And here is the updated index file:

import type { GetStaticProps, NextPage } from "next";
import Head from "next/head";
import { IPageFields } from "../src/@types/contentful";
import { HOMEPAGE_ID } from "../src/util/constants";
import ContentService from "../src/util/content-service";
import styles from "../styles/Home.module.css";

type Props = Pick<IPageFields, "title" | "contentModules">;

const Home: NextPage<Props> = ({ title, contentModules }) => (
  <div className={styles.container}>
    /* The <Head> component should be here, but is not relevant for this code example. */

    <main className={styles.main}>
      <h1 className={styles.title}>{title}</h1>

      // TODO: render the contentModules
    </main>
  </div>
);

export default Home;

export const getStaticProps: GetStaticProps<Props> = async () => {
  const indexPage = await ContentService.instance.getEntryById<IPageFields>(
    HOMEPAGE_ID,
  );

  return {
    props: {
      ...indexPage.fields,
    },
  };
};

I removed all the content and replaced the title with the title value now coming from Contentful. In the getStaticProps function, instead of the articles, I fetch the homepage by its ID—you can find this value when you navigate to the “Info” tab (right sidebar) when editing the page in Contentful and look for “Entry ID”.

The page should look like this in the browser now:

A screenshot of Firefox with the index page that now only contains the page’s title: “Welcome to my Harry Potter blog!”

The content module renderer

I want to be able to render every possible content component that could be part of the contentModules, so I need an additional React component for this task: the ContentModuleRenderer. I create the following file in the src/components folder:

/* ContentModuleRenderer.tsx */
import React from "react";
import {
  IArticleListFields,
  IPageFields,
  ITextBlockFields,
} from "../@types/contentful";
import ArticleList from "./ArticleList";
import TextBlock from "./TextBlock";

type Props = {
  module: IPageFields["contentModules"][0];
};

const ContentModuleRenderer = ({ module }: Props) => {
  const contentTypeId = module.sys.contentType.sys.id;

  switch (contentTypeId) {
    case "articleList":
      return <ArticleList {...(module.fields as IArticleListFields)} />;
    case "textBlock":
      return <TextBlock {...(module.fields as ITextBlockFields)} />;
    default:
      console.warn(`${contentTypeId} is not yet implemented`);
      return null;
  }
};

export default ContentModuleRenderer;

I map the content module’s type to the respective component and pass the fields—in our case, we need to support the “article list” and the “text block” components for now. Back on the index.tsx page, I replace the TODO and update the code as follows:

const Home: NextPage<Props> = ({ title, contentModules }) => (
  <div className={styles.container}>
    /* The <Head> component should be here, but is not relevant right now. */

    <main className={styles.main}>
      <h1 className={styles.title}>{title}</h1>

      {contentModules.map((module) => (
        <ContentModuleRenderer key={module.sys.id} {...{ module }} />
      ))}
    </main>
  </div>
);

The updated page looks like this in the browser:

A screenshot of Firefox with the index page that contains the page’s title, the text block with the introduction text and the first three article we added to the articles list.

The great thing about the setup we’ve just built is that content editors now have full control over the content of a page (order of content modules, content of text blocks,…), while the design is still maintained in the Next.js codebase. Every time a new component is required on the website, you need to create it in Contentful first, re-generate the types, provide the component in Next.js, and finally add it to the “Content module renderer”.

Creating pages automatically

Up until now, we used the page component only for the index page. What if the client created a new page in Contentful and we wanted it to be part of our website? To accomplish this, I am going to restructure the pages folder: I move the [slug].tsx file we created for the articles to a subfolder articles/[slug].tsx and create a new empty [slug].tsx file in the pages folder.

pages/
  articles/
    [slug].tsx
  [slug].tsx
  _app.tsx
  index.tsx

In the Article.tsx component, we need to update the value of the link’s href attribute:

<a href={`/articles/${slug}`} className={styles.card}>

In Contentful, let’s create a simple test page with a title (“About me”), a slug (about-me), and a text block with a few lines of text content (this can be “Lorem ipsum” for now).

In the newly created [slug].tsx file, we need to

  1. generate paths for all pages
  2. fetch the content of all pages
  3. render the content of all pages

This is similar to what we already implemented for the article detail pages. The only difference in getStaticProps and getStaticPaths is that I exclude the page where slug === "index", because we handle this page separately.

The component code is almost the same as for the homepage. For demonstration purposes, I reused the Home.module.css styles. Here’s the full page code:

import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import Head from "next/head";
import React from "react";
import { IPageFields } from "../src/@types/contentful";
import ContentModuleRenderer from "../src/components/ContentModuleRenderer";
import ContentService from "../src/util/content-service";
import styles from "../styles/Home.module.css";

interface Props {
  page: IPageFields;
}

const Page: NextPage<Props> = ({ page }) => {
  const { title, contentModules } = page;

  return (
    <div className={styles.container}>
      <Head>
        <title>{title} | My awesome Harry Potter blog</title>
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>{title}</h1>

        {contentModules.map((module) => (
          <ContentModuleRenderer key={module.sys.id} module={module} />
        ))}
      </main>
    </div>
  );
};

export default Page;

export const getStaticProps: GetStaticProps<Props, { slug: string }> = async (
  ctx,
) => {
  const { slug } = ctx.params!;

  const pages = (
    await ContentService.instance.getEntriesByType<IPageFields>("page")
  ).filter((page) => page.fields.slug !== "index");

  const page = pages.find((page) => page.fields.slug === slug);

  if (!page) {
    return { notFound: true };
  }

  return {
    props: {
      page: page.fields,
    },
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const pages = (
    await ContentService.instance.getEntriesByType<IPageFields>("page")
  ).filter((page) => page.fields.slug !== "index");

  return {
    paths: pages.map((page) => ({
      params: {
        slug: page.fields.slug,
      },
    })),
    fallback: false,
  };
};

When I navigate to http://localhost:3000/about-me, this is what I can see in my browser:

A screenshot of the about me page in the browser. The headline says “About me” and the text content are the first lines of “Lorem ipsum”.

Summary

Let’s summarize all the steps for a flexible website setup of this article. This is what we did; we:

  1. Created a React component for the article link (Article.tsx)
  2. Created two new Contentful components: Text block and Article list
  3. Created React components for Text block and Article list (TextBlock.tsx and ArticleList.tsx)
  4. Created a reusable Page component in Contentful
  5. Used the Page component for the index page content
  6. Built a content module renderer to handle all possible content module elements that can be part of a page (in our case: text block and article list)
  7. Restructured the pages folder and created a generic [slug].tsx file to automatically build all pages created in Contentful

What’s next?

On the way to a great website, there are still many open tasks left:

  • the presented code snippets can be beautified 💅; code can be reused by creating even more components
  • more components will be required—for the content and for the layout/structure
  • page metadata and navigation are large topics where I am still trying to find a solution that works both for me as a developer and my clients as well
  • and much more… 🤯

The goal of this and the previous blog post was to outline my approach to creating a static website setup by defining the structure of the site and its content in Contentful and maintaining the business logic and styles in a Next.js project, all with TypeScript support. 🎉

I am happy to discuss this article and/or answer any open questions over on Twitter! 🐦

Any thoughts or comments?

Say hello on Mastodon or contact us via email or contact form!