DEV Community

Cover image for How to use Contentful with Next.js and Zod
Jussi Nevavuori
Jussi Nevavuori

Posted on • Edited on

How to use Contentful with Next.js and Zod

Using headless CMS’ with modern web technologies such as Next.js has long been a popular way to create websites and manage their content. In this article we’re going to explore a typesafe, easily extensible method of accessing your Contentful schemas.

The target of this article is to create an abstraction for each data model which allows for type safe querying. We call this abstraction createContentfulModel, which will take as input the schema for your content and provide you with typed and validated data.

Step 1 - Setup your Contentful workspace and client

In order to setup your Contentful workspace, go to contentful.com, sign up and set up your Contentful workspace. There you can first create your content models and then create content to suit those models.

Returning to the code side of things, we first create a new next project (with TypeScript) and install contentful with npm.

$ npx create-next-app@latest --ts
$ npm i contentful
Enter fullscreen mode Exit fullscreen mode

We set up our Contentful client in contentful/client.ts according to the Contentful documentation.

import { createClient } from "contentful";

export const contentfulClient = createClient({
  space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!,
  accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN!,
});
Enter fullscreen mode Exit fullscreen mode

We must also provide the necessary environment variables to set up our client and to connect it to our Contentful workspace. In Contentful, under settings and API keys, create a new API key. We’re going to need its Space ID and Content Delivery API access token. Copy and paste the correct values into your .env.local file as shown below.

NEXT_PUBLIC_CONTENTFUL_SPACE_ID="your-space-id"
NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN="your-access-token"
Enter fullscreen mode Exit fullscreen mode

Step 2 - Set up Zod schemas for typesafety

First run npm i zod to install Zod, the arguably best type validation library available for TypeScript.

Before starting to set up the schemas and shapes for our content, we’re going to need Contentful specific types. All Contentful queries return object entries, which contain all content in the fields property, but also contain the metadata and sys properties which include useful metadata about the object, such as it’s ID and timestamps.

We’re first going to set up a contentfulEntrySchema in contentful/contentful-entry-schema.ts which will contain a Zod schema corresponding to an object entry with an empty fields property (which we will later extend).

import { z } from "zod";

export const contentfulEntrySchema = z.object({
  fields: z.object({}), // Extend this later
  metadata: z.object({
    tags: z.array(z.any()),
  }),
  sys: z.object({
    space: z.object({
      sys: z.object({
        type: z.string(),
        linkType: z.string(),
        id: z.string(),
      }),
    }),
    id: z.string(),
    type: z.string(),
    createdAt: z.string(),
    updatedAt: z.string(),
    environment: z.object({
      sys: z.object({
        id: z.string(),
        type: z.string(),
        linkType: z.string(),
      }),
    }),
    revision: z.number(),
    contentType: z
      .object({
        sys: z.object({
          type: z.string(),
          linkType: z.string(),
          id: z.string(),
        }),
      })
      .optional(),
    locale: z.string(),
  }),
});
Enter fullscreen mode Exit fullscreen mode

This allows fully validating all data queried from Contentful and we can easily extend it to suit any content type by overriding the fields property.

Step 3 - Creating createContentfulModel

Now that we have most of our boilerplate set up, we’re going to start working on the createContentfulModel abstraction. Our objective is to create a function that takes as input

  • contentType, which defines which content we want to target in our Contentful workspace
  • fieldsSchemaCreator function, which returns a Zod schema for our content type and provides utilities for creating those schemas in a context object (more on it in the next article).

As output, we want the function to provide us an object with the following properties:

  • The schemas which were created using fieldsSchemaCreator
  • All data fetching functions (in this example we’re only going to implement getAll) to fetch all data without querying.

Let’s start creating the function in contentful/create-contentful-model.ts

// More in the next article on the create contentful model context
export type CreateContentfulModelContext = {};

export type FieldsSchemaCreator<TDataIn extends {}, TDataOut> = (
    context: CreateContentfulModelContext
) => z.Schema<TDataOut, z.ZodTypeDef, TDataIn>;

export function createContentfulModel<TDataIn extends {}, TDataOut>(
    contentType: string,
    fieldsSchemaCreator: FieldsSchemaCreator<TDataIn, TDataOut>
) {
    ...

    return { ... }
}
Enter fullscreen mode Exit fullscreen mode

We want to be able to use this function as follows:

const exampleModel = createContentfulModel("example", (ctx) => z.object({
    title: z.string(),
    description: z.string().optional(),
    rating: z.number().int().positive(),
}));

exampleModel.getAll().then(examples => {...})
Enter fullscreen mode Exit fullscreen mode

The fields schema creator should return a Zod object which represents the fields of the content type we want to access. Let’s start implementing it.

export function createContentfulModel<...>(...) {

    // Set up an empty context. We'll return to this in the next article.
    const context: CreateContentfulModelContext = {};

    // Run the fieldsSchemaCreator with the context to get the type of
    // the content's fields.
    const fieldsSchema = fieldsSchemaCreator(context);

    // Using the object entry schema we defined earlier, extend its fields
    // property to define this object type's full entry schema
    const entrySchema = contentfulEntrySchema.extend({ fields: fieldsSchema });

    // Return schemas
    return {
        fieldsSchema,
        entrySchema,
    }
}
Enter fullscreen mode Exit fullscreen mode

We now have access to the full schemas. Next we need to create data fetcher functions, which will allow us to fetch data in all ways we need. Note: for this project we will only be implementing the getAll fetcher. Rest (getOneById, getAllWhere, and any others you might need) are left as an exercise to the reader. For small projects with only small amounts of data, especially with SSG getAll might be all you need.

export function createContentfulModel<...>(...) {
    // ...

    // Create the get all fetcher to fetch all items of the current
    // content type.
    const getAll = async () => {
        // Fetch all items of current content type
        const res = await contentfulClient.getEntries({ content_type: contentType });

        // Parse and validate all items using zod
        const parsed = z.array(entrySchema).safeParse(res.items);

        // Handle failures
        if (!parsed.success) {
            console.error(parsed.error);
            return [];
        }

        // Return validated data with correct types
        return parsed.data;
    }

    return {
        fieldsSchema,
        entrySchema,
        getAll,
    }
}
Enter fullscreen mode Exit fullscreen mode

We can now use createContentfulModel to create typed, validated abstractions for each of our content types in our Contentful workspace.

Step 4 - Inferring the types

Let’s further utilise the magic of Zod. Instead of having to type out a type for each of our content models, we can simply infer the types from the schemas we already have. No more need for maintaining the representation of the content model in our code in two separate places. To easily infer the types, we’re going to create types/contentful.d.ts in our project. When using a .d.ts file with only inline imports, we don’t even need to import the types in our project where they are used, we only need to include contentful.d.ts in our tsconfig.json.

To help inferring the types, we’re going to create a utility type ExtractModelType which is provided any model created with createContentfulModel and it will return the type of the content entry.

// types/contentful.d.ts

// Get inner type of Array or Promise
type Inner<T> = T extends Array<infer U1>
  ? U1
  : T extends Promise<infer U2>
  ? U2
  : T;

// Helper type to extract a model's type from the model object
type ExtractModelType<
  Model extends ReturnType<
    typeof import("../contentful/create-contentful-model")["createContentfulModel"]
  >
> = Inner<Inner<ReturnType<Model["getAll"]>>>;
Enter fullscreen mode Exit fullscreen mode

Next we’re going to create an example content model at /contentful/example-model.ts.

export const exampleModel = createContentfulModel("example", (ctx) => z.object({
    title: z.string(),
    description: z.string().optional(),
    rating: z.number().int().positive(),
}));
Enter fullscreen mode Exit fullscreen mode

And infer it in types/contentful.d.ts as follows:

type ExampleModelEntry = ExtractModelType<
  typeof import("../contentful/example-model")["exampleModel"]
>;

type ExampleModelFields = ExampleModelEntry["fields"];
Enter fullscreen mode Exit fullscreen mode

The ExampleModelEntry type represents the full object entry returned by the Contentful in the fetcher functions. It contains all metadata in the sys and metadata fields. The content is contained in the fields property and has the type of ExampleModelFields.

Step 5 - Consuming the models

After a lot of work to set up our Contentful models, we can now start consuming them in our applications. The work we did earlier starts now paying off, as consuming our types is as easy as doing the following getStaticProps (or any other place where you might fetch your data).

import type { GetStaticProps, InferGetStaticPropsType } from 'next'
import { exampleModel } from '../contentful/example-model'

export default function Page({ examples }: InferGetStaticPropsType<typeof getStaticProps>) {
    return <ul>
        {
            examples.map(example => <li key={example.sys.id}>
                <p>{example.fields.title}</p>
                <p>{example.fields.description}</p>
                <p>{example.fields.rating} / 5</p>
            </li>)
        }
    </ul>
}

export const getStaticProps: GetStaticProps<{ examples: ExampleModelEntry[] }> = async () => {
    return {
        props: {
            examples: await exampleModel.getAll(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

About abstractions and the work required to create them

By using time to design and create good abstractions, your code will be much easier to maintain, read and extend in the future. The work required to set up createContentfulModel in this article may seem like a lot. But consider the steps required now to add a new content type to your application.

  1. Set up the new content type in your Contentful workspace.
  2. Create a new model with createContentfulModel and only provide the schema for its fields and the content type id.
  3. Add its types to types/contentful.d.ts
  4. You’re ready to consume the data anywhere with the .getAll() method.

In addition to this, abstractions make your code easily extensible. Implementing other fetchers than the getAll fetcher will make them available for all content types by writing the code once. In the next article, we will examine extending this abstraction to work with Contentful images and rich text using the context object teased in this article.

In the next article

Read the next article to examine how we’ll extend this abstraction. We’ll be creating

  • Support for Contentful images
  • Support for Rich Text

(Not yet published)

Top comments (0)