Read Article)
Advanced Link Component for Next.js (Creating a reliable link component for any type of url
- The <Link /> component
- getRoute() helper
- The <ConditionalWrapper /> component
- Example Usage (frontend)
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:hello@spaghetti.com">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 |