Next.js에서 Active NavLink 컴포넌트 만들기

작성일:2026.07.05|수정일:2026.07.05|조회수:4

Next.js에서 Active NavLink 컴포넌트 만들기

이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Building an Active NavLink Component in Next.js 게시글을 번역한 것이다. 번역 과정에서 약간의 의역이 있고, 일부에는 내 의견도 섞여 있다.

Active link 스타일링은 거의 모든 Next.js 앱에서 어떤 형태로든 필요하다. App Router는 현재 route를 읽기 위해 usePathname()useSelectedLayoutSegment()를 제공한다. 그 다음부터는 matching되는 링크를 어떻게 스타일링할지 우리가 결정해야 한다.

이 글에서는 React Router에서 아이디어를 가져와 usePathname() 위에 재사용 가능한 NavLink 컴포넌트를 만들어볼 것이다. 먼저 render prop 패턴부터 시작해, pending 상태를 위한 useLinkStatus, prefix matching, 접근성, TypeScript를 차례로 붙여볼 것이다. 그 다음 useSelectedLayoutSegments를 사용하는 대안을 비교하고, 첫 paint에서 flicker가 생기지 않도록 inline script를 추가한 뒤, cacheComponents 환경에서도 동작하게 만들 것이다.

조금 긴 길이다. 그래도 하나씩 가보자.

사용 사례

X 같은 소셜 미디어의 사이드바 nav를 생각해보자. Home, Search, 그리고 현재 사용자 프로필로 가는 링크가 있다. nav는 root layout에 있고, 몇 개의 static route와 dynamic post route 위에 놓인다.

TXT
app/
  layout.tsx
  page.tsx
  search/page.tsx
  u/[handle]/page.tsx
  drop/[id]/page.tsx

active state logic이 전혀 없다면 nav는 단순히 세 개의 링크다.

TSX
// app/layout.tsx
<nav>
  <Link href="/"><HomeIcon /> Home</Link>
  <Link href="/search"><SearchIcon /> Search</Link>
  <ProfileLink />
</nav>

async function ProfileLink() {
  const handle = await getCurrentUserHandle();
  return <Link href={`/u/${handle}`}><UserIcon /> Profile</Link>;
}

ProfileLink는 현재 사용자에 따라 href가 달라지므로 async Server Component다. 이 컴포넌트 주위에 Suspense boundary가 없다면, handle이 resolve될 때까지 layout 전체가 막힌다. 지금은 일단 괜찮다. 뒤에서 다시 돌아올 것이다.

각 링크는 현재 페이지일 때 자기 자신을 스타일링해야 한다. 텍스트는 bold로 만들고, 아이콘은 채워진 형태로 바꿔야 한다. class swap으로 bold 처리는 가능하지만, 아이콘은 JSX 안에서 outline variant와 filled variant를 바꿔야 한다. 그래서 active state는 class hook으로도 필요하고, render tree 안에서 읽을 수 있는 값으로도 필요하다.

React Router는 어떻게 하는가

React Router의 NavLink는 이런 유연성을 허용하는 API를 가지고 있다. classNamechildren 모두 { isActive, isPending }을 받는 함수를 허용하므로, consumer가 state를 어떻게 사용할지 결정할 수 있다. Home 링크를 세 가지 방식으로 스타일링하면 다음과 같다.

TSX
import { NavLink } from "react-router";

// plain string: CSS/Tailwind에서 aria-current를 기준으로 active styling
<NavLink to="/" className="nav-item aria-[current=page]:font-bold">
  Home
</NavLink>

// function className: isActive에 따라 class 교체
<NavLink to="/" className={({ isActive }) => (isActive ? "active" : "")}>
  Home
</NavLink>

// function children with isPending: navigation 중 pending state 표시
<NavLink to="/">
  {({ isActive, isPending }) => (
    <>
      <HomeIcon filled={isActive} />
      Home
      {isPending && <Spinner />}
    </>
  )}
</NavLink>

처음 보면 조금 이상하다. 왜 스타일링 prop이 함수일까? App Router용 NavLink를 이렇게 만들어보면 점점 자연스럽게 느껴질 것이다.

첫 번째 시도

가장 단순한 버전은 usePathname()과 링크의 href를 비교하고 class를 toggle하는 것이다.

TSX
// app/components/nav-link.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

export function NavLink({ href, children }) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link href={href} className={isActive ? "active" : undefined}>
      {children}
    </Link>
  );
}

여기서 wrapper가 plain <a>가 아니라 next/link를 감싼다는 점을 보자. 이것은 중요하다. next/link는 client-side navigation, viewport 안 route의 automatic prefetch, scroll restoration을 처리한다. 내부 route 이동에 <a href>를 쓰면 click마다 full page reload가 발생하고, router state와 partially streamed UI를 잃는다. 내부 route에서는 반드시 underlying Link를 유지하자.

Next.js 문서도 active 링크 component를 만들 때 useSelectedLayoutSegment() 사용을 추천한다. 이 글의 뒤쪽에서 전체 비교를 다시 볼 것이다. 지금은 링크의 href와 비교하는 가장 직접적인 방식인 usePathname()으로 시작한다.

className과 activeClassName 받기

하나의 active class만으로는 금방 부족해진다. consumer가 사이드바 link와 header link에 서로 다른 스타일링을 주고 싶어지는 순간 hardcoded class는 방해가 된다.

base class와 active class를 둘 다 prop으로 받게 해보자.

TSX
// app/components/nav-link.tsx
type Props = {
  href: string;
  className?: string;
  activeClassName?: string;
  children: React.ReactNode;
};

export function NavLink({ href, className, activeClassName, children }: Props) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      className={isActive ? `${className} ${activeClassName}` : className}
    >
      {children}
    </Link>
  );
}

가장 흔한 경우는 이 정도로 해결된다. 하지만 active 상태일 때 leading dot을 렌더링하거나, 아이콘을 filled variant로 바꾸거나, clsx 같은 class-name utility가 isActive에 접근해야 하는 순간 이 두 class shape은 부족하다. 이런 경우 consumer에게 isActive 자체가 필요하다.

Render Prop으로 isActive 노출하기

React Router가 isActive를 노출하는 방식은 render prop pattern이다. string을 받는 대신, prop이 컴포넌트 내부 state를 인자로 받는 함수를 받을 수 있게 한다. 컴포넌트는 state를 소유하고, consumer는 rendering을 소유하며, 함수가 그 둘 사이의 다리가 된다. 그래서 className이 함수일 수 있다. 컴포넌트는 consumer가 각 state에서 어떤 class를 원하는지 알 수 없으므로, state를 건네주고 결정은 consumer에게 맡긴다.

같은 아이디어를 NavLinkclassNamechildren에 적용할 수 있다. 작은 helper 하나가 “값 또는 함수” 형태를 처리하면, active state가 필요 없는 consumer는 여전히 plain value를 넘길 수 있다.

TSX
// app/components/nav-link.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

function resolve(value, props) {
  return typeof value === "function" ? value(props) : value;
}

export function NavLink({ href, className, children, ...rest }) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link href={href} className={resolve(className, { isActive })} {...rest}>
      {resolve(children, { isActive })}
    </Link>
  );
}

이제 consumer는 plain value와 function 중 하나를 고를 수 있다. className에서는 function shape으로 isActive에 따라 class를 바꿀 수 있고, children에서는 링크 안에 렌더링되는 내용을 바꿀 수 있다. 사이드바의 Home 링크를 두 prop 모두 function으로 쓰면 다음과 같다.

TSX
<NavLink href="/" className={({ isActive }) => (isActive ? "nav-item font-bold" : "nav-item")}>
  {({ isActive }) => (
    <>
      <HomeIcon filled={isActive} />
      Home
    </>
  )}
</NavLink>

function classNamefont-bold modifier를 바꾸고, function children은 아이콘을 outline과 filled 사이에서 바꾼다. consumer는 필요에 따라 function form을 하나만 쓰거나, 둘 다 쓰거나, 아예 쓰지 않을 수 있다. static class나 plain text만 필요한 경우 helper가 함수가 아닌 값을 그대로 반환하므로 기존 방식도 유지된다.

isPending 추가하기

React Router의 NavLink는 destination route가 loading 중일 때 true가 되는 isPending도 노출한다. 이를 추가하는 한 가지 방법은 useTransitionrouter.push()를 쓰는 것이다. 하지만 그러면 <Link>의 click handler를 덮어쓰고, modifier key 감지, scroll restoration, view transitions 같은 것들을 다시 구현해야 한다.

Next.js에는 더 나은 선택지가 있다. useLinkStatus다. 이 hook은 click을 intercept하지 않고 <Link> children 안에서 pending state를 native하게 추적한다. 단점은 <Link> 안에 렌더링되는 컴포넌트에서 호출해야 한다는 점이다. 그래서 className에는 isPending을 노출할 수 없지만, children render prop에는 노출할 수 있다. 꽤 괜찮은 trade-off다. className은 여전히 isActive를 받고, children은 둘 다 받는다.

링크 status를 읽고 children을 resolve하는 작은 inner component를 추가한다.

TSX
import { useLinkStatus } from "next/link";

function NavLinkContent({ isActive, children }) {
  const { pending } = useLinkStatus();
  return <>{resolve(children, { isActive, isPending: pending })}</>;
}

main NavLinkclassName에는 { isActive }만 넘기고, childrenNavLinkContent에 맡긴다.

TSX
export function NavLink({ href, className, children, ...rest }) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      className={resolve(className, { isActive })}
      {...rest}
    >
      <NavLinkContent isActive={isActive}>{children}</NavLinkContent>
    </Link>
  );
}

이제 consumer는 children render prop에서 isPending을 사용할 수 있다.

TSX
<NavLink href="/" className={({ isActive }) => (isActive ? "nav-item font-bold" : "nav-item")}>
  {({ isActive, isPending }) => (
    <>
      <HomeIcon filled={isActive} />
      Home
      {isPending && <Spinner className="ml-2 h-4 w-4" />}
    </>
  )}
</NavLink>

isPending이 실제로 얼마나 유용한지는 destination route가 어떻게 구성되어 있는지에 따라 달라진다. page의 느린 부분이 Suspense boundary 뒤에 있다면 transition은 static shell이 렌더링되는 즉시 resolve되고 isPending은 거의 바로 꺼진다. dynamic data를 위쪽 boundary 없이 읽는 route에서 주로 눈에 띌 것이다. React Router가 원래 NavLink API에서 isPending을 중요하게 만든 이유는 route loader가 전체 navigation을 막았기 때문이다. App Router에서는 Suspense가 그 역할을 많이 가져가므로 isPending은 덜 필수적이다. 그래도 streaming을 아직 도입하지 않은 route에서는 노출해두면 유용하다.

Nested Route를 위한 prefix matching

top-level link에는 exact equality가 동작하지만, /search 링크는 보통 /search?q=react에서도 active 상태로 남아야 한다. prefix matching을 기본으로 하고, exact로 opt-out할 수 있게 만들 수 있다. 또한 /는 항상 exact로 처리해야 한다. 모든 pathname은 /로 시작하기 때문에 prefix matching을 하면 Home이 모든 route에서 active가 되기 때문이다.

TSX
export function NavLink({ href, exact, ...rest }) {
  const pathname = usePathname();
  const target = href.toString();
  const isActive = (exact || target === "/")
    ? pathname === target
    : pathname === target || pathname.startsWith(`${target}/`);

  // ...
}

이제 /search/search?q=react에서도 active로 남고, /는 정확히 home route에서만 match된다.

aria-current로 active 링크 표시하기

nav link는 aria-current="page"의 대표적인 사용 사례다. 현재 페이지를 assistive tech에 알려주고, 같은 attribute를 스타일링 기준으로 삼을 수도 있다. 이렇게 하면 시각적 상태와 assistive-tech 상태가 서로 어긋나는 일을 줄일 수 있다.

TSX
<Link
  href={href}
  aria-current={isActive ? "page" : undefined}
  className={resolveClassName(className, { isActive })}
  {...rest}
>
  <NavLinkContent isActive={isActive}>{children}</NavLinkContent>
</Link>

plain CSS에서는 attribute selector를 직접 사용할 수 있다.

CSS
.nav-item[aria-current="page"] {
  font-weight: 600;
  color: var(--accent);
}

Tailwind에서는 aria- variant로 같은 일을 할 수 있다.

TSX
<NavLink
  href="/"
  className="aria-[current=page]:font-semibold aria-[current=page]:text-accent"
>
  Home
</NavLink>

render prop approach를 선호하는 consumer를 위해 isActive는 여전히 classNamechildren 양쪽에서 사용할 수 있다. 두 방식을 섞어도 된다.

TypeScript 추가하기

컴포넌트는 동작하지만, TypeScript에서는 render prop shape이 type-check되고, consumer가 next/link가 받는 모든 prop에 대해 autocomplete를 유지하며, typedRoutes가 켜졌을 때 href가 Next.js의 statically typed 링크들로 검증되기를 원한다. 몇 가지 type만 있으면 된다.

TSX
import type { Route } from "next";

type ActiveProps = { isActive: boolean };
type RenderProps = ActiveProps & { isPending: boolean };
type Renderable<T> = T | ((props: RenderProps) => T);

type Props<T extends string> = Omit<
  React.ComponentProps<typeof Link>,
  "href" | "className" | "children"
> & {
  href: Route<T> | URL;
  className?: string | ((props: ActiveProps) => string | undefined);
  children?: Renderable<React.ReactNode>;
  exact?: boolean;
};

className prop은 { isActive }만 받는 함수를 허용한다. isPendinguseLinkStatus를 통해 <Link> children 안에서만 사용할 수 있기 때문이다. children prop은 Renderable type을 통해 전체 { isActive, isPending }을 받는다. Props type은 React.ComponentProps<typeof Link>에서 next/link의 prop을 가져오고, 우리가 다시 정의하는 href, className, childrenOmit한다. 그래서 consumer는 prefetch, replace, transitionTypes, event handler, 그 밖에 Link가 받는 prop의 autocomplete를 계속 얻는다.

href: Route<T> | URL generic은 Next.js docs가 Link를 wrapping할 때 권장하는 pattern과 맞다. typedRoutes가 켜져 있으면 잘못된 href가 compile time에 잡히고, 꺼져 있으면 Route<T>는 일반 string처럼 동작한다.

prop shape이 두 개이므로 resolve helper도 두 개가 필요하다.

TSX
function resolve<T>(value: Renderable<T> | undefined, props: RenderProps) {
  return typeof value === "function"
    ? (value as (p: RenderProps) => T)(props)
    : value;
}

function resolveClassName(
  value: string | ((props: ActiveProps) => string | undefined) | undefined,
  props: ActiveProps,
) {
  return typeof value === "function" ? value(props) : value;
}

컴포넌트 signature는 href에 대해 generic이 된다.

TSX
export function NavLink<T extends string>({
  href,
  className,
  children,
  exact,
  ...rest
}: Props<T>) {
  // ...
}

타입 계층은 여기까지다. runtime code는 그대로 두고, TypeScript가 확인할 수 있는 shape만 준 것이다.

모두 합치면 하나의 파일에 이런 컴포넌트가 된다.

TSX
// app/components/nav-link.tsx
"use client";

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

type ActiveProps = { isActive: boolean };
type RenderProps = { isActive: boolean; isPending: boolean };
type Renderable<T> = T | ((props: RenderProps) => T);

type Props<T extends string> = Omit<
  React.ComponentProps<typeof Link>,
  "href" | "className" | "children"
> & {
  href: Route<T> | URL;
  className?: string | ((props: ActiveProps) => string | undefined);
  children?: Renderable<React.ReactNode>;
  exact?: boolean;
};

function checkActive(pathname: string, href: string, exact?: boolean) {
  if (exact || href === '/') return pathname === href;
  return pathname === href || pathname.startsWith(`${href}/`);
}

function resolve<T>(value: Renderable<T> | undefined, props: RenderProps) {
  return typeof value === "function"
    ? (value as (p: RenderProps) => T)(props)
    : value;
}

function resolveClassName(
  value: string | ((props: ActiveProps) => string | undefined) | undefined,
  props: ActiveProps,
) {
  return typeof value === "function" ? value(props) : value;
}

export function NavLink<T extends string>({
  href,
  className,
  children,
  exact,
  ...rest
}: Props<T>) {
  const pathname = usePathname();
  const isActive = checkActive(pathname, href.toString(), exact);

  return (
    <Link
      href={href}
      aria-current={isActive ? "page" : undefined}
      className={resolveClassName(className, { isActive })}
      {...rest}
    >
      <NavLinkContent isActive={isActive}>{children}</NavLinkContent>
    </Link>
  );
}

function NavLinkContent({
  isActive,
  children,
}: {
  isActive: boolean;
  children?: Renderable<React.ReactNode>;
}) {
  const { pending } = useLinkStatus();
  return <>{resolve(children, { isActive, isPending: pending })}</>;
}

사이드바에서는 이렇게 사용할 수 있다. 컴포넌트가 aria-current를 직접 설정하므로, active state는 plain className string으로도 스타일링할 수 있다. render-prop form이 필요한 유일한 부분은 children이다. 여기서 isActive를 보고 아이콘을 바꾼다.

TSX
// app/layout.tsx
<nav>
  <NavLink href="/" exact className="nav-item aria-[current=page]:font-bold">
    {({ isActive }) => (
      <>
        <HomeIcon filled={isActive} />
        Home
      </>
    )}
  </NavLink>
  <NavLink href="/search" className="nav-item aria-[current=page]:font-bold">
    {({ isActive }) => (
      <>
        <SearchIcon filled={isActive} />
        Search
      </>
    )}
  </NavLink>
  <ProfileLink />
</nav>

Home 링크의 exact prop은 /가 모든 route에 prefix match되는 일을 막는다.

주의할 점이 하나 있다. function className 또는 function children은 serializable하지 않다. 그래서 server-client boundary를 넘겨 전달할 수 없다. layout이 Server Component라면 render-prop form을 inline으로 사용할 수 없다. 해결책은 function을 내부에 보관하는 wrapper Client Component를 추출하고, server에서는 serializable props(href, 아이콘, label)만 넘기는 것이다. plain string className과 static children만 필요하다면 이 문제는 없다. Server Component에서 NavLink를 직접 사용할 수 있다.

useSelectedLayoutSegments 대안

지금까지는 모두 usePathname()으로 링크의 href와 match했다. 똑같이 유효한 접근으로 useSelectedLayoutSegments()가 있다. 이 hook은 nearest layout 기준의 active route segments를 준다. pathname 문자열 대신 segment 배열을 비교하는 방식이다.

같은 NavLink를 segment 기반으로 만들면 다음과 같다.

TSX
"use client";

import Link from "next/link";
import { useSelectedLayoutSegments } from "next/navigation";

function NavLink({ href, ...rest }) {
  const segments = useSelectedLayoutSegments();
  const want = href.toString().split("/").filter(Boolean);
  const isActive = want.length === segments.length && want.every((s, i) => s === segments[i]);

  return (
    <Link
      {...rest}
      href={href}
      aria-current={isActive ? "page" : undefined}
    />
  );
}

두 방식 모두 유효하다. trade-off는 다음과 같다.

앱에 맞는 방식을 고르면 된다. inline script, cacheComponents를 위한 Suspense split, useLinkStatus는 두 접근 모두에서 같은 방식으로 적용할 수 있다.

첫 paint에서 flicker 막기

active class는 usePathname()이나 useSelectedLayoutSegments() 같은 client hook에 의존하며, hydration 중에 resolve된다. static prerender 중에는 active route를 아직 알 수 없으므로 아무 link도 highlight되지 않는다. active style은 React가 hydrate된 뒤에야 나타나고, 그 사이 짧은 flash가 생긴다.

이를 HTML parsing 중, browser paint 전에 실행되는 inline script로 해결할 수 있다. Next.js가 theme나 date 같은 값에서 hydration 전 flash를 막는 방식과 같은 pattern이고, Ethan Niser의 “A Clock That Doesn’t Snap”에서 다루는 문제와 같은 종류다. script는 location.pathname을 읽고, matching되는 nav link에 aria-current="page"를 설정한다. Tailwind에서 aria-[current=page]:로 스타일링하고 있다면 이것만으로 충분하다.

script가 nav 링크를 찾을 수 있도록 각 <Link>data-navlink-href attribute를 추가한다. script는 그 attribute를 가진 모든 element를 순회하고, hreflocation.pathname과 비교해 match되는 경우 aria-current를 설정한다.

TSX
export function NavLinkScript() {
  const html = `(function(){
  var p = location.pathname;
  document.querySelectorAll('[data-navlink-href]').forEach(function(el) {
    var href = el.getAttribute('data-navlink-href');
    var exact = el.hasAttribute('data-navlink-exact');
    var active = (exact || href === '/')
      ? p === href
      : (p === href || p.startsWith(href + '/'));
    if (active) el.setAttribute('aria-current', 'page');
    else el.removeAttribute('aria-current');
  });
})()`;

  return (
    <script
      type={typeof window === 'undefined' ? 'text/javascript' : 'text/plain'}
      suppressHydrationWarning
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

client에서는 script type이 text/plain으로 바뀌므로 initial page load에서만 실행된다. soft navigation에서는 React가 active state를 평소처럼 처리한다.

script가 React hydration 전에 aria-current를 설정하므로, NavLink<Link>에도 data-navlink-hrefsuppressHydrationWarning을 추가해야 한다.

TSX
<Link
  href={href}
  aria-current={isActive ? "page" : undefined}
  className={resolveClassName(className, { isActive })}
  data-navlink-href={href.toString()}
  data-navlink-exact={exact || undefined}
  suppressHydrationWarning
  {...rest}
>

<NavLinkScript />는 root layout의 <body> 끝, 다른 content 뒤에 렌더링한다. streaming이 있을 때 resolved Suspense chunk는 도착하는 대로 $RC script를 통해 page에 swap된다. seed script가 너무 일찍 실행되면 나중에 streamed content로 교체될 element에 aria-current를 설정하게 된다. 마지막에 두면 script가 읽는 시점에 nav 링크가 최종 상태에 있다.

Cache Components는 App Router가 향하는 방향이며, partial prerendering과 'use cache' 같은 기능을 사용하기 위해 opt-in하고 싶은 기능이다. cacheComponents가 활성화되면 dynamic data를 읽는 컴포넌트는 Suspense boundary 뒤에 있어야 한다. boundary 밖의 모든 것은 static shell의 일부가 되어 prerender되고 즉시 제공될 수 있다.

next.config.ts에서 cacheComponents를 켜보자.

TS
// next.config.ts
const config: NextConfig = {
  cacheComponents: true,
};

static route는 여전히 잘 렌더링된다. 하지만 /drop/[id]로 이동하면 usePathname을 가리키는 missing-Suspense-boundary error가 발생한다. nav는 root layout에 있고 모든 route에서 공유되므로, dynamic route에서도 usePathname()이 실행된다. cacheComponents가 켜져 있으면 dynamic param이 있는 route에서 usePathname()은 dynamic API처럼 취급된다. generateStaticParams로 prerender하지 않는 한, Suspense boundary 없이 dynamic value를 읽으면 runtime error가 난다.

어딘가에 Suspense boundary가 필요하다. 이 과정에서 앞서 무시했던 문제도 드러난다. ProfileLink는 async Server Component이고, 그 주위에 boundary가 없다면 handle이 resolve될 때까지 layout 전체가 막힌다. 이것도 감싸야 한다.

가장 먼저 떠오르는 해결책은 nav 전체를 감싸는 것이다.

TSX
// app/layout.tsx
<Suspense fallback={<NavSkeleton />}>
  <nav>
    <NavLink href="/" exact /* ... */>{/* HomeIcon + Home */}</NavLink>
    {/* same for /search */}
  </nav>
</Suspense>

동작은 한다. 하지만 usePathname()이 resolve될 때까지 nav 전체가 skeleton으로 바뀐다. skeleton의 dimension이 실제 nav와 맞지 않으면 real nav가 들어올 때 layout shift가 생긴다. 링크마다 개별적으로 감싸면 범위를 줄일 수 있다.

TSX
// app/layout.tsx
<nav>
  <Suspense fallback={<span className="nav-item opacity-50"><HomeIcon /> Home</span>}>
    <NavLink href="/" exact /* ... */>{/* HomeIcon + Home */}</NavLink>
  </Suspense>
  {/* same for /search */}
</nav>

이 방식이 더 낫다. active 스타일링만 지연되고, 아이콘과 label은 즉시 보인다. 그래도 fallback은 실제 link와 같은 size여야 한다. 그렇지 않으면 링크마다 작은 CLS flicker가 생긴다. 게다가 consumer는 모든 layout에서 fallback content를 중복하고 wrapping도 반복해야 한다. 이 boundary를 컴포넌트 내부로 밀어 넣으면 더 좋아진다.

컴포넌트를 outer NavLink와 inner NavLinkInner로 나눌 수 있다. outer NavLinkSuspense boundary를 렌더링하고, NavLinkInnerusePathname()을 읽는다. fallback과 resolved tree가 모두 공유 NavLinkShell을 통과하므로, boundary가 resolve될 때 달라지는 것은 isActive prop뿐이다.

TSX
// app/components/nav-link.tsx
"use client";

// ...imports, types, checkActive, resolve, resolveClassName (same as before)

export function NavLink(props) {
  return (
    <Suspense fallback={<NavLinkShell {...props} isActive={false} />}>
      <NavLinkInner {...props} />
    </Suspense>
  );
}

function NavLinkInner(props) {
  const pathname = usePathname();
  const isActive = checkActive(pathname, props.href.toString(), props.exact);
  return <NavLinkShell {...props} isActive={isActive} />;
}

function NavLinkShell({ href, className, children, isActive, exact, ...rest }) {
  return (
    <Link
      href={href}
      aria-current={isActive ? "page" : undefined}
      className={resolveClassName(className, { isActive })}
      suppressHydrationWarning
      {...rest}
    >
      <PendingIndicator isActive={isActive}>{children}</PendingIndicator>
    </Link>
  );
}

function PendingIndicator({ isActive, children }) {
  const { pending } = useLinkStatus();
  return <>{resolve(children, { isActive, isPending: pending })}</>;
}

export function NavLinkSkeleton({ children, className }) {
  return (
    <span aria-hidden className={`text-gray opacity-50 ${className ?? ""}`}>
      {children}
    </span>
  );
}

fallback은 inactive 상태로 렌더링되므로, active 스타일링은 boundary가 client에서 resolve된 뒤에야 나타난다. 앞에서 본 inline script가 이 문제를 처리한다. HTML parse 중에 aria-current를 설정해 첫 paint에서도 active style이 올바르게 보이도록 만든다.

또한 async Server Component인 ProfileLink를 위해 NavLinkSkeleton도 계속 export한다. 이 경우 여전히 outer Suspense boundary가 필요하다.

두 static link에는 더 이상 링크별 wrapper가 필요 없다. 그렇다면 ProfileLink는 어떻게 할까? 이 컴포넌트는 async Server Component이므로 여전히 바깥쪽 Suspense boundary가 필요하다. fallback 없이 <Suspense>로 감쌀 수도 있지만, handle이 로드되는 동안 아무것도 렌더링되지 않아 링크가 나중에 튀어나오며 nav가 흔들린다. 대신 export한 NavLinkSkeleton을 fallback으로 사용하고, real link와 같은 base layout class를 공유해 dimension을 맞춘다.

TSX
// before
<nav>
  <Suspense fallback={<span className="nav-item opacity-50"><HomeIcon /> Home</span>}>
    <NavLink href="/" exact /* ... */>{/* HomeIcon + Home */}</NavLink>
  </Suspense>
  {/* same for /search */}
  <ProfileLink />
</nav>

// after
<nav>
  <NavLink href="/" exact /* ... */>{/* HomeIcon + Home */}</NavLink>
  <NavLink href="/search" /* ... */>{/* SearchIcon + Search */}</NavLink>
  <Suspense fallback={<NavLinkSkeleton className="nav-item"><UserIcon /> Profile</NavLinkSkeleton>}>
    <ProfileLink />
  </Suspense>
</nav>

consumer는 static 링크의 Suspense를 더 이상 신경 쓰지 않아도 되고, async 링크 하나만 layout-stable fallback을 가진다.

대부분의 앱에서는 여기서 멈춰도 좋다. 컴포넌트는 self-contained하다. 어디에 NavLink를 두든 cacheComponents 아래에서 동작한다. 첫 paint는 모든 링크를 inactive 상태의 Suspense fallback으로 보여주고, boundary가 resolve되면 active 스타일링이 적용된다. fallback이 같은 layout을 가진 실제 <Link>이므로 flash나 layout shift가 없다.

render-prop flexibility가 필요 없고 cacheComponents 아래에서 CSS 기반 active 스타일링만 원한다면 더 단순한 선택지도 있다. <Link> 안에 작은 indicator component를 렌더링해 data-activedata-pending attribute를 설정하고, parent를 Tailwind의 has-data-* variant로 스타일링하는 방식이다.

TSX
"use client";

import Link, { useLinkStatus } from "next/link";
import { usePathname } from "next/navigation";

function ActiveLinkIndicator({ href }: { href: string }) {
  const pathname = usePathname();
  const { pending } = useLinkStatus();
  const isActive = pathname === href || pathname.startsWith(`${href}/`);
  return <span hidden data-active={isActive || undefined} data-pending={pending || undefined} />;
}

// usage
<Link href="/search" className="has-data-active:font-bold has-data-pending:opacity-50">
  <Suspense>
    <ActiveLinkIndicator href="/search" />
  </Suspense>
  <SearchIcon /> Search
</Link>

usePathname()cacheComponents 아래에서 suspend되므로 indicator에는 여전히 Suspense boundary가 필요하다. 하지만 fallback은 비어 있어도 되므로 아무것도 flash되지 않는다. 모든 스타일링은 CSS로 처리된다.

Gotchas

앱이 next.configProxy file에서 rewrites를 사용하고 있고 usePathname() 버전을 쓴다면 값이 잘못될 수 있다. usePathname()은 server에서는 source path를 반환하고, browser URL은 rewritten path일 수 있다. 그러면 server가 잘못된 active state를 렌더링하고, client hydration 때 올바르게 수정하면서 hydration mismatch와 visible flash가 함께 생긴다. useSelectedLayoutSegments() 버전은 이 문제가 없다. segment는 URL이 아니라 React router state에서 오기 때문이다.

문서에서는 pathname read를 mount 이후로 미루는 방식을 권장한다. 이를 hook으로 감쌀 수 있다.

TSX
"use client";

import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";

export function useClientPathname(): string {
  const pathname = usePathname();
  const [clientPathname, setClientPathname] = useState("");
  useEffect(() => {
    setClientPathname(pathname);
  }, [pathname]);
  return clientPathname;
}

이 hook은 server와 첫 client render에서는 ""을 반환하고, mount 후 실제 pathname을 반환한다. NavLink에서 usePathname()useClientPathname()으로 바꾸면 mismatch는 사라진다.

하지만 여전히 flash는 남는다. effect가 실행되기 전까지 모든 링크가 inactive로 렌더링되기 때문이다. 앞에서 본 inline script를 이미 사용하고 있다면 이 경우도 함께 커버된다. script는 usePathname()을 거치지 않고 location.pathname을 직접 읽기 때문이다.

결론

우리는 hardcoded active class에서 시작해 꽤 많은 단계를 거쳤다. render-prop pattern, pending state를 위한 useLinkStatus, prefix matching, aria-current, TypeScript, flicker-free first paint를 위한 inline script, cacheComponents를 위한 Suspense boundary까지 다뤘다. 또한 active route를 읽는 두 가지 접근인 usePathname()useSelectedLayoutSegments()도 비교했다. 하나의 컴포넌트치고는 많은 내용이지만, 각 조각은 production app에서 실제로 마주치는 문제를 해결한다.

물론 이 모든 것이 항상 필요하지는 않다. navbar component에서 단순히 usePathname()을 호출하는 것만으로도 훌륭한 시작점이다. 하지만 render props, pending state, cacheComponents, flicker-free first paint까지 다루는 재사용 가능한 NavLink 하나를 원한다면 이제 어떻게 만들 수 있는지 알게 되었다. 두 구현은 next16-social-media (live demo)에서 확인할 수 있다. usePathname 버전useSelectedLayoutSegments 버전이 모두 있다.

이 글이 도움이 되었기를 바란다. 질문이나 의견이 있다면 Aurora Scharff에게 알려주고, 더 많은 업데이트를 보고 싶다면 그녀의 BlueskyX를 팔로우하면 된다. 그럼 모두들 Happy coding! 🚀

댓글

댓글을 불러오는 중...