React Server Components
Puck provides partial support for React Server Components (opens in a new tab) (RSC) for the following APIs:
- The
<Render>
component, for rendering pages produced by Puck - The
resolveAllData
lib, for running all data resolvers
Overview
The <Render>
component
The <Render>
component is a server component and can be rendered in an RSC environment. However, in order to do so the Puck config must be RSC-friendly.
This can be done by either avoiding client-only code (React useState
, Puck <DropZone>
, etc), or split those files out with the "use client";
directive.
The <Puck>
component
The core <Puck>
component is a client component (marked up with the "use client";
directive), which is necessary to the high-degree of interactivity. This cannot be rendered in an RSC environment.
Because the components are rendered dynamically, all components defined in the config provided to <Puck>
should be safe for rendering on the client.
Implementation
Since there are two ways to render Puck components, we need to consider how to satisfy both environments with a single set of components.
There are three options:
- Avoid using any client-specific functionality (like React
useState
or Puck's<DropZone>
) in your components - Mark your components up with the
"use client";
directive if you need client-specific functionality - 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:
- Avoiding React hooks like
useState
,useContext
etc - Replacing Puck's
<DropZone>
with therenderDropZone
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:
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:
"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:
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:
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:
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:
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:
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} />;
}