Updated: September 30, 2026

P1 Media Image Management for Developers

P1 Media
Media Library
Image Handling

Overview

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:

  • Building a component on a P1 site — the plugin is already wired in for you. You just need to know the field-naming conventions or how to declare a rich media field (see "Extending with your own components").
  • Standing up a new P1 site / enabling the media library — you register the plugin explicitly with a small config object (see "Installation & configuration").

1. Installation & configuration

Requirements

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.

Install


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.

No infrastructure to configure

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.

Registering the plugin

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

Plugin options

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.

2. The built-in Media Figure component

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.

3. Using the plugin in your own components

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.

Basic mode — no plugin-specific code

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.

Rich mode — full picker on your own field

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.

Full exported surface

createMediaPlugin, createMediaFigureBlock, buildImageUrl, getMediaProps, MediaImage, MediaFigure, makeMediaValue, isMediaValue, DEFAULT_MEDIA_PATTERNS, plus the supporting TypeScript types (MediaPluginOptions, MediaFigureBlockOptions, ImageTransformParams, MediaValue, and others).

Caveat: transform helpers only cover the basics

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.

4. Tools & transformations available

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:

  • Crop intent, set once in the editor. When an editor picks "Smart crop," "Fit in," or draws a custom crop, the plugin bakes the corresponding parameters (e.g. fit=cover&gravity=auto, fit=scale-down, or trim.*) into the stored image value. This only happens through the editor UI.
  • Delivery sizing, set by your component. buildImageUrl() and the transform option on createMediaFigureBlock merge width/height/format/quality onto whatever URL is stored, without disturbing the crop parameters already there.

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.)

5. Configuring the editorial interface

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:

  • The crop mode choices — Fit in / Smart crop / Custom… on rich (p1-media) fields, and Fit in / Smart crop on basic-mode fields — are hardcoded. There's no option to add, remove, or relabel them.
  • The aspect-ratio presets offered in the custom-crop dialog — Free, 1:1, 4:3, 3:2, 16:9 — are a fixed list. There's no prop to change this set today.
  • There is no focal-point/gravity picker in the editor, even though the delivery endpoint itself supports named and coordinate-based gravity (see the table above). The only gravity value the editor UI ever produces is the fixed gravity=auto used by "Smart crop." In other words: the backend's gravity capability is broader than anything an editor can currently choose through the UI.

What you can configure, per site or component:

  • Which field names activate the picker in basic mode, via the plugin's fieldNamePatterns option.
  • The delivered output size/format for a given component, via the transform option on createMediaFigureBlock (or the params you pass to buildImageUrl) — this controls what's rendered on the page, not what the editor can choose in the sidebar.
  • Which metadata fields show up as caption-style inputs, and their order/labels, via the schema option. The available field names themselves — alt, caption, credit, byline — are fixed platform-wide today; you can choose which of these four to show, but you can't add your own custom metadata field name (e.g. "photographer") yet.

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.

6. Limits, caveats & known issues

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.

Authentication scope

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.

Deletion is soft-delete only

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."

Known issue: large animated GIFs/WebPs

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:

  • Keep animated sources short and/or low-resolution.
  • If you need to serve a large animated file as-is, request it with no transform query parameters — the original bytes are always served untouched when no transform is requested, which sidesteps this failure mode entirely (at the cost of not being able to resize/reformat it).

Other things not yet available

  • No rate limiting on the image delivery endpoint yet (it's open by design for now).
  • No org-level or cross-site asset sharing — assets are scoped to a single site.
  • No "find everywhere this image is used" / bulk-replace tooling yet.
  • No customer-definable metadata fields beyond the four fixed names described above.

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.

P1 Media
Media Library
Image Handling