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.
Mengapa Active Link Penting?
- 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:
npx create-next-app@latest my-app
cd my-appBasic Implementation
1. Simple Active Link
// 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
// 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
2. Active Link with Custom Classes
// 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>
);
}3. Active Link with startsWith
Untuk handle nested routes:
// 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
<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
4. Active Link with Icons
// 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
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>5. Active Link with Animations
// 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>
);
}Sidebar Navigation
Complete Sidebar Example
// 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
// 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
// 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
// __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
- Use Client Components: Always add
'use client'directive - Handle Edge Cases: Consider trailing slashes dan query parameters
- Accessibility: Include
aria-current="page"untuk active links - Performance: Memoize expensive calculations
- TypeScript: Use proper types untuk better DX
Accessibility Enhancement
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.