next/linkがaタグを自動的に差し込む問題にちゃんと対処する

Next.js v13から<Link>コンポーネントは自動的にaタグを挿入する形に変更された
<Link> is a React component that extends the HTML <a> element to provide prefetching and client-side navigation between routes. It is the primary way to navigate between routes in Next.js.
単純に見れば、Link
コンポーネントがa
タグの変わりをしてくれると思えば良いんだけど、困るケースが2つ
- 既にaタグを含むライブラリを使用している場合
- ボタンコンポーネントをNext.jsに依存しないパッケージとしてパブリッシュしたい場合
1のケースは明確で、MUIやChakra UI等の外部ライブラリを用いている場合、自分ではどうしようもなくaタグは付いてくるという話
2のケースも最近だとモノレポ構成でNext.jsに依存しないようにコンポーネントを作りたいというニーズがある
今回はこの2点に関して、2023年11月時点のソリューションについて調査

Next.jsのドキュメント
If the child of Link is a custom component that wraps an <a> tag, you must add passHref to Link. This is necessary if you’re using libraries like styled-components. Without this, the <a> tag will not have the href attribute, which hurts your site's accessibility and might affect SEO. If you're using ESLint, there is a built-in rule next/link-passhref to ensure correct usage of passHref.
子コンポーネントがaタグを含む場合はpassHrefを使ってねと。
ただ、サンプルコードを見ると
import Link from 'next/link'
import styled from 'styled-components'
// This creates a custom component that wraps an <a> tag
const RedLink = styled.a`
color: red;
`
function NavLink({ href, name }) {
return (
<Link href={href} passHref legacyBehavior>
<RedLink>{name}</RedLink>
</Link>
)
}
export default NavLink
となっており、legacyBehavior
オプションが付与されている。
これは、Next.js v12時点と同じ動作をするためのオプションであり、未来永劫にあるオプションではないため、適切ではなさそう

ちなみに、ざっとissueを漁ってみましたが、この件に関して公式な回答はなさそうでした

MUIやChakra UI等のメジャーなライブラリのソリューション
共に同じようなソリューションを提供してくれていて
Chakra UIのほうが説明が丁寧だったので、そちらを参考にしますが、
import NextLink from 'next/link'
import { Link } from '@chakra-ui/react'
<Link as={NextLink} href='/home'>
Home
</Link>
のように、ライブラリ側がas
propsで<Link>
コンポーネントを受け取るという形式

import { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from 'react'
export type ElementAs = ElementType
export type AsProp<T extends ElementAs> = {
elementAs?: T
}
export type PolymorphicPropsWithoutRef<
T extends ElementAs,
P extends object
> = PropsWithChildren<P> & ComponentPropsWithoutRef<T> & AsProp<T>
type Props<T extends ElementAs> = PolymorphicPropsWithoutRef<
T,
{ isExternal?: boolean }
>
export const Link = <T extends ElementAs = 'a'>({
elementAs,
children,
...attr
}: Props<T>) => {
const Componet = elementAs || 'a'
return <Componet {...attr}>{children}</Componet>
}

こんな感じでできる
ポイントは、ここで定義するprops(上でいうとelementAsやisExternal)は名前被りが発生してはいけない
例えば、next/linkだとasというプロパティが存在しているので、elementAsの部分をasみたいにしてしまうと被りが発生しビルドエラーになる(ビルドエラーで教えてくれるようになってるだけマシだけど、、)
これは親側が気にしないといけないことなので、ちょっと微妙なポイントかも
ちなみに、Chakra UIとかだと、定義したプロパティに関しては、子のものが適応されるように、親側のpropsからOmitするという手法を取ってそう(だけど、これはむしろエラーが消えてしまい、親側を使う人がプロパティがうまく渡せていないことに気づかないと問題になるかもしれないので、やめておいた方が良いかなと思っている

Radix UIのソリューション
Slotという解決策を取っている
これは子コンポーネントに対してpropsを転送するみたいな形を取る
なので、Linkコンポーネントに対してスタイルを転送するみたいなことが可能なはず

こんな感じ
import { Slot } from '@radix-ui/react-slot'
import { ComponentPropsWithoutRef } from 'react'
import { twMerge } from 'tailwind-merge'
type Props = {
asChild?: boolean
} & ComponentPropsWithoutRef<'a'>
export const AnchorButton = (props: Props) => {
const { asChild = false, children, className, ...restProps } = props
const Component = asChild ? Slot : 'a'
return (
<Component
{...restProps}
className={twMerge(className, 'bg-white border border-cyan-700 p-4 w-24')}
>
{children}
</Component>
)
}
本当はasChild
がついている場合はComponentPropsWithoutRef<typeof children>
それ以外の場合はComponentPropsWithoutRef<'a'>
みたいな雰囲気なんだろうけど、まぁ、asChildで使う場合って、子要素にnext/linkみたいにaをラップしたコンポートになっている予定だと思うので、これでもよいような気がする

これで明らかに変なpropsを渡されるみたいなことはなさそう?かな?

それぞれのソリューションのわかりやすい解説があったのでこれを見ると良い

まとめ
MUIやChakra UIのようなas
プロパティによるソリューション(Polymorphic Component)に関して、一定量、コンポーネントの難易度は上がるものの、ある程度TypeScriptやReactに対しての成熟度があるチームであればメンテナンスできるような気がした
一方、Radix UIのようなasChild
プロパティによるソリューションに関して、たしかにPolymophic Componentより難易度は下がる一方、結果、Radix UIへの依存が出てくるので、今回の本来の目的であったnext/linkへの依存排除と同等レベル(もしくは将来性がわからないという点ではそれ以上かも)のものが出来上がってしまうので、個人的には微妙なのかなと思ってしまった(元々Radix UIに依存したコンポーネント群を作ってるならありだと思う)
なので、個人的にはPolymophic Componentの方に一票。
だけど、もうnext/linkに依存させちゃったほうがメンテナンスし易いよねというのは全然あると思うので、その時は共通パッケージ側にintegration-next
とかextends-next
とかそういうディレクトリを切って入れてしまうというのもありなんじゃないかなと思う