> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://contentful.com/developers/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://contentful.com/developers/docs/_mcp/server.

# Building a website with Compose in Next.js

> Build a website with Compose in Next.js | Routing | Fetching content | Rendering content, metadata | Creating a new page type

> **Info**
>
> Compose is being deprecated. It is in maintenance mode and won’t be updated with any new features. Current installations will work until the end of 2026.

> **Info**
>
> This document describes Compose that is driven by a legacy content model. If you set up Compose after April 21st 2022, please refer to the updated [Compose documentation](/compose/what-is-compose).

In this guide you will learn how to develop a website using Compose. Contentful provides an example implementation of a Help Center website in the [contentful/compose-starter-helpcenter-nextjs](https://github.com/contentful/compose-starter-helpcenter-nextjs/tree/legacy-content-model) GitHub repository. This is an application built with the [Next.js](https://nextjs.org/) framework which is powered by [React](https://reactjs.org/).

## Routing

Each `page` entry has a unique `slug` text field that describes and identifies the given page. You can use `slug` values as part of page URLs in your website for a user- and SEO-friendly navigation. For example, on the Help Center website the “Introduction to Contentful” article will be available under the `/articles/introduction-to-contentful` URL.

If your website supports multiple languages you may also want to include a `locale` param in the URL. See our documentation about [Localization with Contentful](/tutorials/general/setting-locales/) for more information.

## Fetching content

Contentful allows you to fetch all the content needed on a given web page in one request (with certain [limits](/developers/docs/technical-limits/)). You can make a `GET` request to [Content Delivery API](/references/content-delivery-api/overview) directly or use the [JavaScript client library](https://contentful.github.io/contentful.js/contentful/7.14.8/), as in the following example:

```
import { createClient } from 'contentful';

const client = createClient({
  space: CF_SPACE_ID, // ID of a Compose-compatible space to be used \
  accessToken: CF_DELIVERY_ACCESS_TOKEN, // delivery API key for the space \
});

type GetPageParams = {
  pageContentType: string;
  slug: string;
  locale: string;
};

async function getPage(params: GetPageParams) {
  const query = {
    limit: 1,
    include: 10,
    locale: params.locale,
    'fields.slug': params.slug,
    content_type: 'page',
    'fields.content.sys.contentType.sys.id': params.pageContentType,
  };
  const { items: [page] } = await client.getEntries(query);
  return page || null;
}
```

View the full code in [/src/lib/api.ts](https://github.com/contentful/compose-starter-helpcenter-nextjs/blob/legacy-content-model/src/lib/api.ts)

You can then use the `getPage` function to fetch a given page. For example, when a user navigates to `/articles/introduction-to-contentful` URL on the Help Center website, we fetch a `Helpdesk Article` page with the `introduction-to-contentful` slug:

```
const page = await getPage({
  pageContentType: 'page_help_center_article',
  slug: 'introduction-to-contentful',
  locale: 'de-DE',
});
```

### Preview mode

If you want to support previewing unpublished content on your website, create a second Contentful client:

```
const previewClient = createClient({
  space: CF_SPACE_ID, // ID of a Compose-compatible space to be used
  accessToken: CF_PREVIEW_ACCESS_TOKEN, // preview API key for the space
  host: 'preview.contentful.com',
});
```

In the Help Center example we rely on a query string parameter `?preview=1` to decide which Contentful client to use for fetching content.

## Rendering content

Once the correct `page` data is fetched, you can render it in your website. In the Help Center example, we traverse the `page` object recursively and use [React](https://reactjs.org/) to render individual entries that make the page. There’s a React component defined for each Content Type:

```
const ContentTypeMap = {
  'page_help_center_article': HelpCenterArticle,
  'component_text': Text,
  'component_image': Image,
  'component_video': Video,
  ...
};
```

View the full code in [/src/components/renderer/block-renderer.tsx](https://github.com/contentful/compose-starter-helpcenter-nextjs/blob/legacy-content-model/src/components/renderer/block-renderer.tsx)

Below is an example of a simple React component rendering a `Text` Content Type. It includes rendering a RichText field as HTML using the [@contentful/rich-text-react-renderer](https://www.npmjs.com/package/@contentful/rich-text-react-renderer) package:

```
import React from 'react';
import { documentToReactComponents as renderRichText } from '@contentful/rich-text-react-renderer';

function Text(textEntry) {
 const { title, text } = textEntry.fields;
 return (
   <>
     {title ? <h2>{title}</h2> : null}
     {renderRichText(text)}
   </>
 );
}
```

## Rendering metadata

In addition to the `page` content, we can also use the metadata stored in the `title` and `seo` fields. Below is an example of how to use those values on a web page:

```
const seo = page.fields.seo.fields;
const { description = '', keywords = [], title = page.fields.title} = seo;
const robots = [
  seo.no_index === true ? 'noindex' : undefined,
  seo.no_follow === true ? 'nofollow' : undefined,
].filter((x) => x !== undefined);

...

<Head>
  <title>{title}</title>
  {robots.length > 0 && <meta name="robots" content={robots.join(', ')} />}
  {description.trim() !== '' && (
    <meta name="description" content={description} key="description" />
  )}
  {keywords.length > 0 && <meta name="keywords" content={keywords.join(',')} />}
</Head>
```

View the full code in [/src/components/page-head.tsx](https://github.com/contentful/compose-starter-helpcenter-nextjs/blob/legacy-content-model/src/components/page-head.tsx)

## Create a new page type and render it on the frontend

Let's wrap up all the information and see an end-to-end use case where we start publishing pages of a different type.

By this time, the following steps might look familiar.

In our case we want to add tutorials to our Help Center, so the pages will display the walk-trough text, a difficulty indicator and, if available, a link to the repo where users can find the tutorial material.

### Step 1: Create the new page type

Open your target space in the [Contentful web app](https://app.contentful.com/). Then navigate to the "Content Model" tab and click "Add content type". Enter your page type name, for us it's "Tutorial".
![Create content type](/developers/docs/_fern-img/7cc10f884aab3fdae6b0908224ed09126b25c630bc7a5621eb078961975cacb7.webp)

Now define the fields of your new page type.

![Create content type](/developers/docs/_fern-img/480a0b15f51b28dbff698ddaf8073786ea04ce9c9f51bdef832a294d589dd899.webp)

**Note:** One of the fields needs to have a short text (also `Symbol` type) field with field ID "name". In our page type the field `Title` has field ID "name".

![Tutorial title field](/developers/docs/_fern-img/ec8f6a541e5a70b4c814e89c0676352ddddfbbd7605b0649255cc4119dfb891c.webp)

### Step 2: Link the new page type to "Compose: Page"

Change the validation of the "Content" field of the "Compose: Page" content type. Make it accept your new page type.

![Add tutorial as content](/developers/docs/_fern-img/2d4e32a86eb8b72d4b967f38ac312f27ab479ac5b8ea6e97f5666e78aa5f8732.webp)

### Step 3: Create and edit a new page

It's time to get creative, open Compose and create a page with the new type. Fill in the fields with the content you want to serve in your website. Then publish the page.

![New tutorial content](/developers/docs/_fern-img/434aaacb3e5b39634a4d340978000013c9f4709a77bb3d577b13db204a8f71f0.webp)

### Step 4: Render your content

To visualize our data, we need to add a new component to our Next.js app:

```
/* new file: src/pages/[locale]/tutorials/[slug].tsx */

import React from 'react';
import { TypePage } from 'lib/types';
import { withLocale } from 'lib/translations/locales';
import { isPreviewEnabled } from '../../../lib/preview';
import { getPage } from 'lib/api';
import { PageContentTypes } from '../../../lib/constants';
import ErrorPage from 'next/error';
import { PageHead } from '../../../components/page-head';
import { isRichText, renderRichText } from '../../../lib/rich-text/render';

/*
The new Tutorial component, it renders the data fetched by getServerSideProps
See https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering
*/
const Tutorial = ({ page }: { page: TypePage }) => {

  if (!page) {
    //getServerSideProps did not find the page
    return <ErrorPage statusCode={404} />
  }

  const {
    difficulty,
    timeRequired,
    explanation,
    repoLink,
  } = page.fields.content.fields

  /* Visualize the data as you see fit, sky is the limit */
  return (
    <>
      {/* We reuse the existing code for title and seo metadata rendering in the header */}
      <PageHead page={page} />
      <div className="container w-full flex flex-wrap mx-auto max-w-screen-xl p-8 pb-16 lg:pb-24">
        <div className="w-full px-5 text-xl text-gray-800 leading-normal">
          <span className="text-sm font-medium py-2 lg:pb-4 text-gray-700 tracking-wide uppercase">
            Tutorial
          </span>
          <div className='flex flex-row justify-between'>
            <div>
              <h1>
                {page.fields.title}
                {/*Turn difficulty into a 3-dots rating*/}
                <ColorRating value={difficulty} />
              </h1>
              <span className="text-sm font-medium py-2 lg:pb-4 text-gray-500 tracking-wide">
                <TimeIcon /> {timeRequired} minute{timeRequired !== 1 ? 's' : ''}
              </span>
            </div>

            {/*Display a link to the code repo if any*/}
            {repoLink ?
              <div>
                <a href="{repoLink}" className='bg-blue-700 text-white p-2 block rounded text-base'>
                    <BeakerIcon /> Open repository
                </a>
              </div>
              : null
            }
          </div>
          <div className="leading-relaxed text-lg text-gray-700 py-6">
            {isRichText(explanation) ? renderRichText(explanation) : explanation}
          </div>
        </div>
      </div>
    </>
  )
}

export default Tutorial

export const getServerSideProps = withLocale(async (locale, { params, query }) => {
  const slug = String(params.slug);
  const preview = isPreviewEnabled(query);
  const pageContentType = 'tutorial'; /* <- the id of the new page type */
  const page = await getPage({
    slug,
    preview,
    locale,
    pageContentType
  })
  return {
    props: { page },
  };
});

/* Cosmetic component */
const TimeIcon = () => (
  <svg className="w-6 h-6 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
  </svg>
)
/* Cosmetic component */
const BeakerIcon = () => (
  <svg className="w-6 h-6 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24" >
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path>
  </svg>
)
/* Cosmetic component */
const ColorRating = (props: { value: number }) => {

  const style = {
    backgroundColor: ['#2dce89', '#11cdef', '#fb6340'][props.value - 1],
    width: '0.6em',
    height: '0.6em',
  }

  return <>{Array(props.value).fill(<span style={style} className='inline-block ml-1' />)}</>
}

```

### Enjoy your content

![New tutorial web page](/developers/docs/_fern-img/130e9ea1d01177468c47e82f665ffc5f029bcba9f039fb4ab0f17bb1dbb35239.webp)