DocsIntegrating PuckServer Components

React Server Components

Puck provides support for React Server Components (RSC), but the interactive-nature of Puck requires special consideration.

Environments

Server

Puck supports the server environment for the following APIs:

These APIs can be used in an RSC environment, but in order to do so the Puck config that they reference must be RSC-friendly.

This can be done by either avoiding client-only code (React useState, Puck <DropZone>, etc), or split out client components with the "use client"; directive.

Client

All other Puck APIs, including the core <Puck> component, cannot run in an RSC environment due to their high-degree of interactivity.

As these APIs render on the client, the Puck config provided must be safe for client-use, avoiding any server-specific logic.

Implementations

Since the Puck config can be referenced on the client or the server, we need to consider how to satisfy both environments.

There are three approaches to this:

  1. Avoid using any client-specific functionality (like React useState or Puck’s <DropZone>) in your components
  2. Mark your components up with the "use client"; directive if you need client-specific functionality
  3. Create separate configs for client and server rendering

1. Avoid client-specific code

Avoiding client-specific code is the easiest way to support RSC across both environments, but may not be realistic for all users. This means:

  1. Avoiding React hooks like useState, useContext etc
  2. Replacing Puck’s <DropZone> with the renderDropZone prop

Replacing DropZone with renderDropZone

The puck.renderDropZone prop is an RSC-friendly way to implement <DropZone> functionality:

const config = {
  components: {
    Columns: {
      render: ({ puck: { renderDropZone } }) => (
        <div>{renderDropZone({ zone: "my-content" })}</div>
      ),
    },
  },
};

2. Marking up components with "use client";

Many modern component libraries will require some degree of client-side behaviour. For these cases, you’ll need to mark them up with the "use client"; directive.

To achieve this, you must import each of those component from a separate file:

puck.config.tsx
import type { Config } from "@measured/puck";
import type { HeadingBlockProps } from "./components/HeadingBlock";
import HeadingBlock from "./components/HeadingBlock";
 
type Props = {
  HeadingBlock: HeadingBlockProps;
};
 
export const config: Config<Props> = {
  components: {
    HeadingBlock: {
      fields: {
        title: { type: "text" },
      },
      defaultProps: {
        title: "Heading",
      },
      // You must call the component, rather than passing it in directly. This will change in the future.
      render: ({ title }) => <HeadingBlock title={title} />,
    },
  },
};

And add the "use client"; directive to the top of each component file:

components/HeadingBlock.tsx
"use client";
 
import { useState } from "react";
 
export type HeadingBlockProps = {
  title: string;
};
 
export default ({ title }: { title: string }) => {
  useState(); // useState fails on the server
 
  return (
    <div style={{ padding: 64 }}>
      <h1>{title}</h1>
    </div>
  );
};

This config can now be rendered inside an RSC component, such as a Next.js app router page:

app/page.tsx
import { config } from "../puck.config.tsx";
 
export default async function Page() {
  const data = await getData(); // Some server function
 
  const resolvedData = await resolveAllData(data, config); // Optional call to resolveAllData, if this needs to run server-side
 
  return <Render data={resolvedData} config={config} />;
}

3. Creating separate configs

Alternatively, consider entirely separate configs for the <Puck> and <Render> components. This approach can enable you to have different rendering behavior for a component for when it renders on the client or the server.

Create a shared config type:

puck-types.ts
import type { Config } from "@measured/puck";
 
type Props = {
  HeadingBlock: {
    title: string;
  };
};
 
export type UserConfig = Config<Props>;

Define a client component config for use within the <Puck> component:

puck.config.client.tsx
import type { UserConfig } from "./puck-types.ts";
 
export const config: UserConfig = {
  components: {
    HeadingBlock: {
      fields: {
        title: { type: "text" },
      },
      defaultProps: {
        title: "Heading",
      },
      render: ({ title }) => {
        useState(); // useState fails on the server
 
        return (
          <div style={{ padding: 64 }}>
            <h1>{title}</h1>
          </div>
        );
      },
    },
  },
};

Define a server config using the shared types for use within the <Render> component, excluding fields as they are unnecessary in this environment:

puck.config.server.tsx
import type { UserConfig } from "./puck-types.ts";
 
export const config: UserConfig = {
  components: {
    HeadingBlock: {
      render: ({ title }) => {
        return (
          <div style={{ padding: 64 }}>
            <h1>{title}</h1>
          </div>
        );
      },
    },
  },
};

Render the appropriate config depending on the environment. Here’s a Next.js app router example of a server render:

app/page.tsx
import { config } from "../puck.config.server.tsx";
 
export default async function Page() {
  const data = await getData(); // Some server function
 
  return <Render data={data} config={config} />;
}