DEV Community

Cover image for Creating a link with an active state in Next.js
Elves Sousa
Elves Sousa

Posted on • Edited on

Creating a link with an active state in Next.js

There is something that, at the moment I write these lines, still lacks in Next.js: a component <Link /> showing a different class while the page is being visited.

Why use the link if you can use normal anchors?

Before continuing, a small pause, to see why using <Link /> instead of an <a>.

Basically, every time you use a normal anchor, the page makes a full refresh. The <Link /> component changes this behavior by loading only what changes on the screen, avoiding unnecessary rendering and making the experience faster and smoother. This is just for internal links; for the external ones, the anchor is enough.

React and Gatsby projects

In a React (CRA) project, this already comes by default with the React Router DOM library: just import a component <Link /> that comes with it, and add the activeClassName attribute, informing a CSS class for the active state of that anchor.

import { Link } from "react-router-dom"

export function Nav() {
  return (
    <nav>
      <Link to="/" activeClassName="active">
        Home
      </Link>
      <Link to="/blog" activeClassName="active">
        Blog
      </Link>
      <Link to="/about" activeClassName="active">
        About
      </Link>
    </nav>
  )
}
Enter fullscreen mode Exit fullscreen mode

In Gatsby, another framework for creating static pages in React, the same can be achieved through the Gatsby library.

import { Link } from "gatsby"

export function Nav() {
  return (
    <nav>
      <Link to="/" activeClassName="active">
        Home
      </Link>
      <Link to="/blog" activeClassName="active">
        Blog
      </Link>
      <Link to="/about" activeClassName="active">
        About
      </Link>
    </nav>
  )
}
Enter fullscreen mode Exit fullscreen mode

However, in Next.js, for some reason I don't know yet, the implementation of the <Link /> component is quite different: a child element is required and there are no to and activeClassName properties.

import Link from "next/link"

export function Nav() {
  return (
    <nav>
      <Link href="/">
        <a>Home</a>
      </Link>
      <Link href="/blog">
        <a>Blog</a>
      </Link>
      <Link href="/about">
        <a>About</a>
      </Link>
    </nav>
  )
}
Enter fullscreen mode Exit fullscreen mode

It is a good implementation, meets multiple needs, but still lacks support for a class for the active state, as seen in previous examples.

How to bring activeClassName support to Next.js

Let's now create the <ActiveLink />: a component which will have the active class support. Here, the code is in typescript, but if your project uses JavaScript, the code works as well: just remove the typing. The component has only the required code for this feature to work.

First, we create the basic structure:

import { useRouter } from "next/router"
import Link from "next/link"

export function ActiveLink() {
  const { asPath } = useRouter()

  return <Link>...</Link>
}
Enter fullscreen mode Exit fullscreen mode

The "hook" function useRouter is imported from Next.js, so that our component has information for the current route. This hook has the asPath property, which informs the current path of the page.

After this, let's create the properties of our component:

import { ReactElement } from "react"
import { useRouter } from "next/router"
import Link, { LinkProps } from "next/link"

type ActiveLinkProps = {
  children: ReactElement
  activeClassName: string
}

export function ActiveLink({ children, activeClassName }: ActiveLinkProps) {
  const { asPath } = useRouter()

  return <Link>{children}</Link>
}
Enter fullscreen mode Exit fullscreen mode

Here, I use the ActiveLinkProps type to inform the properties that the component will accept:

  • children: It is a ReactElement type, that is, accepts a single React element as a parameter. If a ReactNode or JSX.Element type is used, it works as well, but as we will only have one element as child, is better to ReactElement.
  • activeClassName: With the 'string' type, as a simple text is enough to enter the name of a valid CSS class.

The problem is that at this time, the component doesn't have access to the properties of a normal <Link />. To do this, you need to extend the ActiveLinkProps type. Without these properties, the component will not work as a real replacement to the Next.js default link. Thus, it is necessary to import the Linkprops definition that comes with next/link:

import Link, { LinkProps } from "next/link"
Enter fullscreen mode Exit fullscreen mode

After this, we make ActiveLinkProps aware of LinkProps type properties.

...

type ActiveLinkProps = LinkProps & {
  children: ReactElement
  activeClassName: string
}

...
Enter fullscreen mode Exit fullscreen mode

Inside the component, an argument is then added to the function with the spread operator1, so that all the native properties of the Next.js link can be accessed and passed on to the returned component in the function.

import { ReactElement } from "react"
import { useRouter } from "next/router"
import Link, { LinkProps } from "next/link"

type ActiveLinkProps = LinkProps & {
  children: ReactElement
  activeClassName: string
}

export function ActiveLink({
  children,
  activeClassName,
  ...rest
}: ActiveLinkProps) {
  const { asPath } = useRouter()

  // The "...rest" represents all properties coming from LinkProps
  return <Link {...rest}>...</Link>
}
Enter fullscreen mode Exit fullscreen mode

Now just make a conditional that verifies if the current route is the same as the "href" of the component.

const className = asPath === rest.href ? activeClassName : ""
Enter fullscreen mode Exit fullscreen mode

If true, the class informed in activeClassName will be used.

Applying className in children components

Next.js' default implementation of <Link /> doesn't accept a className property. This should be passed on to a child element, otherwise it will not work:

<Link href="/">
  <a className="meuLink">Home</a>
</Link>
Enter fullscreen mode Exit fullscreen mode

Therefore, to pass the property the correct way, we need to use the React.cloneElement()2 method to clone the child element, and passing className to it.

The final code will look like this:

import { cloneElement, ReactElement } from "react"
import { useRouter } from "next/router"
import Link, { LinkProps } from "next/link"

type ActiveLinkProps = LinkProps & {
  children: ReactElement
  activeClassName: string
}

export function ActiveLink({
  children,
  activeClassName,
  ...rest
}: ActiveLinkProps) {
  const { asPath } = useRouter()
  const className = asPath === rest.href ? activeClassName : ""

  return <Link {...rest}>{cloneElement(children, { className })}</Link>
}
Enter fullscreen mode Exit fullscreen mode

One more thing...

If you're not like me, maybe you noticed I forgot something: the className in the child element gets replaced by activeClassName when the route is active (thanks Lyrod for your insights). In many cases it will work properly, but if you need to have two classes in the same element like "mylink active", then this will not be enough.

To solve this little issue, we need to get the current child element's className first. This can be achieved by using children.props.className. After that, we merge it with activeClassName:

const childClassName = children.props.className
const newClassName = `${childClassName} ${activeClassName}`
Enter fullscreen mode Exit fullscreen mode

The code above will print an undefined if children.props.className is not present. The same will happen with activeClassName. To get rid of these, we use the nullish coalescing operator ??3 to save a couple of "ifs".

const childClassName = children.props.className ?? ""
const newClassName = `${childClassName} ${activeClassName ?? ""}`
Enter fullscreen mode Exit fullscreen mode

Now we just have to update the conditional to include this newClassName variable I came up with:

const className = asPath === rest.href ? newClassName.trim() : ""
Enter fullscreen mode Exit fullscreen mode

The trim() part will eliminate spaces left when one of the classes is not available.

So, the real final code looks like this now:

import { cloneElement, ReactElement } from "react"
import { useRouter } from "next/router"
import Link, { LinkProps } from "next/link"

type ActiveLinkProps = LinkProps & {
  children: ReactElement
  activeClassName: string
}

export function ActiveLink({
  children,
  activeClassName,
  ...rest
}: ActiveLinkProps) {
  const { asPath } = useRouter()
  const childClassName = children.props.className ?? ""
  const newClassName = `${childClassName} ${activeClassName ?? ""}`
  const className = asPath === rest.href ? newClassName.trim() : ""

  return <Link {...rest}>{cloneElement(children, { className })}</Link>
}
Enter fullscreen mode Exit fullscreen mode

That's all folks!

Links


If this article helped you in some way, consider donating. This will help me to create more content like this!


  1. Spread operator: Read more about it at MDN

  2. React.cloneElement: See more at React docs 

  3. Nullish coalescing operator: Read more about it MDN

Top comments (5)

Collapse
 
lyrod profile image
Lyrod

Using cloneElement, you'll override the className that already exists on the element, right?

I think you need to retrieve the current className and merge with active

Collapse
 
elvessousa profile image
Elves Sousa

Exactly! cloneElement() does just that.
Thank you for the feedback. The article is updated with the implementation. Now the component is merging the classes.

Collapse
 
lyrod profile image
Lyrod

Uodate 2 : using interpolation to concat both className is not a good idea. If children.props.className is undefined, I think you'll have "undefined className" in the new className

Thread Thread
 
elvessousa profile image
Elves Sousa

That's what happens when you do things in a hurry, hahaah... You were right again. I made this change, what do you think? This was enough to get rid of the undefined.

export function ActiveLink({
  children,
  activeClassName,
  ...rest
}: ActiveLinkProps) {
  const { asPath } = useRouter();
  const childClassName = children.props.className ?? '';
  const newClassName = `${childClassName} ${activeClassName ?? ''}`;
  const className = asPath === rest.href ? newClassName.trim() : '';

  return <Link {...rest}>{cloneElement(children, { className })}</Link>;
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
lyrod profile image
Lyrod

This is better!