كيفية بناء منصة دليل بـ 137K قائمة باستخدام Next.js و Vercel ISR

في العام الماضي، أطلقنا منصة دليل. 137,000 قائمة. لم تكن هذه مسعى ثانوي. كانت منصة مُنجزة بالكامل حيث كانت لكل قائمة صفحتها الخاصة المُحسّنة لـ SEO. كان يجب أن تكون البحوث سريعة، وبالتأكيد كان يجب أن يبقى الاستضافة بأسعار معقولة. إذاً، كيف فعلناها باستخدام Next.js و Vercel و Incremental Static Regeneration (ISR)؟ استعد؛ إليك القصة، بما فيها الأماكن التي واجهنا فيها صعوبات.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR

جدول المحتويات

لماذا منصة الدليل أصعب مما تبدو

قد تبدو مواقع الأدلة بسيطة. قد تعتقد أن صفحة قائمة، صفحة تفاصيل، أضف بعض الفلاتر، وها! تم. لكن بمجرد تجاوزك لبضعة آلاف من القوائم، كل شيء ينزلق إلى تعقيد.

إليك ما يحدث فعلاً:

  • 137,000+ صفحة فريدة يجب أن تكون قابلة للزحف والفهرسة
  • بحث بنواحٍ متعددة عبر الموقع والفئة والمزيد
  • إدارة البيانات القديمة -- القوائم في تدفق دائم مع التحديثات والحذف
  • متطلبات SEO تعني أنه لا يمكنك الاعتماد على العرض من جانب العميل فقط
  • الاستضافة برقابة صارمة على الميزانية تلغي إنشاء جميع الصفحات في وقت البناء

بعد فحص عدد من الطرق، حددنا Next.js مع ISR كخيارنا المفضل. لقد فكرنا في Astro أيضاً (يُستخدم في بعض وظائفنا الأخرى--انظر عمل Astro development). في النهاية، كانت القدرة الديناميكية لـ Next.js مع ISR هي الخيار الواضح.

نظرة عامة على البنية

إليك ما تبدو عليه بنيتنا:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Vercel      │────▶│  Next.js App │────▶│  PostgreSQL  │
│   Edge CDN    │     │  (ISR)       │     │  (Neon)      │
└──────────────┘     └──────────────┘     └──────────────┘
                            │                     │
                            ▼                     ▼
                     ┌──────────────┐     ┌──────────────┐
                     │  Redis       │     │  Meilisearch │
                     │  (Upstash)   │     │  (Cloud)     │
                     └──────────────┘     └──────────────┘

المكدس

المكون التكنولوجيا السبب
الإطار Next.js 14 (App Router) دعم ISR، React Server Components، معالجات المسارات
الاستضافة Vercel Pro Edge CDN، بنية ISR، التحليلات
قاعدة البيانات Neon PostgreSQL Postgres بدون خادم، فروع للمعاينات
البحث Meilisearch Cloud تحمل الأخطاء الإملائية، بحث بنواحٍ متعددة، فهرسة سريعة
الذاكرة المؤقتة Upstash Redis تحديد معدل الطلبات، ذاكرة الجلسات، تنسيق ISR
CMS (الإدارة) لوحة تحكم مخصصة + Payload CMS إدارة القوائم، عمليات جماعية
CDN/الصور Vercel Image Optimization + Cloudinary صور القوائم بنقاط توقف متعددة

هذا هو مشروع Next.js development في الأساس، وكان ISR هو البائع الكبير بالنسبة لنا.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR - architecture

استراتيجية ISR التي تعمل فعلاً على نطاق واسع

دعنا نقول الحقيقة: إذا حاولت إنشاء 137,000 صفحة بشكل ثابت في وقت البناء، فأنت تطلب المشاكل. بجدية، لا تستدعِ هذا الصداع. حتى مع إنشاء Parallel لـ Next.js، يمكن أن تمتد الإصدارات لأكثر من 45 دقيقة، مما يحول كل نشر إلى كابوس.

يتيح ISR إنشاء الصفحات حسب الحاجة وتخزينها مؤقتاً على الحافة. ISR الافتراضي رائع، لكن بالنسبة لنا، كانت التعديلات ضرورية.

استراتيجية الصفحات ذات المستويات الثلاثة

قسمنا قوائمنا إلى ثلاث مستويات:

// app/listing/[slug]/page.tsx

export async function generateStaticParams() {
  // المستوى 1: إنشء مسبق للأعلى 2,000 قائمة عالية حركة المرور
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

  return topListings.map((listing) => ({
    slug: listing.slug,
  }));
}

// المستويات 2 و 3: يتم إنشاؤها عند الطلب عبر ISR
export const revalidate = 3600; // ساعة واحدة لمعظم القوائم

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const listing = await getListingBySlug(params.slug);

  if (!listing) {
    notFound();
  }

  // إعادة التحقق الديناميكية بناءً على مستوى القائمة
  // تتحقق القوائم المميزة كل 10 دقائق
  // القوائم القياسية كل ساعة
  // القوائم المؤرشفة كل 24 ساعة

  return <ListingDetail listing={listing} />;
}

المستوى 1 (2,000 صفحة): يتم إنشاء هذه القوائم عالية حركة المرور مسبقاً في وقت البناء. وهي مسؤولة عن معظم حركة المرور من البحث العضوي. هي دائماً جاهزة.

المستوى 2 (35,000 صفحة): يتم إنشاؤها عند طلبها لأول مرة، وتخزينها مؤقتاً لمدة ساعة. لهذه القوائم حركة مرور ثابتة، لذا الزائر الأول بعد انتهاء صلاحية الذاكرة المؤقتة يحصل على صفحة معروضة من الخادم لكنها سريعة. الجميع الآخرين يحصلون على النسخة المخزنة مؤقتاً.

المستوى 3 (100,000 صفحة): يتم إنشاؤها عند الطلب الأول، وتخزينها مؤقتاً لمدة 24 ساعة. هذه القوائم بالكاد ترى أي حركة، لذا لا داعي لهدر الموارد.

إعادة التحقق عند الطلب لتحديثات فورية

يتم تغطية معظم الحالات من خلال إعادات التحقق المحددة بالوقت، لكن ماذا عن مالكة المطعم التي للتو حدثت ساعات العمل الخاصة بها؟ حسناً، أطلقنا إعادة التحقق عند الطلب في Next.js باستخدام معالجات المسار:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret');

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }

  const { slug, type } = await request.json();

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`);
    revalidateTag(`listing-${slug}`);
  } else if (type === 'category') {
    revalidateTag(`category-${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

مع لوحة الإدارة الخاصة بنا والخطافات تتحدث مع هذه الحقيقة، يحصل أي مُغيِّر قائمة على صفحة طازجة في الطلب التالي. سريع جداً، أليس كذلك؟

التعامل مع 137K صفحة دون انفجار أوقات البناء

أوقات البناء خيفتنا فعلاً! إليك ما وجدناه:

الاستراتيجية وقت البناء زمن الطلب الأول زمن الذاكرة المؤقتة
SSG كامل (جميع 137K صفحة) ~52 دقيقة ~40ms ~40ms
ISR (2K مبنية مسبقاً) ~3.5 دقائق ~180ms (بارد) ~40ms
SSR كامل (بدون ذاكرة تخزين مؤقتة) ~45 ثانية ~250ms N/A
نهجنا الهجين ~3.5 دقائق ~150ms (بارد) ~35ms

قطعت نهجنا ISR أوقات البناء من ساعة مؤلمة إلى ما يزيد قليلاً عن 4 دقائق. هذا هو الفرق بين الخوف من الانتشار و، حسناً، شرب القهوة أثناء تشغيلهم.

إعداد `dynamicParams`

إليك نقطة حاسمة: احتفظ بـ dynamicParams = true للسماح بـ ISR بإنشاء صفحات خارج generateStaticParams. يبدو واضحاً، لكنك ستندهش من عدد المرات التي يتم فيها تجاهلها.

export const dynamicParams = true; // السماح بالإنشاء عند الطلب

شرائح المسار الموازي

بالنسبة للصفحات التي تحتوي على فئات وموقع، استفدنا من شرائح المسار الموازي حتى تتمكن الفلاتر وشبكات القوائم من التحميل بشكل مستقل:

// app/directory/[category]/layout.tsx
export default function CategoryLayout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[280px_1fr] gap-6">
      <aside>{filters}</aside>
      <main>{children}</main>
    </div>
  );
}

هذا يعني أن الفلاتر الخاصة بك يمكن تخزينها مؤقتاً بنفسها. غير فلتراً، وفقط شبكة القوائم تعاد رسمها. سريع جداً!

طبقة قاعدة البيانات والبحث

PostgreSQL على Neon

اخترنا Neon لميزاته بدون خادم مثل الحجم والفروع المعاينة. نوع الأشياء التي جعلت حياتنا أسهل.

جدول القوائم الخاص بنا بسيط لكنه يعتمد بشدة على الفهرسة:

CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);

لماذا فهرس GiST على الموقع؟ الأمر يتعلق بالاستعلامات الجغرافية الدقيقة. "متاجر القهوة بالقرب مني" ليست مجرد حشو؛ إنها حسابات حقيقية.

Meilisearch للبحث

إذا كانت قائمتك تنتفخ مثل القائمة الخاصة بنا، فإن بحث PostgreSQL النصي لن ينجح، وهنا يأتي دور Meilisearch. لقد فاز علينا Algolia، بشكل أساسي على السعر (30 دولاراً/الشهر مقابل 200 دولار +) وتحمله الرائع للأخطاء الإملائية.

// lib/search.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_API_KEY!,
});

export async function searchListings(query: string, filters: FilterParams) {
  const index = client.index('listings');

  return index.search(query, {
    filter: buildFilterString(filters),
    facets: ['category', 'city', 'priceRange', 'rating'],
    limit: 24,
    offset: filters.page * 24,
    attributesToHighlight: ['name', 'description'],
  });
}

كل خمس دقائق، القوائم تتزامن مع وظيفة. نقوم بإعادة فهرسة كاملة أسبوعياً فقط للتأكد. من الأفضل أن تكون آمناً، أليس كذلك؟

SEO على نطاق واسع: Sitemaps والبيانات المنظمة وميزانية الزحف

بالنسبة لمنصة بها 137,000 صفحة، لا يعتبر SEO شيئاً جيداً فقط؛ إنه قضية حياة أو موت. إليك كيف حققناها:

Sitemaps ديناميكي

لا يمكنك تفريغ 137,000 URL في ملف sitemap واحد. الحد هو 50,000 URL وفقاً للمواصفات. فإذاً، ماذا نفعل؟ نحن ننشئ فهرس sitemap يشير إلى قطع مقسمة:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // هذا ينشئ فهرس sitemap
  const totalListings = await db.listing.count({ where: { status: 'active' } });
  const sitemapCount = Math.ceil(totalListings / 10000);

  const sitemaps = [];

  for (let i = 0; i < sitemapCount; i++) {
    sitemaps.push({
      url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
      lastModified: new Date(),
    });
  }

  return sitemaps;
}

تحمل Sitemaps المقسمة 10,000 قائمة لكل واحد، مكتملة مع الطوابع الزمنية. يحفر Google بما يقارب 8,000-12,000 صفحة يومياً.

البيانات المنظمة

تحزم كل صفحة قائمة ترميز مخطط LocalBusiness:

function generateStructuredData(listing: Listing) {
  return {
    '@context': 'https://schema.org',
    '@type': 'LocalBusiness',
    name: listing.name,
    description: listing.description,
    address: {
      '@type': 'PostalAddress',
      streetAddress: listing.address,
      addressLocality: listing.city,
      addressRegion: listing.state,
      postalCode: listing.zip,
    },
    aggregateRating: listing.reviewCount > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: listing.avgRating,
      reviewCount: listing.reviewCount,
    } : undefined,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: listing.lat,
      longitude: listing.lng,
    },
  };
}

هذا النوع من البيانات المنظمة عزز رتبنا بشكل كبير، مع إعطاء Google تفضيلاً كبيراً لهذه المعلومات الدقيقة.

معايير الأداء

مقاييس حقيقية من موقعنا المباشر اعتباراً من أوائل 2025:

المقياس القيمة الهدف
أكبر Contentful Paint (LCP) 1.1s (p75) < 2.5s
First Input Delay (FID) 12ms (p75) < 100ms
Cumulative Layout Shift (CLS) 0.02 (p75) < 0.1
Time to First Byte (TTFB) 85ms (مخزن مؤقتاً) / 190ms (ISR بارد) < 200ms
Lighthouse Performance Score 94-98 > 90
وقت البناء 3 دقيقة 22 ثانية < 5 دقائق
معدل Cache Hit 94.7% > 90%

معدل Cache Hit المرتفع؟ بل، 94.7% هائل من صفحاتنا مباشرة من Edge CDN لـ Vercel--بدون حاجة لحسابات إضافية. إنها فوز-فوز للسرعة والتكاليف.

تفصيل التكاليف على Vercel

دعونا نصل إلى الدولارات والسنتات. من لا يحب صفقة جيدة؟

الخدمة التكلفة الشهرية (2025) الملاحظات
Vercel Pro $20/مقعد لميزات والحدود من المستوى الاحترافي
عرض النطاق الترددي Vercel ~$55 ~600GB/الشهر مع ISR caching
وظائف Vercel بدون خادم ~$40 لعمل ISR + أشياء API
Neon PostgreSQL $19 (خطة Scale) 10GB تخزين، حساب قابل للتطوير
Meilisearch Cloud $30 500K مستندات، مثيل مخصص
Upstash Redis $10 10K أوامر/اليوم في المتوسط
Cloudinary $25 تخزين الصور والتحويلات
الإجمالي ~$199/الشهر لـ 137K صفحة، ~200K زوار شهري

أقل من 200 دولار/الشهر لتشغيل وحش بـ 137,000 صفحة. مقابل إعداد الخادم التقليدي؟ ستنزف أموالاً على VMs، DBs المدارة، CDNs، و DevOps بدوام كامل لرعايته الكاملة.

إذا كنت تلعب بهذا المقياس وتريد محادثة، تواصل معنا أو انظر إلى أسعارنا.

الأخطاء التي ارتكبناها وما سنغيره

الخطأ 1: عدم إعداد إعادة التحقق عند الطلب من اليوم الأول

اعتمدنا في البداية فقط على إعادة التحقق المحددة بالوقت. دعني أخبرك، خطأ سيء. كان أصحاب القوائم يعدلون معلوماتهم ويتحققون على الفور. رؤية البيانات القديمة؟ ليس معزز الثقة. احتاج التحقق إلى أن يكون MVP.

الخطأ 2: قلل من التقدير لتعقيد Sitemap

كانت محاولتنا الأولى في sitemap تجمع كل شيء في وظيفة serverless واحدة. اشتعل المهلة الزمنية. تعطيك Vercel 10 ثوان (60 على Pro) قبل انتهاء الحد الأقصى. تعلمنا. قسم تلك الأشياء.

الخطأ 3: تكاليف تحسين الصور

في البداية، تعاملت Vercel مع جميع تحسينات صور القوائم. كمية مجنونة من الصور تعنت تكاليف برية. قسمنا هذا الواجب مع Cloudinary، احتفظنا بسحر Vercel للضروريات في الواجهة الأمامية.

الخطأ 4: عدم استخدام React Server Components بقوة كافية

كانت بعض الصفحات الأولية مليئة بالكثير من أوامر 'use client'. النتيجة؟ الكثير من JavaScript مشحون. إعادة التركيز على Server Components جعل حزمة JavaScript الخاصة بنا خفيفة مثل الريشة (قطع 62%!).

ما سنفعله بشكل مختلف

المرة القادمة، سنقترن بالتأكيد Next.js مع شيء مثل Payload CMS من البداية بدلاً من اختراق لوحة تحكم الإدارة من الصفر. يا لها من موفرة للوقت!

سننظر أيضاً بعناية في unstable_cache الأحدث لـ Vercel (أو فقط cache الآن) لنتائج الاستعلامات بعيداً عن ISR caching القياسي.

الأسئلة الشائعة

هل يمكن لـ Next.js ISR حقاً التعامل مع مئات الآلاف من الصفحات؟
بالتأكيد. لقد مشينا المشي. أنشئ مسبقاً صفحات حركة المرور الأعلى (عادة 1-5%) باستخدام generateStaticParams واترك ISR للباقي. تتولى حافة Vercel من هناك، مما يضمن أوقات تحميل سريعة عالمياً.

كم يكلف تشغيل موقع دليل كبير على Vercel؟
بالنسبة لنا، حوالي 199 دولاراً/الشهر لـ 137K قوائم مع 200,000 زائر شهري. ستختلف التكاليف، بالتأكيد، لكن اضرب جنة الذاكرة المؤقتة، و ISR يمكن أن يوفر لك كثيراً.

ما الفرق بين ISR و SSR لمواقع الأدلة؟
ISR ينشئ الصفحات مرة واحدة لكل فترة إعادة التحقق ويخزنها مؤقتاً، بينما SSR ينشئ الصفحات من نقطة الصفر عند كل طلب. ISR أكثر كفاءة للقوائم حيث لا تتغير البيانات كل دقيقة.

كيف تتعامل مع البحث في دليل منتج ثابت؟
تذهب تفاعلات البحث مباشرة إلى Meilisearch، مع استدعاءات API للتغطية. يتم عرض نتائج البحث من جانب العميل، بينما صفحات القائمة يدعمها ISR. إنها أفضل مزيج من البحث والديناميكي.

ما فترة إعادة التحقق التي يجب أن أستخدمها لـ ISR على موقع دليل؟
يعتمد على تكرار التغيير. نستخدم نهجاً موضوعياً: 10 دقائق لأقساط، ساعة واحدة لمعايير، و 24 ساعة للقوائم الأكثر هدوءاً. رش في إعادة التحقق عند الطلب للتغييرات الفورية.

كيف تنشئ sitemaps لـ 137,000 صفحة دون انتهاء الحد الأقصى؟
التقسيم هو صديقك. قسمهم إلى قطع من 10,000. اجعلها تمر عبر فهرس sitemap. يجب أن تبقى كل قطعة مريحة ضمن حدود المهلة الزمنية.

هل Next.js أفضل إطار عمل لبناء منصات الأدلة؟
نعم، للاعبين الثقيلين--خاصة مع ISR. لقوائم بسيطة جداً ونادراً ما تتغير؟ يمكن أن يكون Astro خياراً خفيفاً. لقد حرفنا كليهما؛ يتوقف الاختيار على عبء العمل الخاص بك واحتياجاتك.

كيف تمنع البيانات القديمة من إيذاء تجربة المستخدم مع ISR؟
يساعد مزج إعادات التحقق المحددة بالوقت والطلب على الطلب. اقرن ذلك مع SWR من جانب العميل أو React Query للبيانات فائقة الطزاجة. يغذي ISR القشرة الخاصة بك بينما يتألق الوقت الفعلي انتقائياً.