Link
A thin wrapper around next/link that understands both plain URLs and the structured LinkField produced by your linkField utility.
What it does
It decides at runtime whether to render:
- <NextLink> (default for internal routes)
- <a> (for external / custom URLs)
- <Button asChild> (when a LinkField specifies a button-style appearance)
- <span> (fallback when no valid URL is available)
Use it anywhere you might otherwise drop a raw <NextLink>—it just happens to pair perfectly with linkField.
Install
pnpm dlx [email protected] add https://p.livog.com/r/link.json
Code
'use client'
import type { ButtonSize } from '@/components/ui/button/config'
import type { LinkField } from '@/payload-types'
import type { ComponentProps, MouseEvent } from 'react'
import { Button } from '@/components/ui/button'
import { isExternalLink, parseLink } from '@/payload/fields/link/parse-link'
import NextLink from 'next/link'
export type LinkFieldProps = LinkField
type BaseProps = Omit<ComponentProps<typeof NextLink>, 'href'> & {
size?: ButtonSize
}
type InternalLinkProps = BaseProps & {
href: string | URL
linkFieldProps?: undefined
}
type PayloadCMSLinkProps = BaseProps & {
linkFieldProps: LinkFieldProps
href?: string | URL
}
export type CMSLinkProps = InternalLinkProps | PayloadCMSLinkProps
const getLinkProps = (props: CMSLinkProps) => {
const { linkFieldProps, children: incomingChildren, size = 'default', className, onNavigate: incomingOnNavigate, ...rest } = props
const newTab = linkFieldProps?.newTab || false
const doc = typeof linkFieldProps?.doc?.value !== 'string' ? linkFieldProps?.doc?.value ?? null : null
const children = incomingChildren || linkFieldProps?.text || doc?.title
const prefetch = linkFieldProps?.linkType === 'custom' && linkFieldProps.url?.startsWith('/') ? false : true
const { url: href, isExternal } = linkFieldProps
? parseLink(linkFieldProps)
: {
url: (typeof props.href === 'string' ? props.href : String(props.href) || '#') as string,
isExternal: isExternalLink(props.href as string)
}
const rel = isExternal ? 'noopener noreferrer' : undefined
const target = newTab ? '_blank' : undefined
const onNavigate = async (e: MouseEvent<HTMLAnchorElement>) => {
if (typeof incomingOnNavigate === 'function') incomingOnNavigate(e)
if (linkFieldProps?.linkType !== 'custom') return;
/**
* Custom links can go anywhere (e.g. /redirect-this).
* So we need to skip default Next.js fetch behaviour
* as cross-domain redirects will cause console.error.
*/
e.preventDefault()
window.location.href = href
return
}
return {
isExternal,
prefetch,
children,
onNavigate,
className,
rel,
target,
size,
...rest,
href
}
}
export function Link(props: CMSLinkProps) {
const { linkFieldProps } = props
const appearance = linkFieldProps?.appearance || 'link'
const { isExternal, children, className, prefetch, size, onNavigate, ...rest } = getLinkProps(props)
if (!rest.href) return <span className={className}>{children}</span>
if (isExternal || prefetch === false || rest.href === '#') {
return (
<a {...rest} className={className}>
{children}
</a>
)
}
if (appearance === 'link' || !linkFieldProps) {
return (
<NextLink {...rest} onClick={onNavigate} className={className}>
{children}
</NextLink>
)
}
// Exclude potential `as` alias from Next.js `Link` props to prevent clash with Button’s polymorphic `as` prop
const { as: _nextAlias, ...buttonRest } = rest
return (
<Button as={NextLink} {...buttonRest} onClick={onNavigate} variant={appearance} size={size} className={className}>
{children}
</Button>
)
}
export default Link
Examples
Solid color background
import Link from '@/components/Link'
/* 1 — Internal route */
<Link href="/about">About us</Link>
/* 2 — External URL */
<Link href="https://github.com">GitHub</Link>
/* 3 — Link stored in Payload (renders <a> or <NextLink>) */
<Link linkFieldProps={hero.link} />
/* 4 — Button appearance saved in the CMS */
<Link linkFieldProps={cta.link} size="lg" />
Custom background element
Good to know
- Prefetching is skipped for custom URLs that start with / to avoid Next.js cross-domain fetch warnings.
- If linkFieldProps.newTab is true, the component adds
target="_blank"
and the appropriate rel. - When neither href nor a valid linkFieldProps URL is present, the component renders a
<span>
so the layout doesn’t break.