قسمت ششم - ساخت هدر، منو و فوتر در Next.js
ما قبلاً یک نسخه ساده از Header و Footer را ساخته بودیم. در این پست همان ساختار قبلی را ارتقا میدهیم
ساخت هدر، منو و فوتر حرفهای در Next.js
در پستهای قبلی، هم با App Router در Next.js 16 آشنا شدیم، هم Tailwind CSS را به پروژه اضافه کردیم، و هم صفحه اصلی پرتفولیو را با sectionهای جداگانه ساختیم.
حالا وقت آن است که بخشهای مشترک سایت را حرفهایتر کنیم: Header، Navigation Menu و Footer.
نکته مهم اینجاست که ما قبلاً یک نسخه ساده از Header و Footer را ساخته بودیم. در این پست قرار نیست از صفر و بدون زمینه شروع کنیم؛ بلکه همان ساختار قبلی را ارتقا میدهیم تا:
- ناوبری سایت منظمتر شود
- لینکهای داخلی با
Linkبهشکل درست استفاده شوند - لینک فعال مشخص باشد
- در موبایل منوی جداگانه داشته باشیم
- فوتر کاملتر و حرفهایتر شود
- لینکهای اجتماعی هم به انتهای سایت اضافه شوند
ساختار ناوبری سایت
قبل از نوشتن کد، بهتر است مشخص کنیم هدر ما قرار است چه لینکهایی داشته باشد.
با توجه به ساختار پروژهای که تا اینجا ساختهایم، ناوبری عمومی سایت میتواند شامل این مسیرها باشد:
//about/projects/blog/contact
و در کنار آن، یک لینک جدا برای داشبورد هم میتوانیم داشته باشیم:
/dashboard
از آنجا که در پست مربوط به Routing گفتیم مسیرها بر اساس ساختار فایلهای داخل app/ ساخته میشوند، این منو باید با همان routeهایی هماهنگ باشد که قبلاً تعریف کردهایم.
برای تمیزتر شدن کد، بهتر است آیتمهای منو را داخل یک آرایه قرار بدهیم.
// components/layout/site-nav-links.ts
export const siteNavLinks = [
{ href: '/', label: 'Home' },
{ href: '/about', label: 'About' },
{ href: '/projects', label: 'Projects' },
{ href: '/blog', label: 'Blog' },
{ href: '/contact', label: 'Contact' },
]
این کار چند مزیت دارد:
- لینکها در یک نقطه متمرکز میشوند
- برای نسخه دسکتاپ و موبایل از یک داده مشترک استفاده میکنیم
- نگهداری و توسعه منو راحتتر میشود
استفاده از Link
در آموزش Routing قبلی گفتیم که برای navigation داخلی در Next.js باید از Link استفاده کنیم، نه از تگ معمولی a.
چرا؟
- جابهجایی بین صفحهها سریعتر میشود
- reload کامل صفحه اتفاق نمیافتد
- layoutهای مشترک حفظ میشوند
- Next.js میتواند routeها را prefetch کند
پس در Header و Footer، هرجا لینک داخلی داریم، از Link استفاده میکنیم.
بیایید اول یک نسخه تمیزتر از Header بسازیم.
ساخت Header اصلی
از آنجا که برای active state و منوی موبایل به hookهای کلاینت نیاز داریم، Header ما یک Client Component خواهد بود.
// components/layout/header.tsx
'use client'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
import { useState } from 'react'
import { Container } from './container'
import { siteNavLinks } from './site-nav-links'
import { NavLink } from './nav-link'
export function Header() {
const [isOpen, setIsOpen] = useState(false)
return (
<header className="sticky top-0 z-50 border-b border-slate-800 bg-slate-950/80 backdrop-blur">
<Container>
<div className="flex h-16 items-center justify-between">
<Link
href="/"
className="text-lg font-semibold tracking-tight text-slate-50"
>
Matin
</Link>
<nav className="hidden items-center gap-6 md:flex">
{siteNavLinks.map((link) => (
<NavLink key={link.href} href={link.href}>
{link.label}
</NavLink>
))}
<Link
href="/dashboard"
className="inline-flex items-center justify-center rounded-md bg-cyan-500 px-3 py-2 text-xs font-semibold text-slate-950 transition hover:bg-cyan-400"
>
Dashboard
</Link>
</nav>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="inline-flex items-center justify-center rounded-md border border-slate-700 p-2 text-slate-200 transition hover:bg-slate-900 md:hidden"
aria-label={isOpen ? 'Close menu' : 'Open menu'}
aria-expanded={isOpen}
>
{isOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</button>
</div>
{isOpen ? (
<div className="border-t border-slate-800 py-4 md:hidden">
<nav className="flex flex-col gap-2">
{siteNavLinks.map((link) => (
<NavLink
key={link.href}
href={link.href}
onNavigate={() => setIsOpen(false)}
mobile
>
{link.label}
</NavLink>
))}
<Link
href="/dashboard"
onClick={() => setIsOpen(false)}
className="mt-2 inline-flex items-center justify-center rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-cyan-400"
>
Dashboard
</Link>
</nav>
</div>
) : null}
</Container>
</header>
)
}
در این نسخه:
- هدر با
sticky top-0به بالای صفحه میچسبد - در دسکتاپ، منو بهصورت افقی نمایش داده میشود
- در موبایل، دکمه باز/بسته کردن منو داریم
- از همان palette آبی تیرهای استفاده کردهایم که در دو پست قبلی ساختیم
active state
یکی از مهمترین بخشهای navigation حرفهای این است که کاربر بداند الان در کدام صفحه قرار دارد.
برای این کار باید active state داشته باشیم.
در App Router، برای گرفتن مسیر فعلی، از usePathname استفاده میکنیم:
import { usePathname } from 'next/navigation'
حالا بهتر است یک کامپوننت جدا برای لینکهای ناوبری بسازیم.
// components/layout/nav-link.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/cn'
type NavLinkProps = {
href: string
children: React.ReactNode
mobile?: boolean
onNavigate?: () => void
}
export function NavLink({
href,
children,
mobile = false,
onNavigate,
}: NavLinkProps) {
const pathname = usePathname()
const isActive =
href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(`${href}/`)
return (
<Link
href={href}
onClick={onNavigate}
className={cn(
'transition',
mobile
? 'rounded-md px-3 py-2 text-sm'
: 'text-sm font-medium',
isActive
? 'text-cyan-400'
: 'text-slate-300 hover:text-cyan-300'
)}
aria-current={isActive ? 'page' : undefined}
>
{children}
</Link>
)
}
منطق active state
این بخش مهم است:
const isActive =
href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(`${href}/`)
چرا اینطور نوشتیم؟
- برای صفحه اصلی، فقط وقتی مسیر دقیقاً
/باشد باید active شود - برای بقیه مسیرها:
- اگر مسیر دقیقاً برابر باشد، active است
- اگر در زیرمسیر همان بخش باشیم هم active است
مثال:
- اگر کاربر در
/blogباشد، لینک Blog active میشود - اگر کاربر در
/blog/nextjs-routingباشد، باز هم Blog active میماند - اگر کاربر در
/projects/my-appباشد، Projects active است
این رفتار برای سایت پرتفولیو و بلاگ کاملاً مناسب است.
یک utility کوچک برای className
در کد بالا از cn استفاده کردیم. اگر هنوز این utility را نداری، یک فایل ساده برای آن بساز:
// lib/cn.ts
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
این تابع کمک میکند کلاسها را تمیزتر و شرطیتر مدیریت کنیم.
منوی موبایل
در پستهای قبلی فقط یک navigation ساده داشتیم، اما در طراحی واقعی، منوی موبایل بسیار مهم است.
در نمایشگرهای کوچک، اگر همان منوی دسکتاپ را نگه داریم، هدر شلوغ و شکسته میشود.
برای همین:
- منوی دسکتاپ را با
md:flexنمایش میدهیم - در موبایل آن را
hiddenمیکنیم - یک دکمه hamburger اضافه میکنیم
- با
useStateباز و بسته شدن منو را کنترل میکنیم
نکات مهم در منوی موبایل
-
Accessibility
- از
aria-labelاستفاده کردیم - از
aria-expandedبرای نشان دادن وضعیت منو استفاده کردیم
- از
-
بسته شدن منو بعد از کلیک روی لینک
- در
NavLinkیک prop به نامonNavigateگذاشتیم - در موبایل، بعد از کلیک روی هر لینک،
setIsOpen(false)اجرا میشود
- در
-
استفاده از داده مشترک
- همان
siteNavLinksهم در دسکتاپ و هم در موبایل استفاده شده است
- همان
این باعث میشود منو در دو جا از یک منبع داده مشترک تغذیه کند.
ساخت فوتر
در پست قبلی یک Footer ساده داشتیم. حالا میخواهیم آن را کمی حرفهایتر کنیم تا:
- اطلاعات کلی سایت را نشان دهد
- لینکهای مهم را داشته باشد
- لینکهای اجتماعی را نمایش دهد
نمونه پیشنهادی:
// components/layout/footer.tsx
import Link from 'next/link'
import { Container } from './container'
const footerLinks = [
{ href: '/', label: 'Home' },
{ href: '/projects', label: 'Projects' },
{ href: '/blog', label: 'Blog' },
{ href: '/about', label: 'About' },
{ href: '/contact', label: 'Contact' },
]
const socialLinks = [
{ href: 'https://github.com/your-username', label: 'GitHub' },
{ href: 'https://linkedin.com/in/your-username', label: 'LinkedIn' },
{ href: 'mailto:hello@example.com', label: 'Email' },
]
export function Footer() {
return (
<footer className="mt-16 border-t border-slate-800 bg-slate-950">
<Container>
<div className="grid gap-10 py-10 md:grid-cols-3">
<div>
<h2 className="text-base font-semibold text-slate-50">Matin</h2>
<p className="mt-3 max-w-xs text-sm leading-6 text-slate-400">
Personal portfolio, blog, and developer dashboard built with
Next.js, Prisma, and Tailwind CSS.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-slate-100">Navigation</h3>
<ul className="mt-3 space-y-2">
{footerLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-slate-400 transition hover:text-cyan-300"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-slate-100">Connect</h3>
<ul className="mt-3 space-y-2">
{socialLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
target="_blank"
rel="noreferrer"
className="text-sm text-slate-400 transition hover:text-cyan-300"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-slate-800 py-4 text-xs text-slate-500">
<p>© {new Date().getFullYear()} Matin. All rights reserved.</p>
</div>
</Container>
</footer>
)
}
این فوتر سه بخش اصلی دارد:
- معرفی کوتاه برند یا شخص
- لینکهای ناوبری داخلی
- لینکهای ارتباطی و اجتماعی
لینکهای اجتماعی
در Footer، لینکهای اجتماعی معمولاً external هستند، پس اینجا از a استفاده میکنیم، نه Link.
قاعدهای که در آموزش Routing گفتیم هنوز برقرار است:
- لینک داخلی $\rightarrow$
Link - لینک خارجی $\rightarrow$
a
مثلاً:
<a
href="https://github.com/your-username"
target="_blank"
rel="noreferrer"
>
GitHub
</a>
چرا target="_blank" و rel="noreferrer"؟
target="_blank"لینک را در تب جدید باز میکندrel="noreferrer"از نظر امنیت و حریم خصوصی برای لینکهای خارجی بهتر است
اگر بخواهیم، بعدا میتوانیم این بخش را با lucide-react حرفهایتر کنیم و بهجای متن ساده، آیکون GitHub، LinkedIn و Mail هم بگذاریم.
مثلاً:
import { Github, Linkedin, Mail } from 'lucide-react'
اما برای این مرحله، متن ساده هم کاملاً کافی و تمیز است.
اتصال همه چیز به Layout
از آنجا که در پست قبلی Header و Footer را در app/layout.tsx قرار داده بودیم، فقط کافی است نسخه جدید آنها را جایگزین کنیم.
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'
import { Header } from '@/components/layout/header'
import { Footer } from '@/components/layout/footer'
import { Container } from '@/components/layout/container'
export const metadata: Metadata = {
title: 'Matin Portfolio',
description: 'Personal portfolio, blog, and dashboard built with Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="min-h-screen bg-slate-950 text-slate-100">
<Header />
<main className="min-h-[calc(100vh-4rem-3rem)] py-8">
<Container>{children}</Container>
</main>
<Footer />
</body>
</html>
)
}
این همان ساختاری است که در پست قبلی داشتیم، فقط حالا Header و Footer حرفهایتر شدهاند.
ساختار نهایی فایلها
بعد از این بخش، ساختار مربوط به layout میتواند چیزی شبیه این باشد:
app/
├── layout.tsx
├── page.tsx
├── about/
│ └── page.tsx
├── blog/
│ └── page.tsx
├── contact/
│ └── page.tsx
└── projects/
└── page.tsx
components/
├── layout/
│ ├── container.tsx
│ ├── footer.tsx
│ ├── header.tsx
│ ├── nav-link.tsx
│ └── site-nav-links.ts
└── sections/
├── about-snippet.tsx
├── cta-section.tsx
├── hero-section.tsx
└── skills-section.tsx
lib/
└── cn.ts
جمعبندی
در این پست، نسخه اولیه Header و Footer را که قبلاً ساخته بودیم، به یک نسخه کاملتر و حرفهایتر ارتقا دادیم.
چیزهایی که اضافه کردیم:
- ساختار بهتر برای لینکهای ناوبری
- استفاده درست از
Linkبرای routeهای داخلی - active state با
usePathname - منوی موبایل با
useState - Footer حرفهایتر با لینکهای داخلی و اجتماعی