🚧 Under development — suggestions for new items are always welcome! ✨

Link Field

linkField is a reusable Payload CMS group field that lets editors choose either an internal document or an external URL and (optionally) pick a button appearance—while always persisting the same, type-safe data shape.

What it does

  • Presents a radio switch (Internal / Custom URL) so editors never mix up link types.
  • Auto-shows the right inputs: relationship selector for internal docs, plain text for external URLs.
  • Optional “Open in new tab” checkbox lives in the same row, so authors decide behaviour at a glance.
  • Optional “Appearance” select mirrors your shared buttonConfig variants—no design drift.
  • Captures an optional label that falls back to the target document’s title when left blank.
  • Every sub-field can be hidden or overridden from the calling collection/block without breaking the shared LinkField interface.
  • Ships with parseLink and isExternalLink helpers that turn the raw CMS value into { url, label, isExternal }, guarding against unresolved references or malformed URLs.

Install

pnpm dlx shadcn@latest add https://p.livog.com/r/link-field.json

Code

import type { Field, CollectionSlug, GroupField, TextField, CheckboxField, SelectField } from 'payload'
import { buttonConfig, type ButtonVariant } from '@/components/ui/button/config'
import type { LinkFields } from '@payloadcms/richtext-lexical'

type KnownKeys<T> = keyof { [K in keyof T as string extends K ? never : K]: T[K] }

export type LinkFieldKey = KnownKeys<LinkFields> | 'text'
export type LinkAppearances = ButtonVariant

export type LinkFieldOverrides = {
  linkType?: Partial<Omit<Field, 'name'>> | false
  text?: Partial<Omit<TextField, 'name'>> | false
  newTab?: Partial<Omit<CheckboxField, 'name'>> | false
  url?: Partial<Omit<TextField, 'name'>> | false
  doc?: Partial<Omit<Field, 'name'>> | false
  appearance?: Partial<Omit<SelectField, 'name'>> | false
}

export type LinkFieldOptions =
  Partial<Omit<GroupField, 'fields' | 'name'>> & {
    name?: string
    appearances?: LinkAppearances[] | false
    fieldOverrides?: LinkFieldOverrides
    relationTo: CollectionSlug[] | ReadonlyArray<CollectionSlug>
  }

const getAppearanceOptions = (appearances?: LinkAppearances[] | false) => {
  const options = Object.keys(buttonConfig.variants || {}).map((key) => ({
    label: key
      .split(' ')
      .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
      .join(' '),
    value: key
  }))

  if (Array.isArray(appearances)) {
    return options.filter((option) => appearances.includes(option.value as LinkAppearances))
  }

  return options
}

export const linkField = (options: LinkFieldOptions): GroupField => {
  const {
    appearances,
    fieldOverrides = {},
    relationTo,
    ...overrides
  } = options
  if(overrides.name == null) {
    overrides.name = 'link'
  }

  const {
    linkType: linkTypeOverride,
    text: textOverride,
    newTab: newTabOverride,
    url: urlOverride,
    doc: docOverride,
    appearance: appearanceOverride
  } = fieldOverrides

  const fields = [
    {
      type: 'radio',
      defaultValue: 'internal',
      options: [
        { label: 'Internal', value: 'internal' },
        { label: 'Custom URL', value: 'custom' }
      ],
      ...(linkTypeOverride !== false && linkTypeOverride ? linkTypeOverride : {}),
      name: 'linkType' satisfies LinkFieldKey,
      admin: {
        layout: 'horizontal',
        width: '50%',
        ...(linkTypeOverride !== false && linkTypeOverride ? linkTypeOverride?.admin || {} : {}),
        ...(linkTypeOverride === false ? { hidden: true } : {})
      }
    },
    {
      type: 'checkbox',
      label: 'Open in new tab',
      ...(newTabOverride !== false ? newTabOverride : {}),
      name: 'newTab' satisfies LinkFieldKey,
      admin: {
        width: '50%',
        ...(newTabOverride !== false && newTabOverride ? newTabOverride?.admin || {} : {}),
        ...(newTabOverride === false ? { hidden: true } : {})
      }
    },
    {
      type: 'text',
      label: 'Custom URL',
      required: true,
      ...(urlOverride !== false && urlOverride ? urlOverride : {}),
      name: 'url' satisfies LinkFieldKey,
      admin: {
        condition: (_: unknown, siblingData: { linkType?: string }) => siblingData.linkType === 'custom',
        width: textOverride !== false ? '50%' : undefined,
        ...(urlOverride !== false && urlOverride ? urlOverride?.admin || {} : {}),
        ...(urlOverride === false ? { hidden: true } : {})
      }
    },
    {
      type: 'relationship',
      label: 'Document to link to',
      relationTo: [...relationTo],
      required: true,
      ...(docOverride !== false && docOverride ? docOverride : {}),
      name: 'doc' satisfies LinkFieldKey,
      admin: {
        condition: (_: unknown, siblingData: { linkType?: string }) => siblingData.linkType === 'internal',
        width: textOverride !== false ? '50%' : undefined,
        ...(docOverride !== false && docOverride ? docOverride?.admin || {} : {}),
        ...(docOverride === false ? { hidden: true } : {})
      }
    },
    {
      type: 'text',
      label: 'Label',
      required: false,
      validate: (value: unknown, { siblingData }: { siblingData: { linkType?: string } }) => {
        const isVisible = textOverride !== false
        if (siblingData?.linkType === 'custom' && !value && isVisible) {
          return 'This field is required when link type is "custom"'
        }
        return true
      },
      ...(textOverride !== false && textOverride ? textOverride : {}),
      name: 'text' satisfies LinkFieldKey,
      admin: {
        width: '50%',
        ...(textOverride !== false && textOverride ? textOverride?.admin || {} : {}),
        ...(textOverride === false ? { hidden: true } : {})
      }
    },
    {
      type: 'select',
      defaultValue: 'default',
      options: getAppearanceOptions(appearances),
      ...(appearanceOverride !== false && appearanceOverride ? appearanceOverride : {}),
      name: 'appearance',
      admin: {
        ...(appearances === false && { hidden: true }),
        ...(appearanceOverride !== false && appearanceOverride ? appearanceOverride?.admin || {} : {}),
        ...(appearanceOverride === false ? { hidden: true } : {})
      }
    }
  ]

  return {
    type: 'group',
    interfaceName: 'LinkField',
    label: undefined,
    admin: { hideGutter: true, ...(overrides?.admin || {}) },
    fields,
    ...overrides
  } as GroupField
} 

Notice this: import { buttonConfig, ButtonVariant } from '@/components/ui/button/config';

The reason for this is to avoid importing directly from a client component into server components—this is something that’s personally caused me headaches before.

// button/config.ts
import { cva, type VariantProps } from 'class-variance-authority'
import type { ComponentPropsWithRef, ElementType } from 'react'

export const buttonConfig = {
  variants: {
    default: 'bg-primary text-primary-foreground hover:bg-primary/90',
    destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
    outline: 'border-primary/20 bg-background text-foreground hover:bg-accent hover:text-accent-foreground border',
    secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
    ghost: 'hover:bg-accent hover:text-accent-foreground',
    link: 'text-primary inline underline-offset-4 hover:underline'
  },
  sizes: {
    default: 'h-10 px-4 py-2',
    sm: 'h-9 rounded-md px-3',
    lg: 'h-11 rounded-md px-8',
    icon: 'h-10 w-10'
  }
} as const

export const buttonVariants = cva(
  `ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0`,
  {
    variants: {
      variant: buttonConfig.variants,
      size: buttonConfig.sizes
    },
    defaultVariants: {
      variant: 'default',
      size: 'default'
    }
  }
)

export type ButtonVariant = keyof typeof buttonConfig.variants
export type ButtonSize = keyof typeof buttonConfig.sizes

export type ButtonProps<T extends ElementType = 'button'> = {
  as?: T
} & ComponentPropsWithRef<T> & VariantProps<typeof buttonVariants>


Usage

Add to a Block

import { linkField } from '@/fields/link'

export const CallToAction: Block = {
  slug: 'callToAction',
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true
    },
    linkField({
      relationTo: ['pages', 'posts'],
      appearances: ['primary', 'ghost']
    })
  ]
}

Use inside a rich-text LinkFeature

import { LinkFeature } from '@payloadcms/richtext-lexical'
import { linkField } from '@/payload/fields/link'

lexicalEditor({
  features: () => {
    return [
      LinkFeature({
        enabledCollections: ['pages', 'posts'],
        fields: () =>
          linkField({
            appearances: false,
            relationTo: ['pages', 'posts']
          }).fields
      })
    ]
  }
})

Render the stored value

<CMSLink linkFieldProps={block.link} />

or

import { parseLink } from '@/utils/parse-link'

const { url, label, isExternal } = parseLink(block.link)

return isExternal
  ? <a href={url} target="_blank" rel="noopener noreferrer">{label}</a>
  : <Link href={url}>{label}</Link>