ساخت فهرست و صفحه‌ی جزئیات نوشته‌ها

در این فصل لینک‌ها، مسیرهای پویا، 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 جست‌وجو و صفحه‌بندی را انجام می‌دهد.

ts
// 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

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 بدون ساعت، بسته به منطقه‌ی زمانی سرور یک روز جابه‌جا شود.
  • فرمت‌کننده یک‌بار در سطح ماژول ساخته می‌شود تا هر بار ساخته نشود.

گام ۳: کامپوننت کارت نوشته

tsx
// 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 است و سلسله‌مراتب تیترها درست می‌ماند.

گام ۴: کامپوننت صفحه‌بندی

tsx
// 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 داده می‌گیرد و کارت‌ها را نشان می‌دهد:

tsx
// 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 منتقل شدند و صفحه ساده‌تر شد.

گام ۶: صفحه‌ی جزئیات

tsx
// 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 از آدرس می‌آیند؛ همیشه نوع و محدوده‌شان را بررسی کنید.

ساختار پروژه در پایان این درس

text
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» یاد گرفتیم را با یک آزمون می‌سنجیم.