Puck is the open-source visual editor for React that you can embed in any application to create the next generation of page builders and no-code products. Give us a star on GitHub! âïž
Headless CMS platforms like Contentful have changed how we think about content management. Instead of locking you into a specific frontend, like traditional CMSs do, they let you store structured content and deliver it anywhereâwebsites, mobile apps, even IoT devices.
But that flexibility comes with a trade-off: you donât really know how your content will be presented. Unlike traditional CMSs that include WYSIWYG editors, headless CMSs require developers to build the frontend experience from scratch. This means content teams often need to rely on engineers just to tweak layouts, adjust styling, or add new sections to pages.
Whatâs the solution to this? A dedicated page builder. A good page builder gives content teams the freedom to structure pages visually, while still keeping the content store separate, ensuring that every component in the system remains dynamic and modular. There are plenty of proprietary ones out there, but if you want full control over your tech stack, a CMS-agnostic page builder is the way to go.
Thatâs where Puck comes in. Puck is a flexible, open-source page builder that you can embed directly into your React app. Itâs completely unopinionated about how you store and retrieve content, making it compatible with virtually any backend (not just CMSs), including Contentful. This makes it a great fit if you want a dynamic editor that doesnât tie you to a single platform.
In this tutorial, Iâll show you how to integrate Puck with Contentful to bridge the gap between reusable structured content and a user-friendly page-building experience. By the end, youâll learn how to:
By the time weâre done, youâll have a fully functional visual editor + headless CMS setup where users can write blog posts and build and edit pages dynamicallyâwithout writing a single line of code:
Before we get started, Iâll assume you have a basic understanding of Contentful. If youâre new here, no worriesâyouâre welcome to follow along! However, Iâd recommend checking out the Contentful developer portal first to get familiar with the basics.
To begin, we need to define our content structure in Contentful. Since this tutorial focuses on integrating a page builder rather than setting up Contentful from scratch, Iâll keep this section brief. If youâre new to Contentful, I recommend checking out their official getting started guide to get familiar with creating spaces, content types, and obtaining your API keys.
For this simple blog app, you only need to define a single content type named Blog, which represents the collection of blog posts youâll publish and manage in your app. Here are the fields you should add to it:
Title
: A required text field for the blog post titleBody
: A required rich text field for the blog contentOnce you create that content type youâll be able to navigate to the âContentâ section of your Contentful space to create new Blog posts based on this model:
After setting up Contentful, we can now set up the web application to build and render pages with our content. To do this, weâll use Puck both for page building and rendering.
If youâre completely new to Puck and want a broader introduction before diving in, check out our Getting Started guide.
If youâre adding Puck to an existing project, you can install it with npm:
npm install @measured/puck
Or, if youâd rather start fresh, you can use one of the Puck recipes to quickly set up a new project:
npx create-puck-app my-blog
This will create a new Puck project named my-blog (feel free to rename it). After running the command, youâll be prompted to choose a recipeâtype next
for the Next.js recipe or remix
for the Remix recipe.
For this guide, Iâll assume youâve used the generator and chosen the Next.js recipe, however if youâre integrating Puck into an existing project, the next steps should still apply. Puck works with any React application, so you might just need to tweak file names and folder structures based on your preferred setup.
Once Puck is installed, you can start your development server. To do this run the following commands:
# Navigate to your new project
cd my-blog
# Run the application
npm run dev
This will start a local development server on http://localhost:3000. If you navigate to it, youâll be shown a sample message that will prompt you to navigate to the editor. To access the Puck editor, head over to: http://localhost:3000/edit
You should now see the Puck editor, rendering a single component: HeadingBlock
.
Click on the HeadingBlock
in the canvas, modify itâs content to whatever you want and hit Publish in the top-right corner to update your homepage at http://localhost:3000 instantly.
You can also add /edit
at the end of any page URL to edit that page visuallyâwhether it already exists or not. This works thanks to the Next.js catch-all route (app/[...puckPath]
) that comes pre-configured with the Puck Next.js recipe.
â ïž Important: By default, Puckâs Next.js recipe saves your page data in the file system and makes all pages editable by anyone. If you plan to deploy this app to production, be sure to check out the recipe documentation for best practices on securing API routes and storing page data in a real database.
Great, now we have our client app and our CMS ready! Next, weâll connect Contentful to Puck so we can create pages using the content we created in step one.
For this step, weâll use the @measured/puck-field-contentful
package, which provides a Puck field that allows you to select Contentful entries directly inside the editor. This package is a convenience wrapper around Puckâs external
field type, handling the Contentful API integration. You can see the source code here.
Weâll also install Contentfulâs rich-text-react-renderer
package to properly render Contentfulâs rich text field data with HTML.
1. Install Dependencies
Start by installing the dependencies.
npm i @measured/puck-field-contentful @contentful/rich-text-react-renderer
2. Define an Article
Component
Next, add an Article
component to allow users to select and display a blog post entry in the pages theyâre building. Open /puck.config.tsx
, and modify the Puck props
type definition and config
to define the new component.
//... existing setup
type Props = {
//... existing props
Article: {
// TODO: add the Article props
};
};
export const config: Config<Props> = {
components: {
//... existing setup
Article: {
//... TODO: add the component config
},
},
};
3. Add the Field to Select Contentful Entries
Inside the Article
component definition, use the createFieldContentful
function from @measured/puck-field-contentful
to create a field that lets users select a blog post from Contentful. Define this field under a data
prop in the Article
componentâs fields.
import createFieldContentful, { Entry } from "@measured/puck-field-contentful";
// Define the expected props for the Article component
type ArticleProps = {
// Expect contentful entries as article data
data?: Entry<{ title: string; body: any }>;
};
type Props = {
//... existing props
// Replace the empty object definition with the actual type
Article: ArticleProps;
};
export const config: Config<Props> = {
components: {
//... existing setup
Article: {
fields: {
data: createFieldContentful<ArticleProps["data"]>("blog", {
space: "YOUR-SPACE-ID",
accessToken: "YOUR-ACCESS-TOKEN",
}),
},
},
},
};
đč Important: Replace YOUR-SPACE-ID
and YOUR-ACCESS-TOKEN
with your actual Contentful space ID and API token. Storing these in environment variables (.env
) is recommended.
4. Render the Selected Blog Post
Once a blog post is selected via the data
field, the data
prop will be provided to the componentâs render
function for rendering. Modify the Article
âs render
function to show the contents of the blog post in the page.
import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
//... existing setup
export const config: Config<Props> = {
components: {
//... existing setup
Article: {
//... existing field setup
render: ({ data }) => {
// If the user selected a blog entry,
// render it
return data ? (
<div
style={{
fontFamily: "Arial, sans-serif",
padding: 32,
}}
>
<h1>{data.fields.title}</h1>
<hr />
{documentToReactComponents(data.fields.body)}
</div>
) : (
<h1>No selected content</h1>
);
},
},
},
};
Now that everything is set up, navigate to http://localhost:3000/edit and try the following:
Article
component onto the editortitle
and body
of the selected blog post should now appear in the editor previewLetâs extend the Article
component to support different types of content. To demonstrate this, weâll create a separate About content type, and generalize the Article
component so that it works with multiple types.
1. Create the About content type
In Contentful, create an About content type with the same fields as the Blog type:
Title
: a required text field for the section titleBody
: a required rich text field for the section contentđĄ Pro Tip: While you can handle different content type structures using the resolveData
API, keeping consistent field names across related content typesâlike in this exampleâmakes components in Puck instantly reusable. Without any extra implementation, new content types will work automatically with your existing components.
2. Define your own Contentful Client
Previously, the @measured/puck-field-contentful
package created the Contentful client internally for us. However, since we now need to retrieve the available content types in our space dynamically, weâll need to create the client manually and use it for fetching them.
To do this, modify your createFieldContentful
setup to explicitly create the Contentful client:
import createFieldContentful, {
Entry,
createClient, // Import the client builder
} from "@measured/puck-field-contentful";
// Explicitly create the Contentful client
const contentfulClient = createClient({
space: "YOUR-SPACE-ID",
accessToken: "YOUR-ACCESS-TOKEN-HERE",
});
export const config: Config<Props> = {
components: {
//... existing setup
Article: {
fields: {
data: createFieldContentful<ArticleProps["data"]>("blog", {
// Pass the previously created client
client: contentfulClient,
}),
},
},
},
};
3. Move the Contentful Field Definition Inside resolveFields
Next, youâll want to make the fields dynamic, so that the content type passed to the Contentful field definition can be changed based on the value of another field.
To do this, migrate your field definition to the resolveFields
API:
// Import the Fields type
import type { Config, Fields } from "@measured/puck";
export const config: Config<Props> = {
components: {
//... existing setup
Article: {
resolveFields: async (data, params) => {
let newFields: Fields<ArticleProps> = {
data: createFieldContentful<ArticleProps["data"]>("blog", {
client: contentfulClient,
}),
};
return newFields;
},
//... existing setup
},
},
};
4. Let Users Select a Content Type
Now, add a contentType
prop to the Article component config, and configure it with a select
field thatâs populated with a list of all the available content types from Contentful.
When the user changes the value of the contentType
select field, pass it to the createFieldContentful
call in resolveFields
, so the data
field now points to a different content type.
//... existing setup
type ArticleProps = {
data?: Entry<{ title: string; body: any }>;
// Add a prop for selecting a content type
contentType: string;
};
//... existing setup
export const config: Config<Props> = {
components: {
Article: {
resolveFields: async (data, params) => {
// Fetch all available content types from Contentful
const types = await contentfulClient.getContentTypes();
let newFields: Fields<ArticleProps> = {
// Create a dropdown field for selecting a content type
contentType: {
type: "select",
options: [
{ label: "Select a content type", value: "" },
...types.items.map((type) => ({
label: type.name,
value: type.sys.id,
})),
],
},
};
// If a content type is selected, add an entry picker for it
if (data.props.contentType) {
newFields.data = createFieldContentful<ArticleProps["data"]>(
data.props.contentType,
{
client: contentfulClient,
}
);
}
return newFields;
},
//... existing render function
},
},
};
Finally, navigate to the editor in the browser, drag and drop an article, choose a content type, and then select the actual entry of that content type you want to render:
Now that everything is set up, you can start building and publishing blog posts directly from your app.
For example, if you want to add a new post about, say, the Top 5 Drag and Drop Tools for React, all you need to do is:
http://localhost:3000/top-5-drag-and-drop-libraries-for-react/edit
Article
component onto the pageThatâs it! Your new page is now live and accessible at http://localhost:3000/top-5-drag-and-drop-libraries-for-react
đ
If you want to expand this setup, here are a few ideas to take it to the next level:
ContentList
component that dynamically lists entries from any content typeresolveData
function to allow users to author content directly inside the page builderresolveData
function in the Article component to fetch the latest content from Contentful whenever the component renders. Check out our guide on how to implement this hereIn this tutorial, I walked you through a simple way to integrate Puck with Contentful, but this is just one of many possible approaches. Since Puck is unopinionated, you have full control over how you structure your page management. Maybe youâd prefer a centralized admin dashboard instead of an /edit
URL, or maybe you need to separate your editing and rendering environments entirely across two different applications and domains. Itâs completely up to you.
Either way, I hope this guide helped you get started with Puck + Contentfulâand maybe even sparked some ideas for your next project.
Iâd love to hear what youâre building! Whether you have questions, feedback, or just want to bounce around some ideas, hereâs how you can connect with me and the Puck team:
And of course, if you have any questions or comments, drop them below. Iâm always happy to chat!
If youâre interested in learning more about Puck, check out the demo or read the docs. If you like what you see, please give us a star on GitHub to help others find Puck too!