DocsIntegrating PuckServer Components

React Server Components

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

The server environment

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.

The client environment

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.

Implementation

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

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>
      ),
    },
  },
};

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} />;
}

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.

To achieve this, you can create a shared config type:

puck.config.ts
import type { Config } from "@measured/puck";
import type { HeadingBlockProps } from "./components/HeadingBlock";
 
type Props = {
  HeadingBlock: HeadingBlockProps;
};
 
export type UserConfig = Config<Props>;

Define a server component config that uses any server-only components, excluding any unnecessary fields:

puck.config.server.tsx
import type { UserConfig } from "./puck.config.ts";
import HeadingBlockServer from "./components/HeadingBlockServer"; // Import server component
 
export const config: UserConfig = {
  components: {
    HeadingBlock: {
      render: HeadingBlockServer,
    },
  },
};

And a separate client component config, for use within the <Puck> component on the client:

puck.config.client.tsx
import type { UserConfig } from "./puck.config.server.ts";
import HeadingBlockClient from "./components/HeadingBlockClient";
 
export const config: UserConfig = {
  components: {
    HeadingBlock: {
      fields: {
        title: { type: "text" },
      },
      defaultProps: {
        title: "Heading",
      },
      render: ({ title }) => <HeadingBlockClient title={title} />, // Note you must call the component, rather than passing it in directly
    },
  },
};

Now you can render with different configs depending on the context. 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={resolvedData} config={config} />;
}