If youâve worked on content-heavy projects or low code tools beforeâespecially alongside a headless CMSâyouâve probably faced this challenge: at some point, content teams want the ability to build and update pages without needing to go through a developer. Thatâs where website builders usually come in, but most of the ones out there are tied to a specific CMS or force you (and your built pages) into their ecosystem, making editor customization limited and migration hard, or expensive.
Thatâs exactly the problem Puck was built to solve. Puck is an open source, React-first visual editor that works in any project, with any CMS or back-end. This means no vendor lock-in or forced decisions on your stack, itâs just a simple package you install, import, and render in your app like any other React component.
And, because itâs just a component, you can use it alongside whatever youâre already using in your projectâincluding Tailwind.
In this tutorial, Iâll walk you through how to:
Letâs dive in!
If this is the first time youâre hearing about Puck, there are a few reasons why you might consider using it. Puck is:
The core idea is simple: Puck gives you a visual editor to design and publish pages by dragging and dropping your React components. When a page is published, Puck outputs it as a JSON object. You can then pass that to the Render
component to render that page wherever you want.
If you want to get a feel for how this setup works, you can try out the live demo yourself.
Hereâs what that looks like in code:
// Editor.jsx
import { Puck } from "@measured/puck";
import "@measured/puck/puck.css";
const config = {
// Define the components users can drag and drop in the editor
components: {
HeadingBlock: {
// Define the editable fields for the component
fields: {
title: {
type: "text",
},
},
// Render the component with the field values
render: ({ title }) => {
return <h1>{title}</h1>;
},
},
},
};
// Provide an initial page to load into the editor (empty for new pages)
const initialData = {};
// Save the page when the user clicks on Publish
const save = (data) => {
console.log(data); // Replace this with a call to your backend
};
// Render the Puck editor
export function Editor() {
return <Puck config={config} data={initialData} onPublish={save} />;
}
And to render a saved page:
// Page.jsx
import { Render } from "@measured/puck";
export function Page() {
const data = {}; // Load this from your database
return <Render config={config} data={data} />;
}
Now that you know what Puck is, why itâs worth your while, and how it works, letâs actually start building the page builder!
First up, letâs install Puck. If youâre adding it to an existing project, you can install it directly via npm:
npm install @measured/puck
If youâre starting from scratch, you can also use one of the Puck recipes to spin up a new project:
npx create-puck-app my-app
This will scaffold a new project called my-app
, feel free to swap that name for whatever fits your project.
After running that command, youâll be prompted to choose a recipe. Type next
for the Next.js recipe, or remix
if you prefer Remix.
For this guide, Iâm using the Next.js recipe, but if youâre adding Puck to an existing project, or using the Remix recipe, the next steps should still apply. You might just need to adjust the file paths to match your project folder structure.
With Puck installed, letâs get the app running. If you used the recipe, you can start your development server using the following commands:
# Navigate into the new project folder
cd my-app
# Start the development server
npm run dev
Thatâll spin up the app at http://localhost:3000. When you first open it, youâll see a message prompting you to navigate to the editor to edit that specific page. To do that, just head over to: http://localhost:3000/edit
You should now see the Puck editor, with a single HeadingBlock
component already in the canvas.
If you click on the HeadingBlock
in the canvas, modify its title field, and hit Publish in the top-right corner, youâll update your homepage at http://localhost:3000 instantly.
You can also add /edit
at the end of any page URL to create or 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.
â ď¸ Heads up: By default, the Next.js recipe saves page data to your file system and leaves the editor open to anyone. Thatâs fine for local development, but if youâre deploying this to production, youâll want to check the recipe docs for tips on authorizing your API routes and saving pages to a real database
Before we start adding custom components, letâs take a quick look at the configuration object that comes with the recipe in ./puck.config.tsx
to understand how Puck gets integrated with TypeScript.
At the top of the file, youâll see a Props
type definition. This tells Puckâs Config
type what props each of your draggable components expects. In this case, we only have a single componentâHeadingBlock
âand it just expects a title
string.
// puck.config.tsx
import type { Config } from "@measured/puck";
type Props = {
HeadingBlock: { title: string };
};
Next is the actual Puck config object. The only difference with the previous JavaScript setup here is that weâre typing it with Config
to let TypeScript know which components it expects and which fields each one of those components should define. It also uses defaultProps, to define a default value for the title field.
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;
And thatâs the basic setup with TypeScript!
Keep in mind that, as your editor grows, youâll definitely want to pull these nested component configuration objects to their own files, when you do that, you can type them by using the ComponentConfig
type Puck exposes with the props you expect for the component.
Now that weâve got Puck setup and ready, itâs time to make things more interesting. Letâs add a couple of new components:
Card
: to present information about a particular topicGrid
: to display a list of components in a grid, using CSS gridFor this tutorial, the Grid
itself wonât need any propsâitâs just going to be a layout containerâbut each Card
will allow users to input the title and description of the topic itâs introducing, as well as the amount of padding it should have around its content.
Hereâs how to set this up:
Step 1: Add the new Grid and Card components to the Props
type
// ./puck.config.tsx
type Props = {
HeadingBlock: { title: string };
Grid: {}; // No props needed for the grid itself
Card: {
title: string;
description: string;
padding: number;
};
};
//... existing setup
Step 2: Add the Grid component to the config
object
To do this, weâll use the DropZone
component. This component allows you to nest components within other components, which is useful for creating multi-column layouts using CSS Grid or Flexbox.
// ./puck.config.tsx
import { Config, DropZone } from "@measured/puck";
export const config: Config<Props> = {
components: {
//... existing config
Grid: {
render: () => {
// Render a Grid DropZone where users are able to drag and drop components
return (
<DropZone
zone="my-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: "16px",
}}
/>
);
},
},
},
};
Step 3: Add the Card
component to the config
object
// ./puck.config.tsx
import { Config, DropZone } from "@measured/puck";
export const config: Config<Props> = {
components: {
//... existing config
Card: {
// Add the fields for the title, description and padding
fields: {
title: { type: "text" },
description: { type: "textarea" },
padding: { type: "number", min: 4, max: 64 },
},
// Add default values for each field
defaultProps: {
title: "Topic Title",
description: "Topic description...",
padding: 16,
},
render: ({ title, description, padding }) => {
// Render the card using the values from its fields
return (
<article style={{ padding }}>
<h2>{title}</h2>
<p>{description}</p>
</article>
);
},
},
},
};
Thatâs it! If you head back to http://localhost:3000/edit, youâll now see Grid
and Card
in your component list. Go ahead and drop a Grid into the page and add a few Cards inside it, adjusting their titles, descriptions, and padding to see how it all comes together.
At this point, weâve got a working page builderâcomponents can be dragged in, props can be edited, and pages can be published. But visually, things are still pretty basic. Letâs fix that by adding Tailwind to the mix.
Adding Tailwind to a project with Puck is the same as adding it to any React app. Since this tutorial uses Next.js, Iâll walk you through that setup, but if youâre using a different meta framework, you can follow the official Tailwind instructions for your particular stack.
Step 1: Install Tailwind and its dependencies
npm install tailwindcss @tailwindcss/postcss postcss
Step 2: Create a ./postcss.config.mjs
 file in the root of your project and add the Tailwind plugin to it
// ./postcss.config.mjs
const tailwindConfig = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default tailwindConfig;
Step 3: Import Tailwind in ./app/styles.css
/** ./app/styles.css **/
@import "tailwindcss";
And with that, Tailwind is now part of your project. Next, weâll wire it up to your Puck components so you can start using it inside the editor.
Once Tailwind is installed, you can start swapping out the existing styles in your Puck components for Tailwindâs utility classes. Letâs do a quick cleanup by migrating the static inline styles we were using in HeadingBlock
and Grid
.
To do that, open ./puck.config.tsx
and make the following changes:
// ./puck.config.tsx
export const config: Config<Props> = {
components: {
HeadingBlock: {
//... existing configuration
render: ({ title }) => (
// Replace the inline styles to make the text bigger and bold
<div className="text-4xl font-bold p-8">
<h1>{title}</h1>
</div>
),
},
Grid: {
render: () => {
return (
// Replace the inline styles with Tailwind's equivalent
<DropZone zone="my-grid" className="grid grid-cols-3 gap-4 p-4" />
);
},
},
//... existing card configuration
},
};
Once thatâs done, you can navigate back to the editor and check that everything is styled as expected, if this doesnât seem to be working you might need to reset your development server and invalidate your browser cache.
With Puck, you often want to change the appearance of a component based on the value of a field controlled by the user, just like we did earlier with the card padding.
Doing this with Tailwind works great in most cases, but thereâs one important thing to keep in mind: Tailwind doesnât generate styles at runtime. Instead, it scans your code at build time, finds any utility classes youâre using (like text-center
or bg-red-500
), and then includes only those in your final CSS bundle. Tailwindâs docs explain this in more detail.
That means if you try to do something like this using a field value:
className={`grid-cols-${columns}`}
âŚit wonât work unless the class for every possible value of columns
is already present somewhere in your source code. Thatâs because Tailwind wonât recognize the dynamic class when it builds your CSS.
Luckily, there are a few ways to work around this depending on how dynamic you want your styling to be. Letâs go through the options so you can pick the one that fits your setup best.
If all you want to do is give users a set of predefined styles they can pick from, for example through a select or radio field, this is the simplest way to integrate Tailwind. To achieve this, hard-code the fixed list of classes your components can use, the same way you would in any other project. This would make it so all the classes exist at build time, making it the safest and most performant option.
Any other fully dynamic values the user controls directlyâlike the padding around the Cardâs contentâcan still be passed using inline styling.
To demonstrate this, letâs add a variant
field to the Card component so users can choose between an âoutlinedâ or a âfloatingâ card. The padding around the card content will stay as inline styles, so users can still enter any values they want.
Step 1: Add the variant prop in the Card type definition
// ./puck.config.tsx
type Props = {
//... existing components
Card: {
//... existing props
variant?: string;
};
};
Step 2: Add a select field for the Card variants with their corresponding Tailwind classes as values
// ./puck.config.tsx
export const config: Config<Props> = {
components: {
//... existing components
Card: {
//... existing configuration
fields: {
//... existing fields
variant: {
type: "select",
options: [
{ value: "shadow-md", label: "Floating" },
{ value: "border rounded-md", label: "Outlined" },
],
},
},
},
},
};
Step 3: Add the variant prop to the Card class names
// ./puck.config.tsx
export const config: Config<Props> = {
components: {
//... existing components
Card: {
//... existing configuration
render: ({ title, description, padding, variant }) => {
return (
<article
style={{ padding }}
// Pass in the variant prop here
className={variant}
>
<h2 className="text-xl font-bold">{title}</h2>
<p>{description}</p>
</article>
);
},
},
},
};
If you now navigate to the editor and drag and drop a Card, youâll be able to switch between an outlined Card or a floating one by using the new variant field.
You can repeat this setup with any other component you want. The key is always making sure the full class names exist somewhere in your code so Tailwind can pick them up. Anything thatâs truly dynamicâlike widths, heights, or grid spansâcan stay inline.
The only downside to this approach is that you wonât be able to use selectors, design tokens, or target states with the fully dynamic inline values.
The previous setup works great for static class names, but it doesnât solve the problem when you need to generate classes dynamically, which might be a requirement for doing things like selecting a specific color for a background like this:
const className = `bg-${colorOption}`;
In Tailwind v3, you could achieve this by safelisting the class names you wanted in the final CSS bundle so that they were always included. You could even use regular expressions to safelist whole sets of possible class variants in one line, making it easier to cover dynamic class names.
In Tailwind v4.0, however, doing that is no longer possible. To do this now, you need to safelist all the exact class names youâll be generating dynamically in a .txt
file.
Hereâs how youâd do that for adding background colors to the Card component in our setup:
Step 1: Create a ./safelist.txt
file that contains all the Tailwind classes you need to generate dynamically
bg-inherit
bg-red-300
bg-yellow-100
...any other class names you need
Step 2: Import this file into your main stylesheet using @source
so Tailwind knows to include these classes in the final build
/* ./app/styles.css */
@source "../safelist.txt";
Step 3: Add a background
field to the Card component, so users can choose a background color in the editor
// ./puck.config.tsx
type Props = {
//... existing components
Card: {
//... existing props
background?: string;
};
};
export const config: Config<Props> = {
components: {
//... existing components
Card: {
//... existing configuration
fields: {
//... existing fields
background: {
type: "select",
options: [
{ value: "inherit", label: "Inherit" },
{ value: "yellow-100", label: "Yellow" },
{ value: "red-300", label: "Red" },
],
},
},
//... default props configuration
render: ({ title, description, padding, variant, background }) => {
return (
<article
style={{ padding }}
// Add the background class dynamically
className={`${variant} bg-${background}`}
>
<h2 className="text-xl font-bold">{title}</h2>
<p>{description}</p>
</article>
);
},
},
},
};
If you now refresh your editor, drag and drop a card, and select a red or yellow background, you should see your card changing colors.
The key of this approach is making sure all your dynamic class names always resolve to those defined inside the safelist.txt
file. Here I showed you how to do it manually, but you could write scripts to generate some repetitive ones, like a range of paddings and margins, automatically.
The downside of this approach is that every new dynamic class must be manually added, slowing down updates and increasing the risk of missing a class, which could lead to styling bugs.
If you want to give users full control over things like padding, margin, colors, and fontsâpurely using dynamic Tailwind classesâthe previous option will quickly become unmanageable. Youâd need to add hundreds of class names to your safelist just to support a handful of design fields.
The only real workaround is to make every Tailwind class available at runtime, and the way to do that is by loading Tailwind via its CDN. With the CDN, Tailwind doesnât need to scan your files at build timeâit ships all the styles up front and generates them on the fly in the browser. That means you can do things like "p-${padding}"
freely, without worrying about safelists or build time scans.
The main disadvantage of this approach is that youâre importing all of Tailwind, all the time. Which adds around 220 KB to your pages, just for styling, most of which youâll probably never use. It also makes your styling runtime-dependent, which means worse performance, especially on slower connections or less powerful devices.
Thatâs why the Tailwind team doesnât recommend this setup, and why Iâm not going to cover it in depth here.
If you still want to go for it, you can follow the official instructions here. Just be sure to remove Tailwind as an npm dependency, since you wonât be using it at build time anymore.
Use this approach carefully, it gives you complete freedom, but youâll pay for it in performance.
This tutorial walked through how to combine Tailwind with Puck to build a flexible, component-based page builder. We covered different ways to handle component stylingâfrom simple predefined classes and inline styling to fully dynamic valuesâso you can pick the right balance between flexibility, maintainability, and performance.
Hopefully, this guide not only helped you set up Tailwind in Puck but also sparked some ideas for building your own visual editorsâwhether thatâs for website builders, image editors, PDF generators, or something else entirely. The best thing about Puck is that itâs not just a page builderâitâs a component-based editor, so what your components represent and what you build with it is completely up to you.
If youâre exploring this setup, working on something similar, or tackling something completely different, Iâd love to chat and answer any questions you might have:
đž Join the conversation on Discord
đŚ Follow along for updates and sneak peeks on X and Bluesky
â Found this useful? Support Puck by giving us a star on GitHubâit really helps!
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!