Back to components

Nav Link

Adds active, exact, and pending route state to Next.js App Router links, with typed routes, nested matching, and Cache Components support.

https://app.example.com/dashboard
active
Overview
match
exact
aria-current
page
pending
false

Overview

"use client";

import type { Route } from "next";
import Link, { useLinkStatus } from "next/link";
import { usePathname } from "next/navigation";
import {
  Suspense,
  type ComponentProps,
  type ReactNode,
} from "react";

export type NavLinkMatch = "exact" | "prefix";

export type NavLinkActiveProps = {
  isActive: boolean;
  isExact: boolean;
};

export type NavLinkRenderProps = NavLinkActiveProps & {
  isPending: boolean;
};

export type NavLinkProps<T extends string = string> = Omit<
  ComponentProps<typeof Link>,
  "aria-current" | "children" | "className" | "href"
> & {
  /**
   * A typed Next.js route or an absolute URL. When `typedRoutes` is enabled,
   * invalid literal routes are reported by TypeScript.
   */
  href: Route<T> | URL;
  /**
   * Defaults to `exact`, so a link is active only on its own page. Opt into
   * `prefix` for a section link that should stay active on descendant routes.
   * The root route always matches exactly.
   */
  match?: NavLinkMatch;
  /**
   * Overrides `usePathname()`. Useful for previews, tests, rewrites, and
   * routing layers that expose a canonical pathname.
   */
  currentPathname?: string;
  className?:
    | string
    | ((props: NavLinkActiveProps) => string | undefined);
  children?: ReactNode | ((props: NavLinkRenderProps) => ReactNode);
};

/**
 * A Next.js App Router link with active, exact, and pending route state.
 *
 * `className` receives active route state. Function children additionally
 * receive `isPending` because `useLinkStatus()` must run inside the Link.
 */
export function NavLink<T extends string = string>({
  currentPathname,
  ...props
}: NavLinkProps<T>) {
  if (currentPathname !== undefined) {
    return <ResolvedNavLink {...props} pathname={currentPathname} controlled />;
  }

  return (
    <Suspense fallback={<NavLinkShell {...props} state={inactiveState} />}>
      <PathnameNavLink {...props} />
    </Suspense>
  );
}

type PathnameNavLinkProps<T extends string = string> = Omit<
  NavLinkProps<T>,
  "currentPathname"
>;

const inactiveState: NavLinkActiveProps = {
  isActive: false,
  isExact: false,
};

function PathnameNavLink<T extends string>(props: PathnameNavLinkProps<T>) {
  return <ResolvedNavLink {...props} pathname={usePathname()} />;
}

type NavLinkShellProps<T extends string = string> = PathnameNavLinkProps<T> & {
  state: NavLinkActiveProps;
  /** Skips the first-paint script, which cannot know the controlled pathname. */
  controlled?: boolean;
};

function ResolvedNavLink<T extends string>({
  href,
  match = "exact",
  pathname,
  ...props
}: Omit<NavLinkShellProps<T>, "state"> & { pathname: string }) {
  const targetPathname = getHrefPathname(href);
  const isExact = targetPathname
    ? isPathActive(pathname, targetPathname, "exact")
    : false;
  const isActive = targetPathname
    ? isPathActive(pathname, targetPathname, match)
    : false;

  return (
    <NavLinkShell
      {...props}
      href={href}
      match={match}
      state={{ isActive, isExact }}
    />
  );
}

function NavLinkShell<T extends string>({
  href,
  match = "exact",
  state,
  controlled,
  className,
  children,
  ...props
}: NavLinkShellProps<T>) {
  const targetPathname = controlled ? undefined : getHrefPathname(href);

  return (
    <Link
      {...props}
      href={href}
      aria-current={
        state.isActive ? (state.isExact ? "page" : "location") : undefined
      }
      data-slot="nav-link"
      data-active={state.isActive ? "" : undefined}
      data-navlink-href={targetPathname}
      data-navlink-match={targetPathname ? match : undefined}
      suppressHydrationWarning
      className={typeof className === "function" ? className(state) : className}
    >
      {typeof children === "function" ? (
        <NavLinkRenderChildren state={state} render={children} />
      ) : (
        children
      )}
    </Link>
  );
}

function NavLinkRenderChildren({
  state,
  render,
}: {
  state: NavLinkActiveProps;
  render: (props: NavLinkRenderProps) => ReactNode;
}) {
  const { pending } = useLinkStatus();

  return <>{render({ ...state, isPending: pending })}</>;
}

/**
 * Optional first-paint enhancement for CSS that targets `data-active` or
 * `aria-current`. Render it once at the end of the root layout body.
 *
 * Pass `basePath` when the app sets one in `next.config`, because
 * `location.pathname` includes it while `href` does not. Links using
 * `currentPathname` are skipped, since only the app knows that value.
 */
export function NavLinkScript({
  nonce,
  basePath,
}: {
  nonce?: string;
  basePath?: string;
}) {
  return (
    <script
      nonce={nonce}
      data-slot="nav-link-script"
      type={typeof window === "undefined" ? "text/javascript" : "text/plain"}
      suppressHydrationWarning
      dangerouslySetInnerHTML={{ __html: getNavLinkScript(basePath) }}
    />
  );
}

export function isPathActive(
  pathname: string,
  targetPathname: string,
  match: NavLinkMatch = "exact",
) {
  const current = normalizePathname(pathname);
  const target = normalizePathname(targetPathname);

  if (match === "exact" || target === "/") {
    return current === target;
  }

  return current === target || current.startsWith(`${target}/`);
}

/**
 * Returns a comparable pathname only for root-relative hrefs. Absolute URLs,
 * protocol-relative hrefs, relative segments, and bare `?`/`#` hrefs have no
 * route identity of their own and are never treated as active.
 */
function getHrefPathname<T extends string>(href: Route<T> | URL) {
  if (href instanceof URL || !href.startsWith("/") || href.startsWith("//")) {
    return undefined;
  }

  return normalizePathname(href);
}

function normalizePathname(pathname: string) {
  const pathOnly = pathname.split(/[?#]/, 1)[0] || "/";

  if (pathOnly === "/") {
    return pathOnly;
  }

  return pathOnly.replace(/\/+$/, "") || "/";
}

function getNavLinkScript(basePath?: string) {
  return `(function(){
  function normalize(value) {
    var path = (value || '/').split(/[?#]/, 1)[0] || '/';
    return path === '/' ? path : path.replace(/\\/+$/, '') || '/';
  }

  var base = normalize(${JSON.stringify(basePath ?? "").replace(/</g, "\\u003c")});
  var pathname = normalize(location.pathname);

  if (base !== '/' && (pathname === base || pathname.indexOf(base + '/') === 0)) {
    pathname = normalize(pathname.slice(base.length));
  }

  document.querySelectorAll('[data-navlink-href]').forEach(function(link) {
    var target = normalize(link.getAttribute('data-navlink-href'));
    var match = link.getAttribute('data-navlink-match') || 'exact';
    var isExact = pathname === target;
    var isActive = match === 'exact' || target === '/'
      ? isExact
      : isExact || pathname.startsWith(target + '/');

    if (isActive) {
      link.setAttribute('data-active', '');
      link.setAttribute('aria-current', isExact ? 'page' : 'location');
    } else {
      link.removeAttribute('data-active');
      link.removeAttribute('aria-current');
    }
  });
})()`;
}

Why not just next/link?

Based on Building an active NavLink component in Next.js by Aurora Scharff.

next/link performs the navigation and deliberately leaves its state to the app: whether this link is the current location, whether a parent section owns the current page, and whether a slow click was received. NavLink renders the real Link and adds only that state.

Link does not know if it is the current location

Every navigation surface ends up repeating usePathname(), a matching function, active classes, and aria-current — then drifting apart between sidebar, header, and tabs. NavLink resolves it once and exposes isActive, isExact, and data-active, with aria-current="page" for a leaf and "location" for an active parent.

sidebar-nav.tsx
import { NavLink } from "@/components/ui/nav-link";

const link =
  "block rounded-md px-3 py-2 text-muted-foreground data-active:bg-muted data-active:font-medium data-active:text-foreground";

export function SidebarNav() {
  return (
    <nav aria-label="Dashboard">
      {/* Leaf pages. Active only on their own route. */}
      <NavLink href="/dashboard" className={link}>
        Overview
      </NavLink>
      <NavLink href="/dashboard/settings" className={link}>
        Settings
      </NavLink>

      {/* Section. Opts in to staying active on /dashboard/projects/[id]. */}
      <NavLink href="/dashboard/projects" match="prefix" className={link}>
        Projects
      </NavLink>
    </nav>
  );
}

A plain class string and static children stay serializable, so a Server Component can render this navigation.

The current page can also belong to a parent section

Projects should stay selected on /dashboard/projects/acme, but a naive startsWith() also lights up /projects-archive. A link matches exact by default, so nothing highlights by accident; a section opts in with match="prefix", which respects whole path segments. Query strings, hashes, and trailing slashes are normalized away in both modes.

match-modes.tsx
// Default. Active only on its own route.
<NavLink href="/dashboard/projects">Projects</NavLink>

//   /dashboard/projects          isActive, isExact
//   /dashboard/projects/acme     inactive
//   /dashboard/projects-archive  inactive

// Opt in to prefix for a section link.
<NavLink href="/dashboard/projects" match="prefix">
  {({ isActive, isExact }) => (
    <>
      Projects
      {isActive && !isExact ? <ChevronRight /> : null}
    </>
  )}
</NavLink>

//   /dashboard/projects          isActive, isExact   aria-current="page"
//   /dashboard/projects/acme     isActive            aria-current="location"
//   /dashboard/projects-archive  inactive (whole segments only)

A slow navigation gives no feedback

A prefetched route usually updates immediately, so useLinkStatus() never reports a wait — a spinner on every link only makes fast navigation feel slow. Reach for isPending when prefetching is off or the destination must fetch before the URL can change, and delay the indicator ~150ms so quick transitions stay silent.

pending-link.tsx
"use client";

import { LoaderCircle } from "lucide-react";

import { NavLink } from "@/components/ui/nav-link";
import { cn } from "@/lib/utils";

export function ReportsLink() {
  return (
    <NavLink href="/reports" prefetch={false}>
      {({ isPending }) => (
        <>
          <span>Reports</span>
          <LoaderCircle
            aria-hidden="true"
            className={cn(
              "size-4 opacity-0 transition-opacity duration-150",
              isPending &&
                "animate-spin opacity-100 delay-150 motion-reduce:animate-none motion-reduce:transition-none",
            )}
          />
        </>
      )}
    </NavLink>
  );
}

State it exposes

isActive
The exact destination, or a matching descendant under prefix matching.
isExact
The normalized current pathname equals the destination.
isPending
The clicked link is waiting for Next.js to update history. Function children only.
data-active
Set on the active anchor, for static CSS without a render function.
aria-current
"page" for the exact page, "location" for an active parent section.
ref
The rendered anchor, following the React 19 ref-as-prop contract.

Design notes

  • It always renders Next.js Link, so prefetching, modifier keys, history, and scroll behavior are untouched, and href stays generic over Route<T> to keep typed routes checked.
  • The pathname read sits in its own Suspense boundary with a real, inactive Link as the fallback, so a dynamic route cannot blank out the layout. Pass currentPathname to skip that read when the app already knows the canonical path — rewrites, previews, tests.
  • NavLinkScript is optional. Rendered once at the end of the root body, it sets data-active and aria-current during HTML parsing, for a shell that must look correct before hydration. It only affects attribute-based CSS, and skips links that pass currentPathname.
  • Requires the Next.js App Router; isPending needs Next.js 15.3 or later. Function className and children are client values and cannot cross a Server-to-Client boundary. Absolute URLs, relative hrefs, and bare # or ? hrefs are never marked active.
app/layout.tsx
import { NavLinkScript } from "@/components/ui/nav-link";

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        {children}
        <NavLinkScript />
      </body>
    </html>
  );
}

Installation