DEV Community

Cover image for Building a React Page Builder: An Introduction to Puck
Chris Villa for Measured

Posted on • Edited on • Originally published at measured.co

Building a React Page Builder: An Introduction to Puck

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:

  1. Proprietary, making them impossible to use for our larger clients, or
  2. 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.

Gif showing Puck interface

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://puck-editor-demo.vercel.app

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} />;
}
Enter fullscreen mode Exit fullscreen mode

To render a page:

// Page.jsx
import { Render } from "@measured/puck";

export function Page() {
  return <Render config={config} data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Setup

Installing Puck

If you have an existing application, you can install Puck via npm:

npm install @measured/puck
Enter fullscreen mode Exit fullscreen mode

Or if you’re interested in spinning up a new application, you can run the generator

npm create puck-app
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

The default Puck interface

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.

The default Puck interface with HeadingBlock updated to read Hello World

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;
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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>
      ),
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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" },
},
Enter fullscreen mode Exit fullscreen mode

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",
},
Enter fullscreen mode Exit fullscreen mode

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>
),
Enter fullscreen mode Exit fullscreen mode

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>
      ),
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

We can now see this in the left hand side of the editor:

Puck interface showing Paragraph component in the left-hand side

Let’s drag it onto our page and update it:

Gif showing the user dragging the Paragraph into the page

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";
  };
};
Enter fullscreen mode Exit fullscreen mode

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>
      ),
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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!

Puck interface with Paragraph center-aligned

Adding Tailwind

Let’s add Tailwind CSS to improve the styles:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Add the Tailwind classes to app/styles.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Configure tailwind.config.js to process the Puck config:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["puck.config.tsx"],
  theme: {
    extend: {},
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

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>
    ),
Enter fullscreen mode Exit fullscreen mode

And our Paragraph:

    render: ({ text, textAlign }) => (
      <div>
        <p style={{ textAlign }} className="leading-7 mt-6">
          {text}
        </p>
      </div>
    ),
Enter fullscreen mode Exit fullscreen mode

Let’s see how that looks:

Puck interface showing preview window with Tailwind for the HeadingBlock and Paragraph

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>;
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

This time we’re using Tailwind instead of inline styles.

Puck interface showing preview with additional padding wrapping the root of the preview

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:

  • Adaptors - allow your users to select content from an existing headless CMS using adaptors
  • 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)

Collapse
 
takeshikriang profile image
takeshikriang

looks promising 👍 👍

Collapse
 
chrisvxd profile image
Chris Villa

Thanks @takeshikriang! Always keen to hear use cases if you get around to trying it