Advanced Link Component for Next.js (Read Article)
Creating a reliable link component for any type of url
Creating a reliable link component for any type of url
| import React from 'react' | |
| import NextLink from 'next/link' | |
| import { getRoute } from '@lib/routes' | |
| import { ConditionalWrapper } from '@lib/helpers' | |
| const Link = ({ href, external = false, children, ...rest }) => { | |
| const hrefPath = typeof href === 'object' ? getRoute(href) : href | |
| const hrefAttribute = external ? { href: hrefPath } : {} | |
| return ( | |
| <ConditionalWrapper | |
| condition={!external} | |
| wrapper={(children) => ( | |
| <NextLink href={hrefPath} scroll={false}> | |
| {children} | |
| </NextLink> | |
| )} | |
| > | |
| <a | |
| {...hrefAttribute} | |
| target={external && !hrefPath.match('^mailto:|^tel:') ? '_blank' : null} | |
| rel={external ? 'noopener noreferrer' : null} | |
| {...rest} | |
| > | |
| {children} | |
| </a> | |
| </ConditionalWrapper> | |
| ) | |
| } | |
| export default Link |
| export const getRoute = ({ type, slug, hash, query }) => { | |
| // append static base paths based on the page type | |
| const basePath = { | |
| recipe: 'recipe', | |
| project: 'work', | |
| }[type] | |
| // combine our base path with the slug | |
| const routePath = [basePath, slug].filter(Boolean).join('/') | |
| // construct the hash fragment if one exists | |
| const hashFragment = hash ? `#${hash}` : '' | |
| // construct the query string if one exists | |
| const queryString = query | |
| ? `?${Object.keys(query) | |
| .map((key) => `${key}=${query[key]}`) | |
| .join('&')}` | |
| : '' | |
| // return the full route path | |
| return `/${routePath}${queryString}${hashFragment}` | |
| } |
| // conditionally wrap a component with another | |
| export const ConditionalWrapper = ({ condition, wrapper, children }) => { | |
| return condition ? wrapper(children) : children | |
| } |
| import React from 'react' | |
| const Example = () => { | |
| return ( | |
| <> | |
| <Link href="/relative-string">Internal Page (string)</Link> | |
| <Link href={{ | |
| type: 'page', | |
| slug: 'spaghetti', | |
| hash: 'parmesan', | |
| query: { | |
| noodles: 'linguine', | |
| sauce: 'bolognese' | |
| } | |
| }}>Internal Page (object)</Link> | |
| <Link href="mailto:[email protected]">External URL (mailto:)</Link> | |
| <Link href="tel:800-311-0932">External URL (tel:)</Link> | |
| <Link href="https://nextjs.org">External URL</Link> | |
| </> | |
| ) | |
| } | |
| export default Example |
| import { LinkSimpleHorizontal, ArrowSquareOut } from 'phosphor-react' | |
| import { getRoute } from '../../lib/routes' | |
| export default ({ | |
| hasDisplayTitle = true, | |
| internal = false, | |
| ...props | |
| } = {}) => { | |
| return { | |
| title: internal ? 'Internal Page' : 'External URL', | |
| name: internal ? 'internal' : 'external', | |
| type: 'object', | |
| icon: internal ? LinkSimpleHorizontal : ArrowSquareOut, | |
| fields: [ | |
| ...(hasDisplayTitle | |
| ? [ | |
| { | |
| title: 'Title', | |
| name: 'title', | |
| type: 'string', | |
| description: 'Display Text' | |
| } | |
| ] | |
| : [ | |
| { | |
| title: 'Label', | |
| name: 'label', | |
| type: 'string', | |
| description: 'Describe this link for Accessibility and SEO', | |
| validation: Rule => Rule.required() | |
| } | |
| ]), | |
| ...(internal | |
| ? [ | |
| { | |
| title: 'Page', | |
| name: 'page', | |
| type: 'reference', | |
| to: [{ type: 'page' }], | |
| validation: Rule => Rule.required() | |
| } | |
| ] | |
| : [ | |
| { | |
| title: 'URL', | |
| name: 'url', | |
| type: 'url', | |
| description: | |
| 'enter an external URL (mailto: and tel: are supported)', | |
| validation: Rule => | |
| Rule.required().uri({ | |
| scheme: ['http', 'https', 'mailto', 'tel'] | |
| }) | |
| } | |
| ]), | |
| ], | |
| preview: { | |
| ...(internal | |
| ? { | |
| select: { | |
| title: 'title', | |
| label: 'label', | |
| page: 'page', | |
| pageType: 'page._type', | |
| pageSlug: 'page.slug.current' | |
| }, | |
| prepare({ title, page, label, pageType, pageSlug }) { | |
| return { | |
| title: title ?? label, | |
| subtitle: page | |
| ? getRoute({ | |
| type: pageType, | |
| slug: pageSlug | |
| }) | |
| : 'no page set!' | |
| } | |
| } | |
| } | |
| : { | |
| select: { | |
| title: 'title', | |
| label: 'label', | |
| url: 'url' | |
| }, | |
| prepare({ title, label, url }) { | |
| return { | |
| title: title ?? label, | |
| subtitle: url | |
| } | |
| } | |
| }) | |
| }, | |
| ...props | |
| } | |
| } |
| import customLink from '@lib/custom-link' | |
| export default { | |
| title: 'Navigation', | |
| name: 'navigation', | |
| type: 'object', | |
| fields: [ | |
| { | |
| title: 'Links', | |
| name: 'links', | |
| type: 'array', | |
| of: [ | |
| customLink({ | |
| internal: true | |
| }), | |
| customLink() | |
| ] | |
| }, | |
| { | |
| title: 'Call To Action', | |
| name: 'cta', | |
| type: 'array', | |
| description: 'Link this card (optional)', | |
| of: [ | |
| customLink({ | |
| internal: true, | |
| hasDisplayTitle: false, | |
| }), | |
| customLink({ | |
| hasDisplayTitle: false, | |
| }), | |
| ], | |
| validation: (Rule) => Rule.length(1).error('You can only have one CTA'), | |
| } | |
| ], | |
| } |
| export const page = groq` | |
| "type": _type, | |
| "slug": slug.current, | |
| hash | |
| ` | |
| // Construct our "link" GROQ | |
| export const link = groq` | |
| _key, | |
| "type": _type, | |
| page->{ | |
| ${page}, | |
| }, | |
| url, | |
| title, | |
| label | |
| ` |
| import React from 'react' | |
| import Link from '@components/link' | |
| const Navigation = ({ links }) => { | |
| return ( | |
| <ul> | |
| {links.map(({ type, page, url, title }, key) => { | |
| // construct our href value based on the link type | |
| const href = { | |
| external: url, | |
| internal: page, | |
| }[type] | |
| return ( | |
| <li key={key}> | |
| <Link href={href} external={type === 'external'}> | |
| {title} | |
| </Link> | |
| </li> | |
| ) | |
| })} | |
| </ul> | |
| ) | |
| } | |
| export default Navigation |