ساخت فهرست و صفحهی جزئیات نوشتهها
در این فصل لینکها، مسیرهای پویا، searchParams، هدایت و صفحهی ۴۰۴ را جدا جدا یاد گرفتیم. حالا همه را کنار هم میگذاریم و بخش اصلی سایت را کامل میکنیم: یک فهرست نوشتهها با جستوجو و صفحهبندی، و یک صفحهی جزئیات برای هر نوشته. دادهی نمونهی موقت را غنیتر میکنیم و کدی مینویسیم که در فصلهای بعد، با کمترین تغییر، به دریافت دادهی واقعی وصل شود.
آنچه میسازیم
| مسیر | رفتار |
|---|---|
/posts |
فهرست نوشتهها (هر صفحه ۳ نوشته)، فرم جستوجو و لینکهای صفحهی قبل و بعد |
/posts?q=...&page=... |
همان فهرست با فیلتر جستوجو و صفحهی مشخص |
/posts/[slug] |
صفحهی جزئیات یک نوشته؛ اگر پیدا نشد، صفحهی ۴۰۴ بخش نوشتهها |
فایلهای این درس
| فایل | وضعیت | نقش |
|---|---|---|
lib/posts.ts |
تغییر | دادهی نمونهی بیشتر، تاریخ انتشار، تابع getPosts برای جستوجو و صفحهبندی |
lib/format.ts |
جدید | قالببندی تاریخ فارسی |
app/(public)/posts/_components/PostCard.tsx |
جدید | کارت یک نوشته در فهرست |
app/(public)/posts/_components/Pagination.tsx |
جدید | لینکهای صفحهبندی |
app/(public)/posts/page.tsx |
تغییر | صفحهی فهرست |
app/(public)/posts/[slug]/page.tsx |
تغییر | صفحهی جزئیات |
دو کامپوننت جدید در پوشهی خصوصی _components کنار صفحهی نوشتهها قرار میگیرند، چون فقط همین بخش از آنها استفاده میکند (قاعدهای که در درس «پوشههای خصوصی و ساختاردهی فایلها» دیدیم).
گام ۱: لایهی موقت داده
فایل lib/posts.ts را بهروز میکنیم. تفاوتها: فیلد publishedAt به نوع Post اضافه شده، تعداد نوشتهها بیشتر است تا صفحهبندی معنی پیدا کند، و تابع جدید getPosts جستوجو و صفحهبندی را انجام میدهد.
// lib/posts.ts
// لایهی موقت داده برای آموزش؛ در محیط Production مناسب نیست.
// داده در حافظهی برنامه است و هیچچیز ذخیره نمیشود.
// در فصل «پایگاه داده با Prisma و SQLite» پیادهسازی با Prisma جایگزین میشود.
export type Post = {
slug: string;
title: string;
excerpt: string;
body: string;
publishedAt: string; // تاریخ بهصورت ISO، مثل 2026-10-01
};
const posts: Post[] = [
{
slug: "hello-nextjs",
title: "سلام Next.js",
excerpt: "اولین نوشتهی DevNotes دربارهی شروع کار با Next.js.",
body: "Next.js یک فریمورک فولاستک مبتنی بر React است که مسیریابی و رندر روی سرور را در اختیار شما میگذارد.",
publishedAt: "2026-10-01",
},
{
slug: "app-router-intro",
title: "آشنایی با App Router",
excerpt: "نگاهی کوتاه به مسیریابی مبتنی بر پوشهها.",
body: "در App Router هر پوشه یک بخش از آدرس را میسازد و فایل page.tsx آن بخش را قابل دسترسی میکند.",
publishedAt: "2026-10-05",
},
{
slug: "server-components",
title: "Server Componentها چه میکنند؟",
excerpt: "چرا کامپوننتها بهصورت پیشفرض روی سرور اجرا میشوند؟",
body: "Server Componentها روی سرور اجرا میشوند و کد کمتری به مرورگر میفرستند.",
publishedAt: "2026-10-09",
},
{
slug: "layouts-and-routing",
title: "Layoutها و ساختار صفحه",
excerpt: "هر Layout چگونه درون Layoutهای دیگر قرار میگیرد.",
body: "Layoutها ساختار مشترک صفحات را نگه میدارند و هنگام ناوبری دوباره ساخته نمیشوند.",
publishedAt: "2026-10-12",
},
{
slug: "dynamic-routes",
title: "مسیرهای پویا در Next.js",
excerpt: "ساخت صفحهی هر نوشته با یک فایل و پارامتر slug.",
body: "با پوشهی [slug] یک فایل صفحه برای هر مقدار آدرس کافی است و مقدار از params خوانده میشود.",
publishedAt: "2026-10-15",
},
{
slug: "search-params",
title: "خواندن Query String با searchParams",
excerpt: "جستوجو و صفحهبندی را در آدرس نگه دارید.",
body: "حالتهایی مثل جستوجو و شمارهی صفحه بهتر است داخل Query String باشند تا قابل اشتراکگذاری بمانند.",
publishedAt: "2026-10-18",
},
{
slug: "client-components",
title: "کِی به Client Component نیاز داریم؟",
excerpt: "State، رویداد و APIهای مرورگر دلیل اصلی هستند.",
body: "فقط وقتی به State، رویداد یا APIهای مرورگر نیاز دارید فایل را با use client به Client Component تبدیل کنید.",
publishedAt: "2026-10-21",
},
{
slug: "data-fetching-basics",
title: "مقدمهای بر دریافت داده",
excerpt: "خواندن داده مستقیم داخل Server Component.",
body: "کامپوننتهای سروری میتوانند async باشند و داده را مستقیماً با await بخوانند.",
publishedAt: "2026-10-24",
},
];
export const POSTS_PER_PAGE = 3;
export type PostsResult = {
posts: Post[]; // نوشتههای همین صفحه
total: number; // تعداد کل نتایج (پیش از صفحهبندی)
totalPages: number;
};
type GetPostsOptions = {
q?: string;
page?: number;
};
// جدیدترین نوشتهها اول
function sortedPosts(): Post[] {
return [...posts].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
export async function getAllPosts(): Promise<Post[]> {
return sortedPosts();
}
export async function getPostBySlug(slug: string): Promise<Post | undefined> {
return posts.find((post) => post.slug === slug);
}
export async function getPosts(
{ q = "", page = 1 }: GetPostsOptions = {},
): Promise<PostsResult> {
const term = q.trim().toLocaleLowerCase();
const filtered = term
? sortedPosts().filter(
(post) =>
post.title.toLocaleLowerCase().includes(term) ||
post.excerpt.toLocaleLowerCase().includes(term),
)
: sortedPosts();
const total = filtered.length;
const totalPages = Math.ceil(total / POSTS_PER_PAGE);
const start = (page - 1) * POSTS_PER_PAGE;
return {
posts: filtered.slice(start, start + POSTS_PER_PAGE),
total,
totalPages,
};
}توضیح کد
- قرارداد توابع: سه تابع
getAllPosts،getPostBySlugوgetPostsهمگیasyncهستند وPromiseبرمیگردانند. با اینکه داده در حافظه است، همین حالا شکل آن را مثل دریافت از پایگاه داده نوشتهایم؛ در فصل «پایگاه داده با Prisma و SQLite» فقط بدنهی این توابع عوض میشود و صفحهها دست نمیخورند. sortedPostsیک کپی از آرایه را مرتب میکند (با[...posts]) تا آرایهی اصلی تغییر نکند. چون تاریخها به شکل ISO هستند، مقایسهی رشتهای باlocaleCompareترتیب درست زمانی میدهد.getPostsابتدا فیلتر جستوجو (روی عنوان و خلاصه) و بعد صفحهبندی باsliceرا اعمال میکند. جمع کل و تعداد صفحهها را هم برمیگرداند تا صفحهی فهرست بتواند لینکها را بسازد.Math.ceil(total / POSTS_PER_PAGE): با ۸ نوشته و ۳ نوشته در هر صفحه، سه صفحه داریم (۳ + ۳ + ۲). اگر نتیجهای نباشد،totalPagesبرابر ۰ است.- برای سادگی، جستوجو بزرگی و کوچکی حروف لاتین را نادیده میگیرد (
toLocaleLowerCase)، ولی تفاوت نویسههای عربی و فارسی (مثل «ي» و «ی» یا «ك» و «ک») را یکسانسازی نمیکند. یک جستوجوی واقعی فارسی نیاز به نرمالسازی متن دارد؛ در این دوره آن را در حد همین مثال ساده نگه میداریم.
گام ۲: قالببندی تاریخ
تاریخ ISO برای کاربر خوانا نیست. یک تابع کوچک مشترک مینویسیم تا هم فهرست و هم صفحهی جزئیات از آن استفاده کنند. فایل جدید: lib/format.ts
// lib/format.ts
const dateFormatter = new Intl.DateTimeFormat("fa-IR", {
dateStyle: "long",
timeZone: "UTC",
});
export function formatDate(iso: string): string {
return dateFormatter.format(new Date(iso));
}Intl.DateTimeFormatبا زبانfa-IRتاریخ را به فارسی و در بیشتر محیطها با تقویم شمسی و ارقام فارسی نشان میدهد.timeZone: "UTC"مانع میشود تاریخ ISO بدون ساعت، بسته به منطقهی زمانی سرور یک روز جابهجا شود.- فرمتکننده یکبار در سطح ماژول ساخته میشود تا هر بار ساخته نشود.
گام ۳: کامپوننت کارت نوشته
// app/(public)/posts/_components/PostCard.tsx
import Link from "next/link";
import type { Post } from "@/lib/posts";
import { formatDate } from "@/lib/format";
type PostCardProps = {
post: Post;
};
export default function PostCard({ post }: PostCardProps) {
return (
<article>
<h2>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</h2>
<p>{post.excerpt}</p>
<time dateTime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</article>
);
}- عنوان داخل یک
Linkاست و به/posts/slugمیرود؛ همان الگوی Template String که در درس «لینکسازی با Link» دیدیم. - تگ
timeبا ویژگیdateTimeتاریخ را برای ماشین (و موتورهای جستوجو) به شکل ISO و برای کاربر به شکل فارسی نشان میدهد. - کارت
h2دارد، چون عنوان اصلی صفحهی فهرستh1است و سلسلهمراتب تیترها درست میماند.
گام ۴: کامپوننت صفحهبندی
// app/(public)/posts/_components/Pagination.tsx
import Link from "next/link";
type PaginationProps = {
q: string;
page: number;
totalPages: number;
};
function buildHref(q: string, page: number): string {
const search = new URLSearchParams();
if (q) search.set("q", q);
if (page > 1) search.set("page", String(page));
const queryString = search.toString();
return queryString ? `/posts?${queryString}` : "/posts";
}
export default function Pagination({ q, page, totalPages }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
return (
<nav aria-label="صفحهبندی">
{page > 1 && (
<Link href={buildHref(q, page - 1)} rel="prev">
صفحهی قبل
</Link>
)}{" "}
<span>
صفحهی {page} از {totalPages}
</span>{" "}
{page < totalPages && (
<Link href={buildHref(q, page + 1)} rel="next">
صفحهی بعد
</Link>
)}
</nav>
);
}- اگر فقط یک صفحه (یا هیچ نتیجهای) وجود داشته باشد، چیزی نمایش داده نمیشود (
return null). - لینک «قبل» فقط از صفحهی ۲ به بعد و لینک «بعد» فقط تا قبل از آخرین صفحه نمایش داده میشود؛ پس هیچوقت لینک به صفحهی ناموجود نمیسازیم.
- عبارت جستوجو (
q) در تمام لینکها حفظ میشود تا با رفتن به صفحهی بعد، فیلتر از بین نرود. - ویژگیهای
rel="prev"وrel="next"رابطهی صفحهها را به مرورگر و ابزارها میگویند.
گام ۵: صفحهی فهرست
صفحهی /posts حالا searchParams را میخواند، اعتبارسنجی میکند، از getPosts داده میگیرد و کارتها را نشان میدهد:
// app/(public)/posts/page.tsx
import { notFound } from "next/navigation";
import SearchBox from "@/components/SearchBox";
import { getPosts } from "@/lib/posts";
import PostsIntro from "./_components/PostsIntro";
import PostCard from "./_components/PostCard";
import Pagination from "./_components/Pagination";
type PostsPageProps = {
searchParams: Promise<{
[key: string]: string | string[] | undefined;
}>;
};
function first(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export default async function PostsPage({ searchParams }: PostsPageProps) {
const params = await searchParams;
const q = first(params.q)?.trim() ?? "";
const rawPage = Number(first(params.page));
const page = Number.isInteger(rawPage) && rawPage >= 1 ? rawPage : 1;
const { posts, total, totalPages } = await getPosts({ q, page });
// صفحهای بیرون از محدودهی نتایج وجود ندارد.
if (page > Math.max(totalPages, 1)) {
notFound();
}
return (
<main>
<h1>نوشتهها</h1>
<PostsIntro />
<SearchBox key={q} defaultValue={q} />
<p>
{q
? `${total} نتیجه برای «${q}»`
: `${total} نوشته`}
</p>
{posts.length === 0 ? (
<p>نوشتهای با این مشخصات پیدا نشد.</p>
) : (
<div>
{posts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
)}
<Pagination q={q} page={page} totalPages={totalPages} />
</main>
);
}توضیح کد
- بخش خواندن و اعتبارسنجی
qوpageهمان است که در درس «searchParams و Query String» نوشتیم؛ ورودی نامعتبر مثل?page=abcبه صفحهی ۱ برمیگردد. notFound()در درس «Redirect، not-found.tsx و notFound()» معرفی شد. اینجا وقتی شمارهی صفحه از تعداد صفحهها بیشتر باشد (مثلاً?page=99)، ۴۰۴ میدهد.Math.max(totalPages, 1)مطمئن میشود حتی بدون نتیجه، صفحهی ۱ معتبر بماند و پیام «پیدا نشد» نمایش داده شود.notFound()را بیرون از بلوکtryصدا زدهایم؛ همان قاعدهای که در درس قبلی دیدیم.- برای
keyهر کارت ازpost.slugاستفاده شده که در بین نوشتهها یکتاست. SearchBoxهمان کامپوننت درس «مسیریابی برنامهای با useRouter» است؛ فقط صفحهی ما Server Component میماند و فقط آن کامپوننت در مرورگر اجرا میشود.- دو بخش قبلی (
buildHrefو لینکهای صفحهبندی) بهPaginationمنتقل شدند و صفحه سادهتر شد.
گام ۶: صفحهی جزئیات
// app/(public)/posts/[slug]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import BackButton from "@/components/BackButton";
import { formatDate } from "@/lib/format";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
type PostPageProps = {
params: Promise<{ slug: string }>;
};
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
}
return (
<main>
<BackButton />
<article>
<h1>{post.title}</h1>
<p>
<time dateTime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</p>
<p>{post.body}</p>
</article>
<p>
<Link href="/posts">همهی نوشتهها</Link>
</p>
</main>
);
}- این صفحه تقریباً همان است که در درس قبل ساختیم؛ فقط تاریخ و لینک بازگشت به فهرست اضافه شدهاند.
generateStaticParamsحالا از همان لایهی داده میخواند؛ هر نوشتهی جدید بدون تغییر دستی به فهرست مسیرهای از پیشساخته اضافه میشود.BackButtonبه تاریخچهی مرورگر وابسته است. لینک «همهی نوشتهها» مقصد مطمئنی است، حتی اگر کاربر مستقیم با آدرس وارد شده باشد.notFound()برای slug نامعتبر،app/(public)/posts/not-found.tsxرا نشان میدهد.
خروجی و رفتار مورد انتظار
| آدرس | نتیجه |
|---|---|
/posts |
«۸ نوشته» (با رقم لاتین ۸)، سه کارت از جدیدترین نوشتهها، «صفحهی 1 از 3» و فقط لینک «صفحهی بعد» |
/posts?page=3 |
دو کارت آخر، لینک «صفحهی قبل» و بدون لینک «بعد» |
/posts?page=99 |
صفحهی ۴۰۴ بخش نوشتهها |
/posts?q=Router |
«1 نتیجه برای «Router»» و فقط نوشتهی «آشنایی با App Router»؛ بدون صفحهبندی |
/posts?q=xyz |
«0 نتیجه برای «xyz»» و پیام «نوشتهای با این مشخصات پیدا نشد» |
/posts/dynamic-routes |
صفحهی جزئیات با عنوان، تاریخ شمسی و متن |
/posts/not-a-post |
صفحهی ۴۰۴ بخش نوشتهها |
بررسی کنید: وقتی جستوجو دارید و به صفحهی بعد میروید، q در آدرس باقی میماند؛ و با دکمهی «بازگشت» مرورگر، هم آدرس و هم مقدار داخل فیلد جستوجو به حالت قبل برمیگردد.
تصمیمهای طراحی این درس
-
یک لایهی داده، نه داده داخل صفحه: صفحهها فقط توابع
lib/posts.tsرا صدا میزنند و نمیدانند داده از کجا میآید. جایگزینی با پایگاه داده بعداً فقط یک فایل را تغییر میدهد. -
صفحهبندی در لایهی داده: فیلتر و برش آرایه داخل
getPostsاست؛ با پایگاه داده همین کار با پرسوجو (مثلاً با Prisma) انجام میشود و دیگر کل داده را به حافظه نمیآوریم. برای دادهی نمونهی ۸ تایی مشکلی نیست، ولی برای هزاران رکورد روش مناسب نیست. - وضعیت در آدرس: جستوجو و شمارهی صفحه در Query String هستند، پس لینک هر نتیجه قابل اشتراکگذاری و سازگار با دکمهی «بازگشت» است.
-
کامپوننتهای سروری: هیچکدام از فایلهای جدید
"use client"ندارند؛ فقطSearchBoxوBackButton(که قبلاً ساختیم) در مرورگر اجرا میشوند.
نکتههای مهم
-
رندر پویا برای
/posts: چون صفحهی فهرستsearchParamsمیخواند، با درخواست رندر میشود. مسیر/posts/[slug]برای slugهای شناختهشده میتواند از قبل ساخته شود. تفاوتشان را در فصل «Rendering و Data Fetching» بهطور کامل میبینیم. -
بدون
loading.tsxهنوز اسکلت انتظار نداریم: چون دادهی ما در حافظه است سریع جواب میدهد؛ وقتی دادهی واقعی داشته باشیم، در فصل «Loading، Error و Streaming» انتظار را هم طراحی میکنیم. - دادهی موقت برای Production نیست: هر بار برنامه دوباره اجرا شود به حالت اولیه برمیگردد و نوشتهی جدیدی نمیتوان ذخیره کرد. ذخیرهی واقعی در فصلهای فرم و پایگاه داده میآید.
-
ورودی کاربر را اعتبارسنجی کنید: هر دو مقدار
qوpageاز آدرس میآیند؛ همیشه نوع و محدودهشان را بررسی کنید.
ساختار پروژه در پایان این درس
devnotes/
├── lib/
│ ├── posts.ts ← دادهی نمونه، getAllPosts، getPostBySlug، getPosts
│ └── format.ts ← قالببندی تاریخ فارسی (جدید)
├── components/
│ ├── NavLink.tsx
│ ├── SearchBox.tsx
│ ├── BackButton.tsx
│ ├── SiteHeader.tsx
│ └── SiteFooter.tsx
└── app/
└── (public)/
└── posts/
├── page.tsx ← فهرست با جستوجو و صفحهبندی
├── not-found.tsx
├── _components/
│ ├── PostsIntro.tsx
│ ├── PostCard.tsx ← جدید
│ └── Pagination.tsx ← جدید
└── [slug]/
└── page.tsx ← جزئیات نوشتهجمعبندی
- فهرست نوشتهها از
searchParamsبرای جستوجو و صفحهبندی استفاده میکند و ورودی نامعتبر را به مقدار امن برمیگرداند. - صفحهی جزئیات با
paramsناهمگام کار میکند و برای نوشتهی ناموجود ازnotFound()استفاده میکند. - لایهی موقت داده در
lib/posts.tsبا توابع ناهمگام و قرارداد ثابت نوشته شده تا بعداً بهراحتی با پایگاه داده جایگزین شود. - کامپوننتهای اختصاصی این بخش در پوشهی خصوصی
_componentsو کدهای مشترک درlibوcomponentsقرار گرفتند. - DevNotes اکنون یک فهرست و یک صفحهی جزئیات کاربردی دارد، هرچند دادهاش هنوز نمونه است.
- در درس بعد، «آزمون فصل سوم»، آنچه در فصل «Routing و Navigation» یاد گرفتیم را با یک آزمون میسنجیم.
