p1-media is a Puck plugin that adds managed image handling to a P1 site: an editor-facing media library and picker, built-in cropping, and on-the-fly image transforms (resize, format conversion, quality, crop, and more) served from a Pantheon-hosted CDN. You don't provision any infrastructure — you point the plugin at an already-running Pantheon media service and it handles storage and delivery for you.
There are two ways you might be using this plugin, and this guide covers both:
Dependency | Minimum version | Notes |
@puckeditor/core | 0.18 (as declared by this package) | Basic (text-field override) media picking works from 0.18. The rich p1-media field type needs Puck ≥0.20, which is when Puck started dispatching custom field types via overrides.fieldTypes. In practice, see the puck-css row below — most real P1 sites end up needing Puck ≥0.21 anyway. |
react | 18 | — |
@pantheon-systems/puck-css | whatever your P1 site's editor setup already uses | Not a formal dependency of p1-media itself — it's the separate package that provides useP1Editor/useP1Auth, needed only if you're registering the plugin yourself (the "site developer" path below). If you're only building a component on an existing P1 site, the site's own puck-css install already covers this — you don't add it yourself. As of puck-css 0.6.0, it in turn requires @puckeditor/core ≥0.21, react-dom ≥18, @tanstack/react-query ≥5, and @pantheon-systems/pds-toolkit-react ≥2.0.0-alpha.0 — which is why 0.21+ is the realistic Puck floor for a full site, even though p1-media alone only asks for 0.18. |
If the plugin is not yet installed on your site:
# pnpm
pnpm add @pantheon-systems/p1-media
# or npm
npm install @pantheon-systems/p1-media
The package ships two entry points: a default client bundle (React components, hooks, the crop UI and its styles — nothing extra to install for cropping) and a react-server condition export for use in server components / RSC, which excludes hooks and context and only exposes the URL-building helpers.
The plugin does not require you to set up storage, a database, or an image-processing service — that's all run by Pantheon. The only thing you provide is a URL to the already-deployed media service and a couple of identifiers.
If you're only building a component inside an existing P1 site, you don't need to register anything — the plugin is auto-wired for you by the site's editor setup. Skip to "The built-in Media Figure component" or "Extending with your own components."
If you're setting up the editor for a site, you'll also need @pantheon-systems/puck-css installed (it's what provides the P1 editor hook itself — p1-media just plugs into it). siteId and getAuthToken are read automatically from the ambient puck-css P1PuckProvider/P1AuthProvider context when the plugin is rendered inside a standard P1 editor, so the zero-arg form below is normally all you need — pass them explicitly only to override the default or when rendering outside that provider tree. Call createMediaPlugin(options) and pass the result into additionalPlugins when you set up useP1Editor:
import { Puck } from "@puckeditor/core";
import { createMediaPlugin } from "@pantheon-systems/p1-media";
import { useP1Editor } from "@pantheon-systems/puck-css";
// siteId and getAuthToken are read from the ambient P1PuckProvider /
// P1AuthProvider automatically — no need to pass or memoize them yourself.
const mediaPlugin = createMediaPlugin({});
function Editor({ documentPath, config }) {
const { loading, error, puckKey, puckProps } = useP1Editor({
documentPath,
puckConfig: config,
additionalPlugins: [mediaPlugin],
});
if (loading) return null;
if (error) return <div>Error: {error.message}</div>;
return <Puck key={puckKey} {...puckProps} />;Option | Type | Required | Notes |
workerUrl | string | No | Base URL of the Pantheon media service for your environment. Defaults to the production host (https://media.p1.pantheon.io) when omitted. |
siteId | string | No | Scopes the media library to your site. Defaults to the ambient puck-css P1PuckProvider context when omitted — pass explicitly only to override it, or when rendering outside that provider. |
workstreamId | string | No | Accepted for forward-compatibility, but not currently used to scope anything server-side — there's no need to pass it. Treat it as reserved for future use rather than a meaningful partition key. |
getAuthToken | () => Promise<string|null> | string | null | No | Returns the bearer token used to authenticate library/upload requests. Defaults to the ambient puck-css P1AuthProvider context's getToken when omitted — pass explicitly only to override it, or when rendering outside that provider. |
fieldNamePatterns | RegExp[] | No | Overrides which text-field names are treated as media fields in "basic mode" (see below). Defaults to a built-in list. |
metadataFields | { name, label, type }[] | No | Fallback field list used only if the metadata schema can't be fetched from the service at runtime. Not a way to add your own persisted metadata fields — see "Configuring the editorial interface." |
There is currently no feature-flag option on the plugin config — everything described in this guide is either always on, or controlled by the options above.
The quickest way to add an image to a page is createMediaFigureBlock, which returns a ready-to-use Puck component (a captioned <figure>) that you register directly:
import { createMediaFigureBlock } from "@pantheon-systems/p1-media";
const config = {
components: {
MediaFigureBlock: createMediaFigureBlock({
mediaBaseUrl: "https://media.p1.pantheon.io",
transform: { width: 1200, height: 630, format: "auto" },
label: "Media Figure",
fieldLabel: "Photo",
}),
// ...your other components
},
};Option | Default | Notes |
mediaBaseUrl | — | Required. Used to validate that a chosen image actually belongs to your media CDN before rendering it. |
transform | { width: 1200, height: 630, format: "auto" } | Controls the size/format the image is delivered at — see "Tools & transformations" below. |
label | "Media Figure" | Component name shown in the Puck component list. |
fieldLabel | "Photo" | Label on the picker field itself. |
schema | fetched from the service | Pins which metadata fields (e.g. caption, credit, byline) appear and in what order. |
className / captionClassName | — | Passed through to the rendered <figure>/<figcaption>. |
placeholder | "Choose a photo from the media library" | Shown when no image is selected yet. |
To the editor, this component exposes a single "Photo" field. Clicking it opens the full picker: browse/search the media library, crop (fit-in, smart crop, or a manual custom crop), and fill in caption-style metadata. Everything else is handled for you.
You don't have to use the built-in figure block — you can add a managed image to any component you author, in one of two ways.
Declare a field of type "p1-media" directly on your component. Since this type is registered by the plugin at runtime (Puck's own types don't know about it), cast it when you declare the field:
import type { Field } from "@puckeditor/core";
import { MediaFigure, type MediaFieldValue } from "@pantheon-systems/p1-media";
const teamMemberCard = {
fields: {
photo: { type: "p1-media", label: "Photo" } as unknown as Field,
},
defaultProps: { photo: null },
render: ({ photo }: { photo?: MediaFieldValue | null }) => (
<MediaFigure
image={photo}
mediaBaseUrl="https://media.p1.pantheon.io"
transform={{ width: 400, height: 400, format: "auto" }}
/>
),
};In rich mode the stored value is a small object (not just a URL string), so render it with one of the plugin's helpers rather than reading the URL directly: █ <MediaImage>, █ <MediaFigure>, or █ getMediaProps() if you need to build your own markup. All three accept the same value shape and apply the same origin-validation as the basic-mode helpers.
Name a text field using one of the recognized patterns and the plugin automatically upgrades it to a media picker: image/imageUrl, logo/logoUrl, media/mediaUrl, icon/iconUrl, thumbnail/thumbnailUrl, or any field name ending in ImageUrl / LogoUrl. (Navigation-style fields like buttonUrl, linkUrl, and ctaUrl — and alt-text fields — are deliberately excluded from these patterns.) The value stored is a plain URL string, so render it with the framework-agnostic buildImageUrl(url, params) helper — this works in server components too, since it's just string/URL manipulation with no React dependency:
import { buildImageUrl } from "@pantheon-systems/p1-media";
const fields = {
heroImageUrl: { type: "text", label: "Hero image" },
};
function Hero({ heroImageUrl }) {
const src = buildImageUrl(heroImageUrl, { width: 1600, format: "auto" });
return <img src={src} alt="" />;
}If you also want an editor-facing alt-text field alongside a basic-mode image field, a common pattern (not enforced by the plugin, but widely used to avoid naming collisions) is to add a sibling text field by stripping the trailing Url and appending Alt (e.g. heroImageUrl → heroImageAlt). Just make sure the alt field's own name doesn't accidentally match one of the media patterns above.
createMediaPlugin, createMediaFigureBlock, buildImageUrl, getMediaProps, MediaImage, MediaFigure, makeMediaValue, isMediaValue, DEFAULT_MEDIA_PATTERNS, plus the supporting TypeScript types (MediaPluginOptions, MediaFigureBlockOptions, ImageTransformParams, MediaValue, and others).
The typed transform object accepted by buildImageUrl, MediaImage, MediaFigure and getMediaProps only covers width, height, format, and quality. There is no typed way to pass crop-related parameters like fit or gravity through these helpers from your own code — those only ever reach the final image URL because the editor's crop UI already baked them in when the asset was selected. If you need to set those yourself programmatically, you'd have to build the query string by hand, outside the typed API.
Every delivered image goes through a single transform endpoint. If no transform parameters are present at all, the original uploaded file is served unmodified.
Parameter | Values | Notes |
width / height | 1–5000px | Clamped server-side at 5000px. |
format | auto | webp | avif | jpeg | jpg | png | gif | auto content-negotiates avif → webp → jpeg based on the requesting browser. |
quality | 1–100 | Default 85. |
fit | scale-down | contain | pad | squeeze | cover | crop | aspect-crop | How the image is fit into width/height. |
gravity | face | left | right | top | bottom | center | auto | entropy, or relative coordinates like 0.3x0.7 | Focal point used with cropping fit modes. |
blur | 0–250 | — |
brightness / contrast / saturation | 0–10 | — |
sharpen | 0–10 | — |
rotate | 0 | 90 | 180 | 270 | — |
trim.top / trim.left / trim.width / trim.height | source pixels | Manual crop rectangle. |
These parameters get applied in two layers:
Image transforms are performed by Cloudflare's Images service behind the scenes. (An earlier design explored a different image-processing backend; that approach was dropped in favor of Cloudflare Images, so nothing else needs to be provisioned or configured on your end.)
Read this section carefully — the crop and transform options an editor sees are not currently configurable per site or per component. They're fixed in the plugin's UI:
What you can configure, per site or component:
If your project needs finer editorial control over crop behavior or focal points than what's described above, that isn't available today — treat it as a feature request to raise with Pantheon rather than something to configure.
Limit | Value |
Max upload size | 10 MB per file (this is set per environment by Pantheon, not adjustable per upload) |
Allowed upload formats | PNG, JPEG, GIF, WebP, AVIF. SVG is explicitly not allowed — SVGs can embed scripts and are blocked to prevent that from executing when served. |
Max output dimension | 5000px, clamped independently per dimension (width and height are each capped at this value) |
Quality range | 1–100, default 85 |
Metadata field values | Only the four platform-defined field names are accepted (alt, caption, credit, byline); each value is capped at 2000 bytes. |
Media library page size | Server caps listing at 500 items per request; the library UI reveals results in batches of 60 as you scroll. |
Upload URL lifetime | A presigned upload URL is valid for 5 minutes. |
Requests are authenticated with a bearer token, which today is checked for view access to the site. A finer-grained write/edit permission check is still on the roadmap — don't assume today's auth model enforces edit-level authorization beyond what your own application already gates.
Removing an asset from the library hides it from listings, but previously-issued URLs for that asset keep serving from cache — there is no hard-delete/purge path yet. Don't rely on deletion for takedown of sensitive content; treat it as "unlisted," not "removed."
Today, an animated image whose total pixel area across all its frames is very large (as a rough guide, a roughly 1200×1200 GIF becomes an issue somewhere past ~70 frames) will fail during the transform step, and the endpoint currently returns a generic, unhelpful 500 error rather than a clear message. A fix that returns a clearer error is in progress. In the meantime:
This guide reflects the plugin as published today. Some limitations above (the editorial-interface constraints in particular) are active areas of development — check with Pantheon if you need capabilities beyond what's described here.