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

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.