Using Contentful with React: A Practical Integration Guide
How to use Contentful with React: set up the SDK, fetch entries, render rich text, and resolve linked references correctly.
This walkthrough assumes React is already the frontend and Contentful is already the CMS; for the broader question of using a headless CMS with React, start there instead. Using Contentful with React means three steps. Install the contentful SDK. Fetch entries with getEntries(). Render the rich text field with @contentful/rich-text-react-renderer. That third step is the one most tutorials gloss over, and it's the one that costs the most time the first time you hit it.
Setting up the Contentful client
Install the SDK and set two environment variables: a space ID and a content delivery access token. Both come from Contentful's API keys settings page.
npm install contentful
import { createClient } from "contentful";
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});
Keep the access token server-side. It's a read-only delivery token, not a secret in the usual sense. Still, there's no reason to ship it to the browser bundle. A React Server Component can fetch on the server instead.
Fetching entries in a React Server Component
const res = await client.getEntries({ content_type: "blogPost" });
const posts = res.items;
Each item in posts carries two things. fields holds your content. sys holds metadata: id, content type, revision. In the App Router, this call goes straight into a Server Component. No client-side fetch, no loading spinner, and Next.js caches the result according to whatever fetch or revalidate config you set on the route.
Rendering the rich text field
This is the step that trips up most first integrations. Contentful stores rich text as a JSON document tree, not HTML or Markdown. A paragraph is a node. A bold word inside it is a mark on a text node. Rendering that tree by hand means walking every node type. Paragraph, heading, list, embedded asset, embedded entry, hyperlink.
Contentful's own answer is @contentful/rich-text-react-renderer:
npm install @contentful/rich-text-react-renderer
import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
function BlogPostBody({ document }: { document: Document }) {
return <div>{documentToReactComponents(document)}</div>;
}
Out of the box, this renders paragraphs and basic marks, nothing more. Embedded entries, embedded assets, and custom mark styling all need an options object. It's a renderNode map, keyed by node type. A blog post with an embedded image or a custom callout box needs that map written by hand, one entry per node type the content actually uses.
Handling linked entries and assets
Contentful references don't resolve automatically the way a SQL join does. A blog post referencing an author entry comes back with a link object. Not the author's actual name and bio. You have to ask for that with an include parameter:
const res = await client.getEntries({
content_type: "blogPost",
include: 2,
});
The include depth controls how many reference levels get resolved in one call. Set it too low and a nested reference comes back as an unresolved link. Set it needlessly high and the response payload grows for data the page doesn't use.
Typing Contentful content in TypeScript
Contentful's REST response is loosely typed by default: fields comes back as any unless you tell TypeScript otherwise. The contentful SDK supports a generic type parameter on getEntries<T>(), where T describes the shape of a single content type's fields.
interface BlogPostFields {
title: string;
slug: string;
body: Document;
}
const res = await client.getEntries<BlogPostFields>({
content_type: "blogPost",
});
This catches a field rename at compile time. The alternative is a runtime undefined three components deep. Contentful doesn't generate these types from the content model the way some platforms do. Someone on the team writes and maintains them by hand. Or someone wires up a codegen script against the Content Management API. Either way, budget real time for this step. Skipping it is how a renamed field in the Contentful web app quietly breaks a production page with no compiler warning.
Draft preview and unpublished content
A content editor previewing a draft needs a different Contentful client. It points at the Preview API, not the Delivery API. The preview host is preview.contentful.com, and it requires a separate preview access token, not the delivery token used above.
const previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: "preview.contentful.com",
});
Next.js's Draft Mode is the usual way to route a preview request to this client instead of the regular one. Forgetting this split is a common first-integration mistake. A preview link quietly serves published content instead of the draft, because it's still using the delivery client.
What breaks in production
Two failure modes show up more than any others. First, a content editor adds a new embedded entry type to a rich text field. The renderer has no case for it. The page silently drops that block instead of erroring. Add a fallback case to renderNode that at least logs the unknown node type, or content will vanish without a trace in the browser console.
Second, the free Community tier caps out at 100,000 API calls a month and 50GB of CDN bandwidth. A site with even moderate traffic, and no caching layer in front of the delivery API, burns through that limit faster than the pricing page implies. Cache at the CDN edge, not just in Next.js's fetch cache, once traffic passes a few thousand daily visitors.
An alternative: MDX instead of a rich text tree
The rich text renderer step above is real work. Every Contentful integration repeats it. Draftbase takes a different approach. richText fields store MDX as a plain string. It compiles with @mdx-js/mdx and renders through @draftbase/renderer as a React Server Component. No renderNode map required. A custom component in the content, like a pricing table or a callout, is just an MDX component registered once. It's not a new case in a node-type switch statement. Draftbase vs Contentful covers the tradeoff in more depth. That includes where Contentful's rich text format genuinely wins, for content that needs to render outside a React app.
Conclusion
Contentful and React work well together once the rich text rendering step is handled right. Install the SDK. Fetch with getEntries(). Resolve references with include. Write a renderNode map for every embedded content type the editors actually use. If that rendering layer is the part that feels like unnecessary ceremony, Draftbase's free Hobby plan skips it entirely with MDX-native content storage.
How to
- 1Install the Contentful SDK and set environment variables
Run npm install contentful and set CONTENTFUL_SPACE_ID and CONTENTFUL_ACCESS_TOKEN from Contentful's API keys page.
- 2Fetch entries in a React Server Component
Call client.getEntries({ content_type: '...' }) on the server. No client-side fetch or loading spinner needed.
- 3Render the rich text field
Install @contentful/rich-text-react-renderer and call documentToReactComponents on the rich text document. Add a renderNode map for embedded entries and assets.
- 4Resolve linked entries with include
Pass an include depth to getEntries so referenced entries resolve inline instead of coming back as unresolved link objects.
- 5Type the content with a generic
Pass a fields interface to getEntries<T>() so a field rename fails at compile time instead of at runtime.
- 6Set up a separate preview client
Create a second client pointed at preview.contentful.com with a preview access token, and route it through Next.js Draft Mode for unpublished content.
Ship content that's built to be found
Draftbase generates schema, structured data, and a fast MDX editor for every post.
Frequently asked questions
How do you fetch Contentful content in a Next.js App Router project?
Call client.getEntries() directly inside a React Server Component. No client-side fetch or loading state is needed; Next.js caches the result based on the route's fetch or revalidate configuration.
Why doesn't Contentful rich text render as HTML automatically?
Contentful stores rich text as a JSON document tree, not HTML or Markdown. Rendering it requires @contentful/rich-text-react-renderer and a renderNode map for any embedded entries, assets, or custom marks.
Why do Contentful references come back incomplete?
Contentful references return as unresolved link objects unless you pass an include parameter to getEntries(), which controls how many levels of linked entries get resolved in the same request.
Does the contentful SDK support TypeScript types?
Yes, via a generic type parameter on getEntries<T>(), but Contentful doesn't generate these types from your content model automatically. Someone on the team has to write and maintain them, or build a codegen script.
What's the free tier limit for using Contentful with React in production?
The Community tier caps out at 100,000 API calls a month and 50GB of CDN bandwidth. A site with moderate traffic and no edge caching can burn through that faster than expected.