Aman Explains

Settings

Using aria-current for active nav link in NextJS app

July 26, 2021• ☕️☕️7 min read

What is aria-current?

As per W3C definition, aria-current attribute can be used to identify or visually style an element among a set of related items. It can take multiple token values: page, step, locationdatetimefalse and true

The page token can be used to indicate the currently active link among other navigation links. It is equally helpful for users relying on assistive technologies to recognise the currently displayed page. For example, if you are on Home page, the screen reader would announce something like “current page, Home”.

<ul>
  <li><a href="/" aria-current="page">Home</a></li>  <li><a href="/about">About</a></li>
  <li><a href="/contact">Contact</a></li>
</ul>

And you can now easily style the current home page by using the CSS attribute selector

a[aria-current] {
 color: red;
}

With the definition aside, let’s see how we can inject this attribute dynamically into a NextJS Link tag. It’s way simpler than you think, and there’s no excuse for avoiding it. Moreover, this idea can also be applied to other applications, as long as you can get hold of the current URL path.

NextJS provides us with Link component for client-side routing. It has useRouter hook to extract the current route (path as shown in the browser) too. We can easily use it to inject aria-current attribute like in the following example: 

const NavLink = ({ href, name }) => {

  const { asPath } = useRouter();  const ariaCurrent = href === asPath ? "page" : undefined;
  return (
    <Link href={href}>
      <a aria-current={ariaCurrent}>{name}</a>
    </Link>
  );
};

To test it, you can open the voiceover app in the mac via Command + F5, and when the current active link gets the focus, the voiceover will announce current page along with the link name. This is a nice improvement for such a small change. Your users will thank you for this.

The approach remains similar if you’re using a basePath in your next.config.js, as basePath value is not included in router.asPath.

The curious case of dynamic routing with custom server

The suggested solution would cover most of your use cases, including the one where your navigation links are generated based off some dynamic routes at run time. And that was the reason we relied upon router.asPath instead of router.pathname.

The reason being that the router.pathname would return placeholder (slug) values instead of actual dynamic route values. For example, if you have a pages/posts/[pid].js as your dynamic path, the router.pathname would return /posts/[pid], which will be different from your href value at run time. This will break your solution as araiCurrent will be undefined and no aria-current props will be passed down to your anchor element. 

But, what about that curious case?

Here’s the GIF displaying how our first solution fails, and active page link is not highlighted on first page load.

GIF displaying the broken solution

Note how it starts highlighting (colour: red) the correct active page link when we transition to another route (client-side).

Well, that curious case could be the one which meet the following conditions:

  • custom server using express (NextJS)
  • configured with a basePath
  • all server-rendered pages (using getServerSideProps)
  • navigation links are generated at run time for dynamic routes
// router.js
const router = express.Router();

// Our configured base path inside next.config.js file
router.get(/${basePath}/posts/:pid, (req, res) => {
 app.render(req, res, '/posts/[pid]', req.params);
});
// server.js

/** 
 * Custom NodeJS server for our NextJS app
 * Showing only important bits for the sake of code brevity
 */
import router from './router'; 

app.prepare().then(() => {
  const server = express();

  server.use(router);
  server.listen(4000);

  return server;
}).catch(err => console.log(err));

Now, for your server-rendered pages (which contain your nav links), if you try to print the value of console.log(router.asPath) inside your NavLink component above, you will find an interesting fact: the client-side value is different from the server-side value. 

The client printed value will not include basePath in the URL, but server-side does. Considering the dynamic post route example with /custom configured as basePath, you might find the results like as shown in the screenshot below: 

a comparison screenshot of client vs server print value of NextJS router asPath

This discrepancy in the result could break our suggested solution. And that’s what took me by surprise, too. You may wonder why we are even talking about server output? Why don’t we just grab the client’s output and be done with it? After all, our logic (href === asPath) is purely a client side thing—compare href with the URL shown in the browser.

I was thinking along the same line, hoping that if the sever-rendered HTML is different from client-side, then React would at least try to patch things up, making the rendering part win over the hydrating part. But as the React official docs describes that it’s not guaranteed that React will patch up attribute mismatches. 

I was pulling my hair out. Our comparison was returning false and ariaCurrent was becoming undefined on the first render. Nonetheless, making client-side transitions after the page load was injecting correct aria-current value as expected.

The solution

Let’s try to solve the problem with existing information/result we have:

  • the router.asPath is equal to href attribute at client-side
  • client-side route transitions fixes the problem automatically

Based on this fact, we can use useState hook to force re-render our NavLink component after the mounting phase. This will make our state variable (ariaCurrent) stores the expected value. 

Let’s see that in action:

const NavLink = ({ href, children }) => {

  // We need a setter to cause a re-render  const [ariaCurrent, setAriaCurrent] = useState();
  const { asPath } = useRouter();

  useEffect(() => {
    const ariaCurrent = href === asPath ? "page" : undefined;

    /* You could even create your own custom hook,
     * for e.g. `useForceUpdate()` if you like.
     *
     * Calling the setter, would cause a re-render
     */
    setAriaCurrent(ariaCurrent);  }, [asPath, href]);

  return (
    <Link href={href}>
      <a aria-current={ariaCurrent}>{children}</a>
    </Link>
  );
};

Here’s the GIF showing the fix with our new implementation:

GIF showing the fix for aria-current discrepancy in custom server inside NextJS app

Summary

In this article, we looked into an easy solution to not only visually style currently active link among the set of related page links, but also how to make it more accessible to the users relying on assistive technologies.

Additionally, we looked into an interesting discrepancy with the first solution when your navigation links are generated at run time based on dynamic paths. The working solution we discussed required us to make our NavLink component re-render after the first page load. I understand that we are doing unnecessary rendering, but I think the benefits it provides are way more than the rendering cost I am willing to take. 

Here’s the GitHub repo link for the custom server setup we discussed. The main branch carries the bug (the broken solution), and inject-aria-current-dynamic-routes branch carries the fix. Go and see it for yourself. 

If you’ve a better solution to tackle this problem, or want to share your ideas in general over this article, please feel free to drop your comment in the box below. Stay safe and happy coding 😍.


Amandeep Singh

Written by Amandeep Singh. Developer @  Domain  Sydney. Tech enthusiast and a pragmatic programmer.