Implementing Contentful

⚠️ This recipe is out-of-date. It needs to be updated in regards to i18n and to prioritize the usage of getStaticProps / getServerSideProps methods. If you are going to use use Contentful in your next project, please upgrade this recipe.

This recipe aims to guide you through the implementation of Contentful in the project and also provides custom solutions for different use-cases.

What is a CMS?

A CMS, or Content Management System, is a platform that helps in the creation and management of content to be consumed in a web application. In short, these services provide a database and a database management system with a layer of usability on top that makes them inclusive to users without technical expertise.

Contentful

Contentful is one such CMS, and the object of this recipe.

For more information about Contentful's API please refer to their documentation.

Modeling the schema

As a developer working on the App that will consume the content stored in the CMS, you're also responsible for creating and modeling the schema itself. This will mean creating new content models that must be usable by the client team which will eventually handle Contentful after the hand off of the project. One major consideration to have is the usability and readability of the content models you're creating. Remember that they are meant to be usable without any previous technical knowledge of this context.

Contentful offers many options for type validations, input appearance and descriptions to help the users understand what kind of input is expected. You should use all of them to minimize the chance of users not understanding what's expected of them, in what ways they are limited, reducing the chances of unexpected content in your application.

Keep in mind:

→ Always use appropriate types when possible,

→ Use validations where appropriate to make sure the input values are what the developer expects,

→ Use meaningful titles and descriptions to ensure the user knows what's expected of that field.

Localization

Contentful makes it easy to add localized content to your application. Once you define a locale, which you can do in the Locales option in the Settings menu, you can specify in each field you create whether they can be localized, in what languages, etc., giving you fine control over what needs to be localized and what doesn't.

Once having these locales, you can then use the Contentful API signaling your desired locale through the locale field. You'll find an example of this in the next section.

Preview

Contentful splits content changes in 3 states:

  • Published: when a user saves some content.
  • Changed: when the user changes previously saved content but still hasn't saved the new one.
  • Draft: when the user creates new content but doesn't save.

Usually, only Published content is available, unless a specific request is done. This request is called Preview and may help users see beforehand how content would look like on their website before publishing it and making it available for every visitor.

However, Contentful does not validate content that is not published. This means that, if your app relies on validation from Contentful, it will likely break in preview mode unless all content in Contentful already passes validation. This is assumed and must taken into account when developing your applications and communicated to potential users.

Walk-through

1. Installing Contentful SDK

Using Contentful in your application can be done using the Contentful SDK available as an npm package, which you can install using the following command:

npm install --save contentful

This SDK provides access to Contentful's Delivery API and Preview API, both of which will be used in the integration of Contentful in your app.

First, you'll need two Contentful access tokens and a space identifier. All three can be found in the Settings menu, under the API keys option:

  • Space ID,
  • Delivery token,
  • Preview token;

Each token must be used together with their specific host, as follows:

  • Delivery host: cdn.contentful.com,
  • Preview host: preview.contentful.com;

After obtaining the Contentful access tokens, you need to set these environment variables:

CONTENTFUL_SPACE_ID=<SPACE_ID>
CONTENTFUL_TOKEN=<DELIVERY_TOKEN>
CONTENTFUL_PREVIEW_TOKEN=<PREVIEW_TOKEN>

Setting up the SDK can be done as follows:

import { createClient } from 'contentful';
const client = createClient({
space: // Space ID token
accessToken: // Either the Delivery token, or the Preview token
host: // Either the Delivery host, or the Preview host
});

This client exposes the functions necessary to fetch the data in Contentful, for example:

const result = await client.getEntries({
content_type: 'clients',
});

If you're taking advantage of localization in your Contentful space, you must add to the request a field with your intended language code, like so:

const result = await client.getEntries({
content_type: 'clients',
locale: 'en-US'
});

In the contents you are retrieving, you may have links to other contents. In this case, you will have to decide if you want the SDK to resolve those links for you. This way, instead of a content link being returned in the includes property of the response, the actual content will be placed in the field where the linked entry metadata would be.

You can change the depth to which links will be resolved with the option include. By default it is set at 1, and its maximum value is 10. Please consider that a higher include value will introduce more complexity to the resolver and may result in a loss of performance.

const result = await client.getEntries({
content_type: 'clients',
include: 0,
});

2. Populating the app with fetched content

To populate the app with the fetched content, we will explore two possible implementations: one for bigger applications that justify the inclusion of a Redux store, and another which doesn't.

2.1. Accessing the client directly though getInitialProps

In Next.js apps, the getInitialProps function in general components typically receives a context object, which exposes many aspects of the context in which the page is rendered. Because we are building a custom app, and doing the work of running these getInitialProps functions ourselves, we can control what data they receive. In practice, we will be populating the context object with the information we want to propagate to our components. So, in your App.js file, you can check whether the page was loaded with a cms-preview query parameter, build your client, and share it across the app.

This function also has the advantage of letting you return props to your App function, making it easier to signal the App itself if you're in preview mode.

// www/app/App.js
import { createClient } from 'contentful';
App.getInitialProps = async ({Component, ctx, router}) => {
// Check whether route has `cms-preview` query parameter
const isPreviewingContentful = router.query['cms-preview'] != null;
// Set the preview or regular host
const contentfulHost = isPreviewingContentful ? 'preview.contentful.com' : 'cdn.contentful.com';
const contentfulToken = isPreviewingContentful ? process.env.CONTENTFUL_PREVIEW_TOKEN : process.env.CONTENTFUL_TOKEN;
// Create client with correct host
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: contentfulToken, // Delivery API Token
host: contentfulHost, // Delivery API host
});
// Append it to the context object so other Components can access it
ctx.contentfulClient = client;
let pageProps = {};
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx);
}
// Return the state here to receive as a prop,
// allowing you to conditionally render a DOM element in case of preview
return { pageProps, isPreviewingContentful };
};

Afterwards, you can use the Contentful client in your component's getInitialProps to fetch content required for that component.

// www/pages/use-cases/UseCases.js
UseCases.getInitialProps = async (ctx) => {
const { contentfulClient } = ctx;
// Fetch data from Contentful
const entries = await contentfulClient.getEntries({
content_type: 'useCase',
});
// Return result as props
return { entries };
};

2.2. Using Redux

When using Next.js, you can use next-redux-wrapper to integrate Redux in your application. We will be using this wrapper as part of this walkthrough, but will not cover its integration. For more detailed information on this package, e.g. how to install it, please refer to its documentation.

As part of using this package, you'll have to create a function (which we'll call buildStore ) which should return the instance of the Redux store in your application, and it will be here that you will create your client instance, and decide whether it should fetch published or preview content.

Given the nature of Next.js and the implementation of next-redux-wrapper, buildStore will run twice, once server-side and once client-side, requiring you to consistently instantiate the client to be the same for both cases. But the same strategy cannot be used for both. Each requires its own solution.

In the following example we will be checking the presence of a cms-preview query parameter to decide whether to show preview content or the standard, published content, i.e. something like www.example.com/?cms-preview.

// www/shared/redux/buildStore.js
import { createClient } from 'contentful';
import { applyMiddleware, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
export const buildStore = (initialState, { query }) => {
// Instantiate standard client by default
let client = createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_TOKEN, // Delivery API Token
host: 'cdn.contentful.com', // Delivery API host
});
if (
// For server-side, check whether `query` exists and has the `cms-preview` key
query?.['cms-preview'] != null ||
// For client-side, check whether `window` exists and has the `cms-preview` query parameter
(typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('cms-preview'))
) {
// In a positive case, switch the client instance to access the Preview API instead
client = createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN, // Preview API Token
host: 'preview.contentful.com', // Preview API host
});
}
const reducer = {
// Example reducer
};
// Add client as a thunk middleware
// This will give us access to the `client` object in our fetcher functions as an argument
const middlewares = applyMiddleware(thunkMiddleware.withExtraArgument({ client }));
return createStore(reducer, initialState, middlewares);
}
export default buildStore;

Then, in your actions scripts, you can use the provided client to fetch the content that you need, like so:

// www/shared/redux/usecases/actions.js
// Create a fetch action that fetches data from Contentful
const fetch = () => async (dispatch, getState, { client }) => {
dispatch({ type: 'Fetching' });
try {
// Fetch content that are instances of `useCase` content model, in Spanish
const result = await client.getEntries({
content_type: 'useCase',
locale: 'es-ES',
});
// The incoming object will be fairly complex, and it's recommended to build
// a more conveniently structured object here so that other scrips in the pipeline
// can avoid handling that complexity and can instead enjoy a simpler, custom built object.
// E.G.
const entries = result.items
.map((item)=> ({
title: item.fields.title,
description: item.fields.description,
weight: item.fields.weight,
})).sort((a, b) => b.weight - a.weight);
dispatch({
type: 'Success',
payload: { entries },
});
} catch (error) {
dispatch({
type: 'Failure',
payload: { error },
});
}
};

2.3 Rendering Images

When rendering images from Contentful, you should use @moxy/react-contentful-image in order to easily take advantage of Contentful's Image API.

NOTES:

ℹ️ The Contentful Image API provides a CDN when serving the assets so processed images are cached to reduce response time.

ℹ️ The Contentful Image API does not provide compression for raster or vector images at the time of writing (see this and this). As such, we need to make sure the assets uploaded to Contentful are already compressed as much as possible, for example by using tinypng before uploading in the Contentful UI.

Raster images

In order reduce image file size as much as we can, make use of the parameters format and resize. The default format is webp which provides low file sizes without major quality losses.

When rendering small logos opt for 8bit png and resize to the maximum used height:

<ContentfulImage
image={ image }
format="8bit png"
resize={ { height: 50 } }
/>

NOTES:

ℹ️ The component does not at the moment generate srcsets for different screen sizes, see the issue here. Please contribute to @moxy/react-contentful-image in order to have this functionality available to everyone.

Vector (SVGs)

No compression or optimization options can be applied here. So in order to handle SVGs:

  • Make sure all SVG files uploaded to contentful are properly compressed and optimized beforehand through a visual editor and SVGOmg.
  • When rendering, skip any kind of optimization and use the file from the API as is. You can conditionally render the image or use the helper parameter optimize:
    <ContentfulImage
    image={ image }
    optimize={ !image.url.includes('.svg') }
    format="8bit png"
    resize={ { height: 50 } }
    />

3. Conditionally rendering DOM elements in case of preview

Since we're on the topic, you must use a similar strategy in your App.js file to conditionally render a DOM element to let the user know they are in a preview state. This must happen once per instance of the App, since we will not perpetuate the cms-preview query parameter on route changes.

As an example, we show how to conditionally render ContentfulPreview, which is a component that consists in a ribbon that is placed on the page to indicate the user is viewing a preview version of the content.

Using hooks:

// www/app/App.js
const App = ({ Component, pageProps, rootSelector, router }) => {
const [isPreviewingContentful, setIsPreviewingContentful] = useState(false);
// Using a useEffect hook with no dependencies guaranties that it fires only once per instance of App
useEffect(() => setIsPreviewingContentful(router.query['cms-preview'] != null), []);
return (
(...)
{ isPreviewingContentful && <ContentfulPreview /> }
(...)
);
}

Class component:

// www/app/App.js
class App extends NextApp {
state = {
isPreviewingContentful: false,
};
// Using the componentWillMount() lifecycle function guarantees that this runs only once per instance of App
componentWillMount() {
const { router } = this.props;
this.setState({
isPreviewingContentful: router.query['cms-preview'] != null,
});
}
render() {
return (
(...)
{ this.isPreviewingContentful && <ContentfulPreview /> }
(...)
);
}
}

4. CMS Translations

This boilerplate already includes Internationalization using @moxy/next-intl, this is done by statically configuring individual intl/messages/<locale>.json per locale. You can do this dynamically by moving this static files to a Contentful content model and by enabling localization in it.

1. Defining the content type

Firstly, define the content type that will consist on a list of key/value pairs. Add the fields necessary to fit your needs, for example homePageTitle.

ℹ️ Make sure that you enable localization on every field you set in your content type.

After creating your content type populate the respective fields accordingly.

2. Overriding @moxy/next-intl

Now that you have your content type set you should update the /intl/index.js file to fetch the data from Contentful. This is done by using the Contentful SDK.

locales: [
{
id: 'en-US',
name: 'English',
loadMessages: async () => {
const content = await client.getEntries({
content_type: 'genericTranslations',
locale: 'en-US',
// The parameter bellow is needed to avoid the problem described at the top of the file.
});
return content.items[0].fields;
},
},
],

Cache

1. Contentful API rate limits

API Rate limits specify the maximum number of requests a client can make to Contentful APIs in a specific time frame. Every request counts against a per second rate limit.

Currently, Contentful doesn't enforce any limits on requests that hit their CDN cache. For requests that do hit the Content Delivery API enforces rate limits of 78 requests per second.

When a client gets rate limited, the API responds with the 429 Too Many Requests status code and sets the X-Contentful-RateLimit-Reset header that tells the client when it can make its next request.

2. Custom Caching Layer

One preventive measure to avoid hitting the rate limit for Contentful is to implement your own custom caching layer. This can be done by setting up a proxy server which will add an s-maxage HTTP header into the Contentful's response.

This header will then be interpreted by the CDN that is delivering the application (for example CloudFlare), which will cache the response and avoid repeating the same request to Contentful during a specified time interval.

The first thing you'll need in order to implement this solution is to install http-proxy:

$ npm i http-proxy

Then, you'll want to create an endpoint in your application which will serve as a proxy for all the requests directed at Contentful's API.

For this, you'll have to create the file pages/api/cms/[...cms].js. This file/directory structure and naming is important because you'll want to receive any requests directed to <hostname>/api/cms/*.

This endpoint will create the proxy server and, on each request, remove /api/cms from the request, rewrite the host header to the correct host (cdn.contentful.com), redirect the request to cdn.contentful.com and set the Cache-Control HTTP header of the response to s-maxage=60.

// pages/api/cms/[...cms].js
import httpProxy from 'http-proxy';
const proxy = httpProxy.createProxyServer({});
export default (req, res) => {
req.url = req.url.replace('/api/cms', '');
req.headers.host = 'cdn.contentful.com';
proxy.web(req, res, {
target: {
host: 'cdn.contentful.com',
},
});
if (req.method === 'GET') {
res.setHeader('Cache-Control', 's-maxage=60');
}
};

Finally, you will need to setup the Contentful client to direct it's requests to the endpoint you just configured. Please note that you only want to cache the requests when in a production environment.

const clientOptions = {
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_TOKEN,
};
if (process.env.NODE_ENV === 'production') {
clientOptions.host = process.env.SITE_URL;
clientOptions.basePath = '/api/contentful';
}
const client = createClient(clientOptions);

NOTES:

ℹ️ You have to be able to access to process.env.NODE_ENV from the client side as well as the server side, to proxy client side requests to Contentful in production environments.

ℹ️ The process.env.SITE_URL variable has to be correctly configured and accessible from both server and client side, otherwise the request to the proxy endpoint will not happen correctly. This may mean that preview url's in merge request will not function correctly, which will happen if the application is started with process.env.NODE_ENV set to production.

Custom SEO

There may be cases where you will want to configure custom SEO per page. Unfortunately, Contentful does not provide out-of-the-box SEO support, so you will need to implement your own strategy. Bellow you can find our approach to the problem.

Create a content model for SEO

To make it easier to configure SEO you need to create a specific content model just for it.

While creating a content model for SEO please do the following:

  • Add an entry title field to identify the model, e.g. "Homepage SEO".
  • Add a text field called Title to be used as the title tag and for the following meta tags: [og:title, twitter:title].
  • Add a text field called Description to be used for the following meta tags: [description, og:description, twitter:description].
  • Add a media field called Image to be used for the following meta tags: [og:image, twitter:image].
  • Add a JSON field called Additional SEO to provide the possibility to add more meta tags.
  • Add a many files media field called Additional SEO Assets to give the possibility to use CMS images in meta tags.

The resulting content model should look like this:

SEO Content Model

The Additional SEO must follow the same structure documented in @moxy/next-seo package but with a small difference. Since Contentful doesn't provide an easy way to obtain an image URL directly from their dashboard, we need to reference the assets we want with the Additional SEO Assets field and use their ids to specify which one should be used:

{
"meta": [
{
"property": "og:locale",
"content": "en_GB"
},
{
"property": "og:image",
"content": {
"id": "6fU8dkL1P9eZlPE9JPw89n"
}
}
]
}

By defining content as an object with an id property we make the assumption that the content derives from a CMS asset.

In order to obtain the id you need to select the asset entry and, on the right side panel of the asset, select Info and copy the id. Later in the application you'll use this id to generate the URL for the asset.

Add a link for SEO to other content models

Now that the SEO content model is created, its time to add a reference field type in other models that might need SEO.

Remember to add a validation to only accept an SEO content type.

Preparing the application

On the application itself, the steps to customize the SEO are the following:

  • Install @moxy/next-seo.
  • Define a default SEO data.
  • Render the default SEO data in the App.js file using @moxy/next-seo (outside the Head tag).

Create a component to deal with a Contentful SEO entry

After fetching the page data from Contentful as you normally would, you might now have an SEO field. To be able to render its content in the right manner we suggest you use a ContentfulSeo component that prepares everything to be rendered with @moxy/next-seo.

import React from 'react';
import PropTypes from 'prop-types';
import NextSeo from '@moxy/next-seo';
import { useRouter } from 'next/router';
import { isPlainObject } from 'lodash';
const parseAdditionalSeoItem = (item, assets) => {
const { content } = item;
if (isPlainObject(content)) {
const asset = assets.find(({ sys }) => sys.id === content.id);
item.content = asset ? `http:${asset.fields.file.url}` : '';
}
return item;
};
const parseAdditionalSeo = (seo, assets) => {
const { meta = [], link = [] } = seo;
const parsedMeta = meta.map((item) => parseAdditionalSeoItem(item, assets));
const parsedLink = link.map((item) => parseAdditionalSeoItem(item, assets));
return {
meta: parsedMeta,
link: parsedLink,
};
};
const ContentfulSeo = ({ data }) => {
const { asPath = '' } = useRouter();
const metadata = {
meta: [{ property: 'og:url', content: `${process.env.SITE_URL}${asPath}` }],
link: [],
};
if (data && data.fields) {
const { title, description, image, additionalSeo, additionalSeoAssets } = data.fields;
if (title) {
metadata.title = title;
metadata.meta.push({ property: 'og:title', content: title });
metadata.meta.push({ property: 'twitter:title', content: title });
}
if (description) {
metadata.meta.push({ name: 'description', content: description });
metadata.meta.push({ property: 'og:description', content: description });
metadata.meta.push({ property: 'twitter:description', content: description });
}
if (image) {
const imageUrl = `http:${image.fields.file.url}`;
metadata.meta.push({ property: 'og:image', content: imageUrl });
metadata.meta.push({ property: 'twitter:image', content: imageUrl });
}
if (additionalSeo) {
const { meta, link } = parseAdditionalSeo(additionalSeo, additionalSeoAssets);
metadata.meta = [...metadata.meta, ...meta];
metadata.link = [...metadata.link, ...link];
}
}
return <NextSeo data={ metadata } />;
};
ContentfulSeo.propTypes = {
data: PropTypes.object,
};
export default ContentfulSeo;

The data it receives is the SEO field in its original state.

So, to render Contentful SEO you just need to do the following:

const MyPage = ({ contentfulData }) => {
const { seo } = contentfulData.fields;
return (
<div className={ styles.container }>
<ContentfulSeo data={ seo } />
...
</div>
);
};

Where contentfulData is the data that you have fetched from Contentful.