obsidian/wiki/payloadcms/custom-components-authoring.md
2026-05-15 15:13:56 +01:00

8.3 KiB

title aliases tags sources created updated
Custom Components — Authoring Guide
custom-components-building
payload-rsc
payload-client-components
payloadcms
react
rsc
performance
raw/custom-components__overview.md
2026-05-15 2026-05-15

Custom Components — Authoring Guide

Complements wiki/payloadcms/custom-components. Covers: Component Paths, Import Map internals, building RSC/Client components, i18n, locale, hooks, styles, performance, troubleshooting.

Component Paths

Components are registered by file path string, not import — keeps the Node.js config lightweight.

// Named export via hash
Button: '/src/components/Logout#MyComponent'

// Default export
Button: '/src/components/Logout'

// Object form
Button: {
  path: '/src/components/Logout',
  exportName: 'MyComponent',
  clientProps: { label: 'Sign out' },   // serializable → client
  serverProps: { someData: true },       // non-serializable allowed → server only
}

Set admin.importMap.baseDir to shorten all paths:

admin: {
  importMap: { baseDir: path.resolve(dirname, 'src') },
  components: { logout: { Button: '/components/Logout#MyComponent' } },
}

Import Map

Payload auto-generates src/app/(payload)/admin/importMap.js (or app/(payload)/admin/importMap.js).

When it regenerates

  • Application startup (dev or build)
  • HMR — whenever you save a component file during development
  • Manual: pnpm payload generate:importmap

When it does NOT regenerate

  • Normal runtime (only at startup)
  • After a production build completes

Never edit importMap.js manually. layout.tsx that imports it is safe to modify.

Override location

admin: {
  importMap: {
    baseDir: path.resolve(dirname, 'src'),
    importMapFile: path.resolve(dirname, 'app', '(payload)', 'custom-import-map.js'),
  },
}

Custom imports (plugin authors)

admin: {
  dependencies: {
    myTestComponent: {
      path: '/components/TestComponent.js#TestComponent',
      type: 'component',
      clientProps: { test: 'hello' },
    },
  },
}

Building Custom Components

Default props (auto-injected)

Prop Type Notes
payload Payload Full wiki/payloadcms/local-api — server components only
i18n I18nClient Translation object

Field components additionally receive field (server) or clientField (client).

Server Components (default)

import React from 'react'
import type { Payload } from 'payload'

async function MyServerComponent({ payload }: { payload: Payload }) {
  const page = await payload.findByID({ collection: 'pages', id: '123' })
  return <p>{page.title}</p>
}

Access Payload Config directly from the payload prop:

export default async function MyServerComponent({ payload: { config } }) {
  return <Link href={config.serverURL}>Go Home</Link>
}

Client Components

Add 'use client' — Payload strips non-serializable props automatically.

'use client'
import React, { useState } from 'react'

export function MyClientComponent() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
}

Payload Config on the client — use useConfig() hook (Client Config is a serializable subset):

'use client'
import { useConfig } from '@payloadcms/ui'

export function MyClientComponent() {
  const { config: { serverURL } } = useConfig()
  return <Link href={serverURL}>Go Home</Link>
}

Field config on the client — prop is named clientField (non-serializable fields removed):

'use client'
import type { TextFieldClientComponent } from 'payload'

export const MyField: TextFieldClientComponent = ({ clientField: { name } }) => (
  <p>Field: {name}</p>
)

i18n in Custom Components

Server Component

import { getTranslation } from '@payloadcms/translations'

export default async function MyServerComponent({ i18n }) {
  return <p>{getTranslation(myTranslation, i18n)}</p>
}

Client Component

'use client'
import { useTranslation } from '@payloadcms/ui'

export function MyClientComponent() {
  const { t, i18n } = useTranslation()
  return <span>{t('namespace:key', { variable: 'value' })}</span>
}

Locale in Custom Components

Server Component

export default async function MyServerComponent({ payload, locale }) {
  const page = await payload.findByID({ collection: 'pages', id: '123', locale })
  return <p>{page.title}</p>
}

Client Component

'use client'
import { useLocale } from '@payloadcms/ui'

function Greeting() {
  const locale = useLocale()
  const trans = { en: 'Hello', es: 'Hola' }
  return <span>{trans[locale.code]}</span>
}

Using Payload Hooks (Client)

Any @payloadcms/ui hook is available in client components. Examples:

'use client'
import { useDocumentInfo } from '@payloadcms/ui'

export function MyComponent() {
  const { slug } = useDocumentInfo()
  return <p>Entity slug: {slug}</p>
}

See wiki/payloadcms/react-hooks for the full hook reference.

Adding Styles

import './index.scss'

export function MyComponent() {
  return <div className="my-component">Custom Component</div>
}
// Use Payload CSS variables for theme compatibility
.my-component {
  background-color: var(--theme-elevation-500);
}

// Import Payload's SCSS library for mixins
@import '~@payloadcms/ui/scss';
.my-component {
  @include mid-break {
    background-color: var(--theme-elevation-900);
  }
}

Performance

Reduce server→client HTML size

  • Prefer server components; only send necessary props to the client
  • All props crossing the server/client boundary are serialized — be explicit
  • Use React Suspense for progressive loading

Prevent unnecessary re-renders

Use useFormFields (targeted) instead of useFields (all fields):

'use client'
import { useFormFields } from '@payloadcms/ui'

const MyComponent: TextFieldClientComponent = ({ path }) => {
  const value = useFormFields(([fields]) => fields[path])
  // ...
}

Import best practices

Context Import style Why
Admin Panel UI import { X } from '@payloadcms/ui' Avoids package mismatch
Frontend app import { X } from '@payloadcms/ui/elements/X' Tree-shaking, avoids bundle bloat

Importing the entire @payloadcms/ui barrel from frontend code bundles the whole admin library.

Follow React and Next.js best practices: memoization, streaming, caching, server/client boundary placement, layouts vs pages.

Troubleshooting

useConfig is undefined / value of useConfig cannot be destructured

Caused by dependency mismatches — multiple versions of @payloadcms/ui installed.

Fix: pin all Payload packages to the exact same version in package.json:

"@payloadcms/ui": "3.x.x",
"payload": "3.x.x",
"@payloadcms/next": "3.x.x"

See wiki/payloadcms/migration-troubleshooting for dedup instructions.

Key Takeaways

  • Components are referenced by path string, not imported — keeps config Node.js-safe
  • importMap.js auto-regenerates at startup and on HMR — never edit manually
  • All custom components are RSC by default; add 'use client' to opt into client mode
  • Payload auto-strips non-serializable props before passing them to client components
  • payload prop (local API) is available on server components for free DB access
  • Client Config accessed via useConfig() — it's a serializable subset of full config
  • Field config is named field in server components, clientField in client components
  • For i18n: getTranslation() in server, useTranslation() in client
  • For locale: locale prop in server, useLocale() in client
  • In admin panel imports use bare @payloadcms/ui; in frontend use deep paths for tree-shaking
  • useFormFields over useFields to prevent unnecessary re-renders