Circle IT Logo
Technology

Cara Membuat Komponen Active Link yang Reusable di Next.js App Router

Pelajari cara membuat komponen Active Link yang reusable untuk menampilkan status aktif navigation links di Next.js App Router. Lengkap dengan TypeScript dan best practices.

Pengenalan

Active Link adalah komponen penting dalam navigation yang menunjukkan halaman mana yang sedang aktif. Di Next.js App Router, kita perlu membuat solusi custom karena tidak ada built-in active state seperti di Pages Router.

  • User Experience: User tahu posisi mereka di website
  • Accessibility: Membantu screen readers identify current page
  • Visual Feedback: Clear indication dari navigation state
  • SEO: Proper semantic HTML untuk better crawling

Setup Project

Pastikan Anda menggunakan Next.js 13+ dengan App Router:

bash
npx create-next-app@latest my-app
cd my-app

Basic Implementation

tsx
// components/ActiveLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';

interface ActiveLinkProps {
  href: string;
  children: ReactNode;
}

export function ActiveLink({ href, children }: ActiveLinkProps) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}
    >
      {children}
    </Link>
  );
}

Usage

tsx
// app/layout.tsx
import { ActiveLink } from '@/components/ActiveLink';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <nav>
          <ActiveLink href="/">Home</ActiveLink>
          <ActiveLink href="/about">About</ActiveLink>
          <ActiveLink href="/contact">Contact</ActiveLink>
        </nav>
        {children}
      </body>
    </html>
  );
}

Advanced Implementation

tsx
// components/ActiveLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';

interface ActiveLinkProps {
  href: string;
  children: ReactNode;
  className?: string;
  activeClassName?: string;
  inactiveClassName?: string;
}

export function ActiveLink({
  href,
  children,
  className,
  activeClassName = 'text-blue-600 font-bold',
  inactiveClassName = 'text-gray-600',
}: ActiveLinkProps) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      className={cn(
        className,
        isActive ? activeClassName : inactiveClassName
      )}
    >
      {children}
    </Link>
  );
}

Untuk handle nested routes:

tsx
// components/ActiveLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';

interface ActiveLinkProps {
  href: string;
  children: ReactNode;
  className?: string;
  activeClassName?: string;
  exact?: boolean; 
}

export function ActiveLink({
  href,
  children,
  className,
  activeClassName = 'text-blue-600 font-bold',
  exact = false,
}: ActiveLinkProps) {
  const pathname = usePathname();
  
  const isActive = exact
    ? pathname === href
    : pathname.startsWith(href);

  return (
    <Link
      href={href}
      className={cn(
        className,
        isActive && activeClassName
      )}
    >
      {children}
    </Link>
  );
}

Usage untuk nested routes

tsx
<nav>
  <ActiveLink href="/" exact>Home</ActiveLink>
  <ActiveLink href="/blog">Blog</ActiveLink>
  {/* Will be active for /blog, /blog/post-1, etc */}
  <ActiveLink href="/about" exact>About</ActiveLink>
</nav>

Advanced Features

tsx
// components/ActiveLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';

interface ActiveLinkProps {
  href: string;
  children: ReactNode;
  icon?: ReactNode;
  activeIcon?: ReactNode;
  className?: string;
}

export function ActiveLink({
  href,
  children,
  icon,
  activeIcon,
  className,
}: ActiveLinkProps) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      className={cn(
        'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
        isActive
          ? 'bg-blue-50 text-blue-600 font-semibold'
          : 'text-gray-600 hover:bg-gray-50',
        className
      )}
    >
      {isActive && activeIcon ? activeIcon : icon}
      {children}
    </Link>
  );
}

Usage dengan Icons

tsx
import { Home, FileText, Mail } from 'lucide-react';

<nav>
  <ActiveLink 
    href="/" 
    icon={<Home />}
  >
    Home
  </ActiveLink>
  <ActiveLink 
    href="/blog" 
    icon={<FileText />}
  >
    Blog
  </ActiveLink>
  <ActiveLink 
    href="/contact" 
    icon={<Mail />}
  >
    Contact
  </ActiveLink>
</nav>
tsx
// components/AnimatedActiveLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { motion } from 'framer-motion';

interface AnimatedActiveLinkProps {
  href: string;
  children: ReactNode;
}

export function AnimatedActiveLink({ href, children }: AnimatedActiveLinkProps) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link href={href} className="relative">
      <motion.span
        className={isActive ? 'text-blue-600' : 'text-gray-600'}
        animate={{ scale: isActive ? 1.05 : 1 }}
        transition={{ duration: 0.2 }}
      >
        {children}
      </motion.span>
      
      {isActive && (
        <motion.div
          layoutId="activeIndicator"
          className="absolute -bottom-1 left-0 right-0 h-0.5 bg-blue-600"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: 0.3 }}
        />
      )}
    </Link>
  );
}

Complete Sidebar Example

tsx
// components/Sidebar.tsx
'use client';

import { ActiveLink } from './ActiveLink';
import {
  Home,
  FileText,
  Settings,
  Users,
  BarChart,
} from 'lucide-react';

const navItems = [
  { href: '/dashboard', label: 'Dashboard', icon: Home },
  { href: '/posts', label: 'Posts', icon: FileText },
  { href: '/users', label: 'Users', icon: Users },
  { href: '/analytics', label: 'Analytics', icon: BarChart },
  { href: '/settings', label: 'Settings', icon: Settings },
];

export function Sidebar() {
  return (
    <aside className="w-64 h-screen bg-white border-r border-gray-200">
      <div className="p-6">
        <h2 className="text-2xl font-bold text-gray-900">My App</h2>
      </div>
      
      <nav className="px-4 space-y-1">
        {navItems.map((item) => (
          <ActiveLink
            key={item.href}
            href={item.href}
            icon={<item.icon size={20} />}
          >
            {item.label}
          </ActiveLink>
        ))}
      </nav>
    </aside>
  );
}

Mobile Navigation

Mobile Nav with Active State

tsx
// components/MobileNav.tsx
'use client';

import { useState } from 'react';
import { ActiveLink } from './ActiveLink';
import { Menu, X } from 'lucide-react';

export function MobileNav() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="lg:hidden p-2"
      >
        {isOpen ? <X /> : <Menu />}
      </button>

      {isOpen && (
        <div className="fixed inset-0 z-50 bg-white lg:hidden">
          <div className="flex flex-col p-6 space-y-4">
            <ActiveLink href="/" onClick={() => setIsOpen(false)}>
              Home
            </ActiveLink>
            <ActiveLink href="/about" onClick={() => setIsOpen(false)}>
              About
            </ActiveLink>
            <ActiveLink href="/services" onClick={() => setIsOpen(false)}>
              Services
            </ActiveLink>
            <ActiveLink href="/contact" onClick={() => setIsOpen(false)}>
              Contact
            </ActiveLink>
          </div>
        </div>
      )}
    </>
  );
}

TypeScript Types

Complete TypeScript Implementation

tsx
// types/navigation.ts
export interface NavItem {
  href: string;
  label: string;
  icon?: React.ComponentType<{ size?: number }>;
  exact?: boolean;
}

// components/TypedActiveLink.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import type { NavItem } from '@/types/navigation';

interface TypedActiveLinkProps extends NavItem {
  className?: string;
  activeClassName?: string;
  children?: React.ReactNode;
}

export function TypedActiveLink({
  href,
  label,
  icon: Icon,
  exact = false,
  className,
  activeClassName,
  children,
}: TypedActiveLinkProps) {
  const pathname = usePathname();
  
  const isActive = exact
    ? pathname === href
    : pathname.startsWith(href);

  return (
    <Link
      href={href}
      className={cn(
        'flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all',
        isActive
          ? 'bg-blue-50 text-blue-600 font-semibold shadow-sm'
          : 'text-gray-600 hover:bg-gray-50',
        activeClassName,
        className
      )}
    >
      {Icon && <Icon size={20} />}
      {children || label}
    </Link>
  );
}

Testing

Unit Test Example

tsx
// __tests__/ActiveLink.test.tsx
import { render, screen } from '@testing-library/react';
import { usePathname } from 'next/navigation';
import { ActiveLink } from '@/components/ActiveLink';

jest.mock('next/navigation', () => ({
  usePathname: jest.fn(),
}));

describe('ActiveLink', () => {
  it('applies active class when pathname matches', () => {
    (usePathname as jest.Mock).mockReturnValue('/about');
    
    render(
      <ActiveLink href="/about">
        About
      </ActiveLink>
    );
    
    const link = screen.getByText('About');
    expect(link).toHaveClass('text-blue-600');
  });

  it('applies inactive class when pathname does not match', () => {
    (usePathname as jest.Mock).mockReturnValue('/');
    
    render(
      <ActiveLink href="/about">
        About
      </ActiveLink>
    );
    
    const link = screen.getByText('About');
    expect(link).toHaveClass('text-gray-600');
  });
});

Best Practices

  1. Use Client Components: Always add 'use client' directive
  2. Handle Edge Cases: Consider trailing slashes dan query parameters
  3. Accessibility: Include aria-current="page" untuk active links
  4. Performance: Memoize expensive calculations
  5. TypeScript: Use proper types untuk better DX

Accessibility Enhancement

tsx
export function AccessibleActiveLink({ href, children }: ActiveLinkProps) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      className={isActive ? 'active' : ''}
      aria-current={isActive ? 'page' : undefined}
      aria-label={`Navigate to ${children}`}
    >
      {children}
    </Link>
  );
}

Kesimpulan

Membuat reusable Active Link component di Next.js App Router memerlukan penggunaan usePathname hook dan proper component design. Dengan mengikuti patterns yang dijelaskan di artikel ini, Anda dapat create navigation yang user-friendly dan maintainable.

Resources