قسمت یازدهم - Data Fetching در Next.js: دریافت داده و نمایش محتوای واقعی
نحوه دریافت داده از دیتابیس و نمایش داده واقعی با نکست جی اس
Data Fetching در Next.js: دریافت داده و نمایش محتوای واقعی
بعد از اینکه ساختار صفحهها را با mock data جلو بردیم، قدم بعدی این است که پروژه را به داده واقعی وصل کنیم.
در Next.js با App Router، الگوی پیشفرض و طبیعی این است که داده را در Server Component بگیریم و همانجا UI را رندر کنیم.
برای پروژهای مثل پرتفولیو، بلاگ یا CMS، این رویکرد کاملاً منطقی است؛ چون بیشتر صفحهها محتوایی هستند و نیازی نیست منطق دریافت داده به مرورگر فرستاده شود.
در این نوشته، از مرحلهی mock data عبور میکنیم و به ساختاری میرسیم که صفحهها داده را از یک data layer واقعی دریافت میکنند.
چرا باید از mock data عبور کنیم؟
استفاده از mock data در شروع پروژه تصمیم درستی است.
در این مرحله، هدف معمولاً این است که:
- UI سریعتر ساخته شود
- ساختار صفحهها مشخص شود
- بدون درگیر شدن با دیتابیس یا API توسعه را جلو ببریم
اما این فقط یک مرحله موقت است.
وقتی پروژه وارد فاز واقعی میشود، محدودیتهای mock data کاملاً مشخص میشوند:
- دادهها ثابت هستند
- تغییرات پنل ادمین را نشان نمیدهند
slug، ترتیب و فیلتر واقعی ندارند- وضعیتهای
loadingوemptyبهصورت واقعی دیده نمیشوند
برای مثال، صفحه projects نباید همیشه از یک آرایه دستی بخواند.
این صفحه باید به منبع اصلی داده وصل باشد؛ مثلاً به Prisma و PostgreSQL.
یعنی بهجای این:
const projects = [
{ id: '1', title: 'Portfolio Website' },
{ id: '2', title: 'CMS Dashboard' },
]
به این برسیم:
const projects = await getProjects()
از نظر ظاهری این تغییر کوچک است، اما از نظر معماری، پروژه را وارد مرحلهی واقعیتری میکند.
دریافت داده در Server Component
در App Router، رایجترین الگو این است که داده را مستقیماً در Server Component بگیریم.
در عمل، دو روش رایج برای این کار وجود دارد:
- استفاده از
fetch - استفاده از ORM یا دیتابیس، مثل
Prisma
در این پروژه، چون دادهها داخل دیتابیس نگهداری میشوند، تمرکز ما روی Prisma است.
یک مثال ساده:
import { prisma } from '@/lib/prisma'
export default async function ProjectsPage() {
const projects = await prisma.project.findMany({
orderBy: {
createdAt: 'desc',
},
})
return (
<ul>
{projects.map((project) => (
<li key={project.id}>{project.title}</li>
))}
</ul>
)
}
این الگو چند مزیت مهم دارد:
- query روی سرور اجرا میشود
- اطلاعات حساس و منطق دیتابیس وارد
client bundleنمیشوند - صفحه با داده واقعی رندر میشود
- ساختار پروژه با مدل
App Routerهماهنگ میماند
در داک رسمی Next.js، fetch همچنان گزینه اصلی برای درخواستهای HTTP است.
اما وقتی داده را مستقیماً از دیتابیس میخوانی، استفاده از ORM کاملاً طبیعی است.
برای همین، در پروژهای مثل پرتفولیو یا CMS، گرفتن داده از Prisma داخل Server Component معمولاً بهترین نقطه شروع است.
ساخت data layer
هرچند میتوان query را مستقیم داخل page.tsx نوشت، اما در پروژه واقعی بهتر است منطق دریافت داده را به یک لایه جدا منتقل کنیم.
یعنی بهجای این:
const projects = await prisma.project.findMany(...)
به این برسیم:
const projects = await getProjects()
این همان چیزی است که به آن data layer میگوییم.
چرا data layer مهم است؟
جدا کردن منطق query از UI چند مزیت مهم دارد:
- کد خواناتر میشود
pageوsectionها سبکتر میمانند- reuse راحتتر میشود
- تستپذیری بهتر میشود
- تغییرات بعدی سادهتر مدیریت میشوند
مثلاً میتوانیم فایلی مثل lib/data/projects.ts داشته باشیم:
import { prisma } from '@/lib/prisma'
export async function getProjects() {
return prisma.project.findMany({
where: {
published: true,
},
orderBy: {
createdAt: 'desc',
},
})
}
و بعد در صفحه:
import { getProjects } from '@/lib/data/projects'
export default async function ProjectsPage() {
const projects = await getProjects()
return (
<ul>
{projects.map((project) => (
<li key={project.id}>{project.title}</li>
))}
</ul>
)
}
این ساختار در ادامه پروژه هم بهدرد میخورد.
بعداً میتوانی همین الگو را برای توابعی مثل این تکرار کنی:
getProjectBySluggetFeaturedProjectsgetPostsgetPostBySlug
در نتیجه، queryها بهجای پخش شدن در فایلهای مختلف، داخل یک لایه مشخص و قابل نگهداری جمع میشوند.
یک نکته مهم
در App Router، این data layer باید در سمت سرور باقی بماند.
یعنی این توابع برای Server Componentها، route handlerها و server actionها مناسباند، نه برای Client Componentها.
نمایش لیست پروژهها
وقتی data layer آماده شد، میتوانیم داده واقعی را در صفحه نمایش دهیم.
یک الگوی تمیز این است که page.tsx فقط ساختار کلی صفحه را نگه دارد و بخشی جدا مسئول دریافت و رندر لیست پروژهها باشد.
مثلاً:
// app/projects/page.tsx
import { ProjectsIntroSection } from '@/components/sections/projects/projects-intro-section'
import { ProjectsListSection } from '@/components/sections/projects/projects-list-section'
export default function ProjectsPage() {
return (
<div className="space-y-16">
<ProjectsIntroSection />
<ProjectsListSection />
</div>
)
}
و در بخش لیست:
// components/sections/projects/projects-list-section.tsx
import { getProjects } from '@/lib/data/projects'
export async function ProjectsListSection() {
const projects = await getProjects()
return (
<section>
<div className="grid gap-6 md:grid-cols-2">
{projects.map((project) => (
<article key={project.id}>
<h2>{project.title}</h2>
<p>{project.description}</p>
</article>
))}
</div>
</section>
)
}
این مدل چند مزیت دارد:
page.tsxتمیز و سبک میماند- منطق دریافت داده نزدیک به بخشی قرار میگیرد که واقعاً به آن نیاز دارد
- بعداً اضافه کردن
Suspenseسادهتر میشود - ساختار صفحه برای پروژههای محتوایی قابلگسترشتر میماند
اگر برای نمایش هر مورد یک ProjectCard جدا داشته باشی، ساختار باز هم بهتر میشود:
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
در این الگو، Server Component داده را میگیرد و کامپوننتهای نمایشی فقط مسئول رندر UI هستند.
loading و empty state
وقتی با داده واقعی کار میکنیم، دیگر همیشه مطمئن نیستیم که داده فوراً آماده است یا حتی اصلاً نتیجهای وجود دارد.
برای همین، دو وضعیت مهم را باید از ابتدا در نظر بگیری:
loading stateempty state
loading.tsx
اگر در همان segment یک فایل loading.tsx بسازی، Next.js آن را بهعنوان fallback همان route استفاده میکند.
مثلاً:
// app/projects/loading.tsx
export default function Loading() {
return <div>Loading projects...</div>
}
این رویکرد برای زمانی مناسب است که میخواهی کل route یک fallback اولیه داشته باشد.
<Suspense>
اگر بخواهی کنترل دقیقتری روی بخشهای مختلف صفحه داشته باشی، Suspense انتخاب بهتری است.
import { Suspense } from 'react'
import { ProjectsListSection } from '@/components/sections/projects/projects-list-section'
import { ProjectsListSkeleton } from '@/components/sections/projects/projects-list-skeleton'
export default function ProjectsPage() {
return (
<div className="space-y-16">
<section>
<h1>Projects</h1>
<p>Selected work and recent builds.</p>
</section>
<Suspense fallback={<ProjectsListSkeleton />}>
<ProjectsListSection />
</Suspense>
</div>
)
}
این مدل مخصوصاً وقتی مفید است که بالای صفحه محتوای ثابتی داریم و فقط بخشی از صفحه وابسته به داده است.
در این حالت، کاربر میتواند header یا intro را زودتر ببیند و لیست پروژهها بعداً stream شود.
در عمل، برای UI واقعی، استفاده از skeleton معمولاً انتخاب بهتری از یک متن ساده مثل Loading... است، چون ساختار نهایی محتوا را بهتر نشان میدهد.
empty state
حالت دوم زمانی است که query با موفقیت اجرا میشود، اما نتیجه خالی است.
import { getProjects } from '@/lib/data/projects'
export async function ProjectsListSection() {
const projects = await getProjects()
if (projects.length === 0) {
return (
<section>
<p>No projects have been published yet.</p>
</section>
)
}
return (
<section>
<div className="grid gap-6 md:grid-cols-2">
{projects.map((project) => (
<article key={project.id}>
<h2>{project.title}</h2>
</article>
))}
</div>
</section>
)
}
این وضعیت در پروژه واقعی کاملاً طبیعی است.
ممکن است هنوز پروژهای منتشر نشده باشد، فیلتر فعلی نتیجهای نداشته باشد یا دیتابیس هنوز داده اولیه نداشته باشد.
برای همین، empty state یک حالت فرعی نیست؛ بخشی از UX واقعی پروژه است.
جمعبندی
در Next.js با App Router، برای پروژههای محتوایی مثل پرتفولیو، بلاگ و CMS، نقطه شروع مناسب برای data fetching این است که:
- داده را در
Server Componentبگیریم - queryها را داخل یک
data layerنگه داریم - داده واقعی را از دیتابیس رندر کنیم
- برای تجربه بهتر،
loadingوempty stateرا از ابتدا در نظر بگیریم
نتیجه این رویکرد این است که پروژه از mock data عبور میکند و وارد فاز واقعیتری میشود؛
فازی که در آن صفحهها به داده زنده متصلاند و ساختار پروژه برای ادامه توسعه، تمیزتر و قابل نگهداریتر باقی میماند.