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
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!,
});
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"
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(),
}),
});
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 acontext
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 { ... }
}
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 => {...})
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,
}
}
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,
}
}
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"]>>>;
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(),
}));
And infer it in types/contentful.d.ts
as follows:
type ExampleModelEntry = ExtractModelType<
typeof import("../contentful/example-model")["exampleModel"]
>;
type ExampleModelFields = ExampleModelEntry["fields"];
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(),
}
}
}
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.
- Set up the new content type in your Contentful workspace.
- Create a new model with
createContentfulModel
and only provide the schema for its fields and the content type id. - Add its types to
types/contentful.d.ts
- 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)