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>