Youâd think it would be simple. And in principle, it can be. The Next.js Link component accept both a className and style prop so you can style it however you want. But what if you already have a Button component that can render a <a> element, and want that component to support router navigation?
In my case, I use Ant Design in a side project, and want to be able to use router navigation with Antâs Button component. Passing a resolved href prop technically works, but it will be a browser navigation (including a full page reload), and not a router navigation.
import { Button } from 'antd'
// đ Kinda works, but triggers full-page navigation
function MyComponent() {
return <Button href="/item/1234">Jump back</Button>
}
Approach 1: imperative routing
In its documentation, Next.js mentions âimperative routingâ. It would look like this:
import { Button } from 'antd'
import { useRouter } from 'next/router'
function MyComponent() {
const router = useRouter()
return (
<Button onClick={() => router.push('/item/1234')}>
Jump back
</Button>
)
}
The problem with this approach is that it will render a <button> element, which is not semantically correct and an accessibility faux-pas. This is not actually a button, itâs a link to another page, and as such should render an <a> element.
Approach 2: headless link
The second approach, which I opted for, is to use Nextâs <Link> as a headless component â that is a component that doesnât actually render DOM. This is how to do it (and still works in Next.js 16):
import { Button } from 'antd'
import Link from 'next/link'
function MyComponent() {
return (
<Link href='/item/1234' passHref legacyBehavior>
<Button>
Jump back
</Button>
<Link>
)
}
There is a lot going on in Nextâs code, so here is the summary:
- The
legacyBehaviorprop resorts to cloning the child element. On its own, it basically does nothing. - The
passHrefprop assigns thehrefprop onto the child.
So if you use only legacyBehavior, nothing happens because all it does is clone the child element. And if you use only passHref, it renders a <button> element (from Ant) within a <a> element (from Next), which is not what we want. Itâs by combining both that we get it to render an Ant <Button> component rendering a <a> element, using Nextâs routing logic.
Making a reusable component
Itâs something Iâve used often enough in the project that I resorted to create a small wrapping component for that:
import { Button, type ButtonProps } from 'antd'
import Link, { type LinkProps } from 'next/link'
export type RouterButtonProps = Omit<ButtonProps, 'href' | 'htmlType'> & {
href: LinkProps['href']
linkProps?: Omit<LinkProps, 'href'>
}
export function RouterButton({ href, linkProps, ...props }: RouterButtonProps) {
return (
<Link href={href} {...linkProps} passHref legacyBehavior>
<Button {...props} />
</Link>
)
}
And it can be used like this:
<RouterButton
// The `href` prop is handled by the Next link
href="/item/1234"
// Occasional and optional link props can be passed
// See: https://nextjs.org/docs/app/api-reference/components/link#reference
linkProps={{ onNavigate: () => track('jump_back_click') }}
// Everything else goes to the Ant button
// See: https://ant.design/components/button#api
type='primary'
ghost
block
className="MyButton"
>
Jump back
</RouterButton>
And of course, it renders what we expect: a single <a> element, benefiting from Next routing navigation, styled as a button. No invalid DOM, no full page reload. Neat!