قسمت بیست و ششم - SEO در Next.js
در این قسمت میخواهیم SEO پایه را در پروژه Next.js پیادهسازی کنیم؛ با استناد از روش رسمی Next.js در App Router.
#SEO در Next.js با Metadata، Open Graph و ساختار صفحات
در این قسمت میخواهیم SEO پایه را در پروژه Next.js پیادهسازی کنیم؛ با استناد از روش رسمی Next.js در App Router.
در پایان این قسمت، صفحات ما این قابلیتها را خواهند داشت:
titleاختصاصیdescriptionمناسب- metadata داینامیک برای پستها و پروژهها
- Open Graph برای نمایش بهتر هنگام share شدن لینک
- canonical URL
- تصویر مناسب برای شبکههای اجتماعی
- ساختار بهتر برای صفحات public پروژه
هدف این قسمت
تا اینجا ما یک پنل ادمین داریم که میتواند پروژه و پست بسازد. اما اگر صفحههای public ما metadata مناسبی نداشته باشند، موتورهای جستجو و شبکههای اجتماعی اطلاعات دقیقی از محتوا دریافت نمیکنند.
هدف این قسمت این است که برای بخشهای اصلی سایت، metadata استاندارد بسازیم.
مثلاً:
/
/blog
/blog/[slug]
/projects
/projects/[slug]
برای هرکدام از این صفحات، باید اطلاعات مناسب در head صفحه تولید شود.
SEO چیست و چرا مهم است؟
SEO یعنی بهینهسازی سایت برای موتورهای جستجو.
اما در پروژههای مدرن، SEO فقط مربوط به گوگل نیست. وقتی لینک سایت شما در پیامرسانها، شبکههای اجتماعی یا ابزارهای مختلف share میشود، همان metadata تعیین میکند که لینک چطور نمایش داده شود.
مثلاً اگر یک پست بلاگ را share کنید، بهتر است این موارد درست نمایش داده شوند:
- عنوان پست
- توضیح کوتاه
- تصویر کاور
- آدرس اصلی صفحه
- نام سایت
Next.js برای این کار API رسمی به نام Metadata API دارد.
Metadata در Next.js
در App Router، برای تعریف metadata دو روش اصلی داریم:
روش اول: metadata ثابت
برای صفحاتی که اطلاعاتشان ثابت است، از export کردن metadata استفاده میکنیم.
مثلاً صفحه اصلی یا صفحه لیست بلاگ.
روش دوم: metadata داینامیک
برای صفحاتی مثل:
/blog/[slug]
/projects/[slug]
باید metadata را بر اساس دیتابیس تولید کنیم. برای این کار از تابع generateMetadata استفاده میکنیم.
نکته مهم طبق داک رسمی Next.js:
metadata و generateMetadata فقط در Server Component پشتیبانی میشوند.
پس این کدها را نباید داخل Client Component بنویسیم.
مرحله اول: ساخت metadata اصلی سایت
ابتدا سراغ فایل root layout میرویم.
فایل:
app/layout.tsx
نمونه کد:
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
metadataBase: new URL('https://your-domain.com'),
title: {
default: 'Matin Portfolio',
template: '%s | Matin Portfolio',
},
description:
'A professional portfolio built with Next.js, React, Prisma, PostgreSQL, and Tailwind CSS.',
applicationName: 'Matin Portfolio',
authors: [{ name: 'Matin' }],
creator: 'Matin',
openGraph: {
type: 'website',
locale: 'en_US',
url: '/',
siteName: 'Matin Portfolio',
title: 'Matin Portfolio',
description:
'A professional portfolio built with Next.js, React, Prisma, PostgreSQL, and Tailwind CSS.',
},
twitter: {
card: 'summary_large_image',
title: 'Matin Portfolio',
description:
'A professional portfolio built with Next.js, React, Prisma, PostgreSQL, and Tailwind CSS.',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
metadataBase چیست؟
وقتی در metadata آدرسهای نسبی مثل /blog/my-post یا /og-image.jpg استفاده میکنیم، Next.js باید بداند دامنه اصلی سایت چیست.
برای همین از metadataBase استفاده میکنیم:
metadataBase: new URL('https://your-domain.com')
بعداً وقتی پروژه را deploy کردید، این مقدار باید با دامنه واقعی شما جایگزین شود.
اگر هنوز دامنه ندارید، میتوانید فعلاً دامنه لوکال یا آدرس Vercel را قرار دهید، اما برای نسخه نهایی بهتر است دامنه اصلی وارد شود.
title template در Next.js
در این بخش:
title: {
default: 'Matin Portfolio',
template: '%s | Matin Portfolio',
}
ما به Next.js میگوییم:
- اگر صفحه title اختصاصی نداشت، از
Matin Portfolioاستفاده کن - اگر صفحه title اختصاصی داشت، آن را داخل template قرار بده
مثلاً اگر صفحه بلاگ این title را بدهد:
title: 'Blog'
خروجی نهایی میشود:
Blog | Matin Portfolio
این کار باعث میشود title صفحات یکدست و حرفهای باشد.
مرحله دوم: metadata صفحه بلاگ
حالا برای صفحه لیست بلاگ metadata ثابت مینویسیم.
فایل:
app/blog/page.tsx
یا اگر
title: 'Blog',
app/blog/layout.tsx
نمونه:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Blog',
description:
'Read articles about Next.js, React, web development, and building modern full-stack applications.',
alternates: {
canonical: '/blog',
},
openGraph: {
title: 'Blog',
description:
'Read articles about Next.js, React, web development, and building modern full-stack applications.',
url: '/blog',
type: 'website',
},
}
export default function BlogPage() {
return (
<main>
<h1>Blog</h1>
</main>
)
}
canonical چیست؟
canonical به موتورهای جستجو میگوید آدرس اصلی یک صفحه کدام است.
مثلاً ممکن است یک صفحه با چند URL قابل دسترسی باشد:
/blog
/blog/
/blog?page=1
با canonical مشخص میکنیم نسخه اصلی کدام است.
در Next.js این کار را با alternates.canonical انجام میدهیم:
alternates: {
canonical: '/blog',
}
مرحله سوم: metadata صفحه پروژهها
برای صفحه لیست پروژهها هم همین کار را انجام میدهیم.
فایل:
app/projects/page.tsx
tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Projects',
description:
'Explore selected projects built with Next.js, React, Prisma, PostgreSQL, and Tailwind CSS.',
alternates: {
canonical: '/projects',
},
openGraph: {
title: 'Projects',
description:
'Explore selected projects built with Next.js, React, Prisma, PostgreSQL, and Tailwind CSS.',
url: '/projects',
type: 'website',
},
}
export default function ProjectsPage() {
return (
<main>
<h1>Projects</h1>
</main>
)
}
مرحله چهارم: ساخت helper برای گرفتن پست
در صفحات داینامیک، هم خود صفحه به اطلاعات پست نیاز دارد، هم metadata.
اگر دو بار query بزنیم، کد تکراری و غیر بهینه میشود.
طبق داک رسمی Next.js، میتوانیم از cache در React استفاده کنیم تا یک درخواست تکراری در همان render memoize شود.
فایل:
lib/data/posts.ts
ts
import { cache } from 'react'
import { prisma } from '@/lib/prisma'
export const getPostBySlug = cache(async (slug: string) => {
const post = await prisma.post.findUnique({
where: {
slug,
},
})
return post
})
این helper را هم در generateMetadata استفاده میکنیم، هم در خود صفحه.
مرحله پنجم: metadata داینامیک برای صفحه پست
حالا سراغ صفحه جزئیات پست میرویم.
فایل:
app/blog/[slug]/page.tsx
tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/data/posts'
type PageProps = {
params: Promise<{
slug: string
}>
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
return {
title: 'Post Not Found',
}
}
const description =
post.excerpt || post.content.slice(0, 160)
return {
title: post.title,
description,
alternates: {
canonical: `/blog/${post.slug}`,
},
openGraph: {
title: post.title,
description,
url: `/blog/${post.slug}`,
type: 'article',
images: post.coverImage
? [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
]
: [],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description,
images: post.coverImage ? [post.coverImage] : [],
},
}
}
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
notFound()
}
return (
<main>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<article>{post.content}</article>
</main>
)
}
نکته مهم درباره params در Next.js 16
در نسخههای جدید App Router، مقدار params بهشکل Promise دریافت میشود.
برای همین در کد بالا نوشتیم:
type PageProps = {
params: Promise<{
slug: string
}>
}
و بعد:
const { slug } = await params
این الگو با داک جدید Next.js هماهنگ است.
مرحله ششم: metadata داینامیک برای پروژهها
برای پروژهها هم دقیقاً همین الگو را پیاده میکنیم.
ابتدا helper پروژه:
فایل:
lib/data/projects.ts
import { cache } from 'react'
import { prisma } from '@/lib/prisma'
export const getProjectBySlug = cache(async (slug: string) => {
const project = await prisma.project.findUnique({
where: {
slug,
},
})
return project
})
حالا صفحه پروژه:
فایل:
app/projects/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getProjectBySlug } from '@/lib/data/projects'
type PageProps = {
params: Promise<{
slug: string
}>
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params
const project = await getProjectBySlug(slug)
if (!project) {
return {
title: 'Project Not Found',
}
}
const description =
project.summary || project.content.slice(0, 160)
return {
title: project.title,
description,
alternates: {
canonical: `/projects/${project.slug}`,
},
openGraph: {
title: project.title,
description,
url: `/projects/${project.slug}`,
type: 'article',
images: project.imageUrl
? [
{
url: project.imageUrl,
width: 1200,
height: 630,
alt: project.title,
},
]
: [],
},
twitter: {
card: 'summary_large_image',
title: project.title,
description,
images: project.imageUrl ? [project.imageUrl] : [],
},
}
}
export default async function ProjectDetailsPage({ params }: PageProps) {
const { slug } = await params
const project = await getProjectBySlug(slug)
if (!project) {
notFound()
}
return (
<main>
<h1>{project.title}</h1>
<p>{project.summary}</p>
<article>{project.content}</article>
</main>
)
}
Open Graph چیست؟
Open Graph مجموعهای از metadataهاست که مشخص میکند وقتی لینک صفحه شما share میشود، چه اطلاعاتی نمایش داده شود.
مثلاً در لینک یک مقاله، بهتر است این موارد وجود داشته باشد:
- عنوان مقاله
- توضیح مقاله
- تصویر کاور
- آدرس اصلی مقاله
- نوع محتوا
در Next.js، این بخش را داخل metadata مینویسیم:
openGraph: {
title: post.title,
description,
url: `/blog/${post.slug}`,
type: 'article',
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
}
ابعاد رایج برای تصویر Open Graph معمولاً $1200 \times 630$ است.
مرحله هفتم: استفاده از تصاویر Cloudinary در Open Graph
در قسمت قبل، برای پستها coverImage و برای پروژهها imageUrl را ذخیره کردیم.
حالا همان URLها را برای Open Graph استفاده میکنیم.
برای پست:
images: post.coverImage
? [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
]
: [],
برای پروژه:
images: project.imageUrl
? [
{
url: project.imageUrl,
width: 1200,
height: 630,
alt: project.title,
},
]
: [],
این دقیقاً یکی از مزیتهای ذخیره کردن URL تصویر در دیتابیس است.
مرحله هشتم: ساخت Open Graph image ثابت
اگر برای بعضی صفحات تصویر اختصاصی ندارید، میتوانید یک تصویر OG ثابت بسازید.
طبق داک رسمی Next.js، کافی است فایل زیر را داخل app قرار دهید:
app/opengraph-image.jpg
این تصویر بهصورت خودکار برای routeهایی که تصویر اختصاصی ندارند استفاده میشود.
همچنین میتوانید برای یک route خاص تصویر جدا داشته باشید:
app/blog/opengraph-image.jpg
app/projects/opengraph-image.jpg
تصویر نزدیکتر به route، اولویت بیشتری دارد.
مثلاً اگر این فایل وجود داشته باشد:
app/blog/opengraph-image.jpg
برای صفحات زیرمجموعه بلاگ نسبت به تصویر root اولویت دارد.
مرحله نهم: ساخت OG image داینامیک
در Next.js میتوانیم تصویر Open Graph را با کد تولید کنیم.
برای این کار از ImageResponse استفاده میکنیم.
فایل:
app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/data/posts'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
type ImageProps = {
params: Promise<{
slug: string
}>
}
export default async function Image({ params }: ImageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
const title = post?.title ?? 'Blog Post'
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
background: '#020617',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '64px',
fontSize: 72,
fontWeight: 700,
textAlign: 'center',
}}
>
{title}
</div>
),
{
width: 1200,
height: 630,
}
)
}
این فایل باعث میشود برای هر پست، یک تصویر OG داینامیک با عنوان همان پست ساخته شود.
نکته مهم درباره ImageResponse
طبق داک رسمی Next.js، ImageResponse از JSX و CSS پشتیبانی میکند، اما نه از همه قابلیتهای CSS.
مواردی مثل flexbox، رنگ، فونت، padding و position معمولاً قابل استفاده هستند. اما بهتر است سراغ layoutهای پیچیده مثل gridهای سنگین نرویم.
برای OG image، طراحی ساده و واضح معمولاً بهتر است.
مرحله دهم: اضافه کردن favicon
برای favicon کافی است فایل زیر را داخل پوشه app قرار دهید:
app/favicon.ico
Next.js بهصورت خودکار آن را به metadata اضافه میکند.
همچنین میتوانید فایلهای دیگری هم داشته باشید:
app/icon.png
app/apple-icon.png
این فایلها برای مرورگرها و دستگاههای مختلف استفاده میشوند.
مرحله یازدهم: robots و sitemap
Next.js برای metadata file conventions فایلهای مخصوصی دارد؛ مثل:
app/robots.ts
app/sitemap.ts
در این قسمت فقط اشاره میکنیم، اما در یک قسمت جدا میتوانیم کامل پیادهسازی کنیم.
نمونه ساده robots.ts:
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://your-domain.com/sitemap.xml',
}
}
نمونه ساده sitemap.ts:
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://your-domain.com',
lastModified: new Date(),
},
{
url: 'https://your-domain.com/blog',
lastModified: new Date(),
},
{
url: 'https://your-domain.com/projects',
lastModified: new Date(),
},
]
}
در قسمتهای بعدی میتوانیم sitemap را داینامیک کنیم تا پستها و پروژههای دیتابیس را هم شامل شود.
مرحله دوازدهم: بررسی خروجی metadata
بعد از اجرای پروژه:
npm run dev
یکی از صفحات را باز کنید.
بعد در مرورگر:
Inspect → Elements → head
باید tagهایی مثل این را ببینید:
<title>Blog | Matin Portfolio</title>
<meta name="description" content="..." />
<meta property="og:title" content="..." />
<meta property="og:description" content="..." />
<meta property="og:image" content="..." />
<link rel="canonical" href="..." />
این یعنی metadata شما توسط Next.js تولید شده است.
Streaming Metadata در Next.js
طبق داک رسمی Next.js، برای صفحات dynamic، metadata میتواند بهصورت streaming تولید شود.
یعنی Next.js میتواند محتوای بصری صفحه را سریعتر ارسال کند و metadata بعد از resolve شدن generateMetadata به HTML تزریق شود.
اما برای botها و crawlerهایی که metadata را حتماً داخل head میخواهند، Next.js این رفتار را مدیریت میکند.
برای بیشتر پروژهها نیاز نیست تنظیم خاصی انجام دهید. فقط کافی است generateMetadata را درست بنویسید.
اشتباهات رایج در SEO پروژههای Next.js
1. نوشتن metadata داخل Client Component
این اشتباه است:
'use client'
export const metadata = {
title: 'Blog',
}
metadata فقط در Server Component پشتیبانی میشود.
2. نداشتن description اختصاصی
اگر همه صفحات description یکسان داشته باشند، کیفیت SEO پایین میآید.
برای صفحات جزئیات، description را از دیتای همان صفحه بسازید.
3. نداشتن canonical
اگر صفحههای مختلف URLهای مشابه داشته باشند، canonical کمک میکند موتور جستجو نسخه اصلی را تشخیص دهد.
4. استفاده نکردن از تصویر Open Graph
لینک بدون تصویر، هنگام share شدن حرفهای دیده نمیشود.
5. استفاده از URL اشتباه برای دامنه
قبل از deploy نهایی، مقدار metadataBase و URLهای sitemap و robots را با دامنه واقعی هماهنگ کنید.
چکلیست این قسمت
اگر این موارد را انجام دادهاید، SEO پایه پروژه شما آماده است:
- metadata اصلی در
app/layout.tsxتعریف شده است -
metadataBaseتنظیم شده است - title template ساخته شده است
- صفحه بلاگ metadata ثابت دارد
- صفحه پروژهها metadata ثابت دارد
- صفحه جزئیات پست
generateMetadataدارد - صفحه جزئیات پروژه
generateMetadataدارد - canonical برای صفحات اصلی تعریف شده است
- Open Graph برای پستها و پروژهها فعال شده است
- تصویر Cloudinary در OG metadata استفاده شده است
- favicon اضافه شده است
- خروجی metadata در مرورگر بررسی شده است
تمرین پیشنهادی
برای تمرین، این موارد را اضافه کنید:
تمرین 1
برای صفحه About یک metadata ثابت بنویسید.
تمرین 2
برای پروژهها یک opengraph-image.tsx داینامیک بسازید.
تمرین 3
اگر پست coverImage نداشت، یک تصویر پیشفرض برای Open Graph قرار دهید.
تمرین 4
فایل sitemap.ts را داینامیک کنید و پستها و پروژههای منتشرشده را از دیتابیس بخوانید.
تمرین 5
برای صفحات unpublished مقدار robots را طوری تنظیم کنید که index نشوند.
جمعبندی
در این قسمت، SEO پروژه Next.js را با روش رسمی App Router پیادهسازی کردیم.
ما یاد گرفتیم:
- چطور metadata ثابت بسازیم
- چطور با
generateMetadataبرای صفحات داینامیک metadata تولید کنیم - چطور از title و description استفاده کنیم
- چطور canonical تعریف کنیم
- چطور Open Graph و Twitter metadata اضافه کنیم
- چطور از تصویرهای آپلودشده در Cloudinary برای share preview استفاده کنیم
- و چطور با file conventions مثل
favicon.icoوopengraph-image.jpgکار کنیم
از اینجا به بعد، صفحات پروژه فقط از نظر ظاهری کامل نیستند؛ بلکه برای موتورهای جستجو و اشتراکگذاری در وب هم آمادهتر شدهاند.