Page builders are great. They fill a gap once supported by the WYSIWYG CMS, enabling no/low-code creation of pages in a headless CMS world.
However, we’ve never found a page builder that works for our clients. They’re normally:
- Proprietary, making them impossible to use for our larger clients, or
- Difficult to integrate, often requiring us to interface with a Java-based CMS or introduce convoluted integration layers
After years of searching for a self-hosted, React-first solution for our clients, we eventually decided to build our own.
Introducing Puck
Puck is an open source page builder for React.
Puck enables you to create your own page builder and embed it directly inside your React application. Puck is:
- Open-source under MIT
- Self-hosted
- Compatible with most existing headless CMS solutions
- Extensible using plugins
Tell Puck what components you support, and Puck will provide a slick drag-and-drop interface for you or your content teams to create pages on-the-fly.
Puck also uses the permissive MIT license, so there’s nothing stopping you using it for commercial products.
Try the demo: http://demo.puckeditor.com
The launch
Since Puck’s launch less than a month ago, we’ve been overwhelmed with the response.
We hit the Hacker News front page, going from 8 to 1,800 stars on GitHub in 24 hours, continuing to grow to over 3,000 throughout September. Industry heavyweights like Simon Willison (co-creator of Django) and Guillermo Rauch (creator of Next.js and CEO of Vercel) both gave their praise.
A quick look at the Puck API
Puck is React-first, and its core functionality is exposed via the <Puck>
React component.
To render the Puck editor:
// Editor.jsx
import { Puck } from "@measured/puck";
import "@measured/puck/dist/index.css";
// Puck configuration, describing components to drag-and-drop
const config = {
components: {
HeadingBlock: {
// Field types to render for each prop of your component
fields: {
children: {
type: "text",
},
},
// Your render function
render: ({ children }) => {
return <h1>{children}</h1>;
},
},
},
};
// Describe the initial data
const initialData = {};
// Save the data to your database
const save = (data) => {};
// Render Puck editor
export function Editor() {
return <Puck config={config} data={initialData} onPublish={save} />;
}
To render a page:
// Page.jsx
import { Render } from "@measured/puck";
export function Page() {
return <Render config={config} data={data} />;
}
Setup
Installing Puck
If you have an existing application, you can install Puck via npm:
npm install @measured/puck
Or if you’re interested in spinning up a new application, you can run the generator
npm create puck-app
For this guide, we’re going to assume you’ve used the generator and chosen the next recipe, but most of it will apply to existing applications too.
Running the application
If you’re using the next recipe, navigate into your directory and run the application:
cd my-app
npm run dev
The server will start on port 3000. If you navigate to the homepage /
, you’ll be prompted to redirect to the editor.
If you now navigate to edit
you’ll see Puck rendering with a single component, HeadingBlock.
This is the Puck editor for our homepage, which contains a single HeadingBlock. If we click the component, we can update the text being rendered using the field on the right hand side.
If you hit Publish in the top-right corner, our homepage at /
will be updated.
You can add /edit
to any route in your application to spin up a Puck editor, whether or not the page already exists. This is thanks to the Next.js catch-all route under app/[…puckPath]
.
Understanding the Puck config
Let’s take a look at the puck.config.tsx
file generated by the generator:
// puck.config.tsx
import type { Config } from "@measured/puck";
type Props = {
HeadingBlock: { title: string };
};
export const config: Config<Props> = {
components: {
HeadingBlock: {
fields: {
title: { type: "text" },
},
defaultProps: {
title: "Heading",
},
render: ({ title }) => (
<div style={{ padding: 64 }}>
<h1>{title}</h1>
</div>
),
},
},
};
export default config;
This TypeScript file is pretty similar to the API example we shared above, with some type declarations. It exports a config
object that matches Puck’s Config
type.
Let’s break it down —
type Props = {
HeadingBlock: { title: string };
};
This type definition allows us to tell Puck’s Config
type what our underlying components are expecting. In this scenario, we’re defining one component, HeadingBlock, that accepts props title
, which is a string
.
We then pass this to our config
, which tells Puck that we’re expecting to render a component called HeadingBlock that accepts the title: string
prop.
export const config: Config<Props> = {
components: {
HeadingBlock: {
fields: {
title: { type: "text" },
},
defaultProps: {
title: "Heading",
},
render: ({ title }) => (
<div style={{ padding: 64 }}>
<h1>{title}</h1>
</div>
),
},
},
};
We use the extended Config
type definition to create a config
object. Our config
object accepts the components
key (see API reference), which must now define a HeadingBlock configuration.
Our HeadingBlock definition receives three keys: fields
, defaultProps
and render
.
fields: {
title: { type: "text" },
},
fields
describes how to render the field for each prop passed to our component. In this case, we’re using a text field. See the field API reference for a full list of available field types.
defaultProps: {
title: "Heading",
},
Because we’ve specified this as a required field, TypeScript will complain if we don’t configure default values for the props. Here, we’re setting the title
to Heading.
We have to use the defaultProps key to set defaults so that Puck knows how to render the data in the fields.
render: ({ title }) => (
<div style={{ padding: 64 }}>
<h1>{title}</h1>
</div>
),
Finally, we define a render
function that tells Puck how to render the component.
Adding a Paragraph component
Let’s add a new component called Paragraph that accepts a text: string
prop and renders a Paragraph element.
type Props = {
HeadingBlock: { title: string };
Paragraph: { text: string };
};
export const config: Config<Props> = {
components: {
//...existingComponents,
Paragraph: {
fields: {
text: { type: "text" },
},
defaultProps: {
text: "Paragraph",
},
render: ({ text }) => (
<div style={{ padding: 64 }}>
<p>{text}</p>
</div>
),
},
},
};
We can now see this in the left hand side of the editor:
Let’s drag it onto our page and update it:
Great! Let’s add a text alignment property called textAlign
, which accepts the values left
, center
or right
.
type Props = {
HeadingBlock: { title: string };
Paragraph: {
text: string;
textAlign: "left" | "center" | "right";
};
};
To render this, a radio
field type would be better than a text
input because it doesn’t allow for the input of arbitrary text:
export const config: Config<Props> = {
components: {
// ...existingComponents
Paragraph: {
fields: {
text: { type: "text" },
textAlign: {
type: "radio",
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
],
},
},
defaultProps: {
text: "Paragraph",
textAlign: "left",
},
render: ({ text, textAlign }) => (
<div style={{ padding: 64 }}>
<p style={{ textAlign }}>{text}</p>
</div>
),
},
},
};
We’ve also set a default value of left
, and mapped it straight to the textAlign
CSS on our <p>
element.
Deleting our existing Paragraph and drag a new one in shows us the new field on the right hand side. Toggling between the options works as expected!
Adding Tailwind
Let’s add Tailwind CSS to improve the styles:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Add the Tailwind classes to app/styles.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
Configure tailwind.config.js
to process the Puck config:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["puck.config.tsx"],
theme: {
extend: {},
},
plugins: [],
};
Finally, let’s add some classes to our HeadingBlock and remove the padding on the wrapper:
render: ({ title }) => (
<div>
<h1 className="text-4xl lg:text-5xl font-extrabold tracking-tight">
{title}
</h1>
</div>
),
And our Paragraph:
render: ({ text, textAlign }) => (
<div>
<p style={{ textAlign }} className="leading-7 mt-6">
{text}
</p>
</div>
),
Let’s see how that looks:
Great! The styles are rendering, but things are looking a little cramped.
Rendering a custom root
To fix this, we’ll want to add a custom root
. Puck allows us to change the default behaviour by overriding the root
component of our page:
export const config: Config<Props> = {
... existingConfig
root: {
render: ({ children }) => {
return <div className="p-16">{children}</div>;
},
},
}
This time we’re using Tailwind instead of inline styles.
Much better!
Taking it further
There are a bunch of Puck features that we won't cover in this tutorial that you could use to take your editor to the next level:
-
External fields - allow your users to select content from an existing headless CMS using
external
fields -
Plugins - the custom plugin API enables powerful new functionality. For example, the
heading-analyzer
plugin to analyse the document structure of your page and warn when you break WCAG2.1 guidelines - Custom fields - create your own input types for your users, like calendar widgets
You'll find documentation on all of these via our GitHub.
Closing words
We are committed to continuing the development of Puck, with features like multi-column layouts, more form inputs and new plugins in the works. After all, we need it for our clients too.
In the last month, our community has started building bespoke website builders, email builders and other innovative applications we never could have imagined.
If you like Puck, please throw us a star via our GitHub repository or come chat on our Discord server. For more information about our React services, please visit the Measured website.
Thank you for joining us on this journey. We're excited to see what you create with Puck!
Top comments (2)
looks promising 👍 👍
Thanks @takeshikriang! Always keen to hear use cases if you get around to trying it