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:
- The
<Render>
component, for rendering pages produced by Puck - The
resolveAllData
lib, for running all data resolvers
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:
- 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} />;
}