feat(blog): implement loading and error handling components for blog pages feat(clinic): add loading and error handling components for clinic pages feat(doctor): create loading and error handling components for doctor pages feat(clinics): add loading component for clinics page feat(specialties): improve metadata for specialties page with Open Graph and Twitter images feat(layout): add structured data for Organization and WebSite in layout fix(middleware): restrict middleware execution to specific routes to improve performance chore(audit): add comprehensive SEO and performance audit documentation
16 KiB
بازبینی کامل Rendering Strategy، SEO، Performance و امنیت (Next.js 15)
پروژه
nobat724_front
زمینه
این سایت عمومی نوبتدهی (App Router، چند-شهری، RTL فارسی) چندین مشکل ساختاری دارد که هم روی SEO و هم روی performance اثر میگذارد:
- تمام fetchهای server-side با axios انجام میشوند (
lib/req.js→fetchReq/axiosInstance)، نهfetchبومی Next.js. این یعنی هیچکدام از قابلیتهایcache/revalidate/force-cache/no-storeNext.js کار نمیکنند — همهچیز عملاً همیشه SSR بدون cache است، حتی صفحاتی که میتوانند ISR باشند (مثل/doctor/[slug]). middleware.jsروی همه مسیرها (matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)') اجرا میشود تا فقط یک هدر (x-pathname) ست کند — این کار رندر استاتیک را در سطح کل سایت بهصورت اجباری به dynamic تبدیل میکند.- در
app/doctor/[slug]/page.js،generateMetadataو کامپوننتDoctor()هر دو مستقلGET /api/v1/doctor/${slug}را صدا میزنند — یعنی هر بار بازدید صفحه پزشک، ۲ بار درخواست یکسان به backend میرود (هیچ dedup باReact.cache()وجود ندارد چون axios است نه fetch). - هیچ
loading.js,error.js,template.jsدر کلapp/وجود ندارد — یعنی هیچ Suspense boundary یا error boundary واقعی در سطح route نیست؛ خطاهای fetch باcatchخاموش میشوند و صفحه با دادهnullرندر میشود. og:image/twitter:imageدر بسیاری صفحات به یک لوگوی استاتیک ثابت (https://www.nobat724.com/assets/images/logo.png) فالبک میکنند یا اصلاً ست نمیشوند (app/doctors/page.jsفقطtitle/descriptionدرopenGraphدارد، بدونimages).- JSON-LD فقط در
doctor/[slug]،clinic/[slug]،blog/[slug]هست؛ هیچOrganization,WebSite,BreadcrumbListدر سطح global (layout.js) وجود ندارد.
فایلهای مرتبط
| فایل | نقش |
|---|---|
lib/req.js |
fetchReq/axiosInstance — تمام server fetchها از اینجا رد میشوند |
middleware.js |
روی همه مسیرها اجرا میشود، رندر دینامیک سراسری تحمیل میکند |
app/layout.js |
generateMetadata ریشه؛ بدون JSON-LD سراسری (Organization/WebSite) |
app/doctors/page.js |
لیست پزشکان؛ SSR کامل، بدون cache/revalidate، بدون og:images |
app/doctor/[slug]/page.js |
دو فراخوانی تکراری به همان endpoint؛ JSON-LD ناقص (بدون @id, url) |
app/clinic/[slug]/page.js, app/blog/[slug]/page.js |
الگوی مشابه doctor/[slug] — باید با همان منطق بررسی شوند |
app/specialties/page.js, app/about-us/page.js, app/blogs/page.js, app/clinics/page.js |
از getStateInfo() برای متادیتا استفاده میکنند؛ محتوای نسبتاً ایستا اما بهصورت SSR رندر میشوند |
app/dashboard/page.js, app/panel/(layout)/layout.js |
پنل کاربری احرازشده — اینها باید SSR/CSR بمانند (داده per-user) |
lib/getStateInfo.js |
تشخیص شهر از subdomain — روی هر درخواست header میخواند، نمیتواند cache شود مگر با segment config درست |
app/sitemap.js, app/robots.js |
باید بررسی شوند که revalidate و فیلتر صفحات DEV_MODE درست تنظیم شده باشد |
app/globals.css, mui/index.js |
فونت Vazir، بررسی next/font بهجای @font-face دستی |
وضعیت فعلی
lib/req.js — مشکل اصلی caching
import axios from "axios";
import https from "https";
export const axiosInstance = axios.create({
...(process.env.NODE_ENV === "development" && {
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
}),
});
export const fetchReq = async (url, headers) => {
try {
const response = await axiosInstance.get(url, headers);
return response.data;
} catch (error) {
console.error("fetchReq error:", error.message);
return null;
}
};
app/doctor/[slug]/page.js — دو فراخوانی تکراری
export async function generateMetadata({ params }) {
const { slug } = await params;
// ...
const res = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`);
// ...
}
async function Doctor({ params }) {
const { slug } = await params;
// ...
const resDoctor = await axiosInstance.get(`${API_URL}/api/v1/doctor/${slug}`); // همان درخواست، دوباره
// ...
}
middleware.js — اجرا روی همه مسیرها
export function middleware(request) {
const response = NextResponse.next();
response.headers.set('x-pathname', request.nextUrl.pathname);
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
app/doctors/page.js — بدون og:images، بدون cache
export async function generateMetadata() {
// ...
return {
title,
description,
openGraph: { title, description }, // بدون images
};
}
async function Doctors({ searchParams }) {
// ...
doctors = await fetchReq(`${API_URL}/api/v1/doctors`, { params }); // هر بار fresh، بدون revalidate
// ...
}
وظایف
۱. جایگزینی fetch لایهی axios با fetch بومی Next.js (یا wrapper روی آن) برای فراخوانیهای Server Component
برای صفحاتی که قابل ISR هستند (doctor/[slug], clinic/[slug], blog/[slug], specialties, about-us)، بهجای axiosInstance.get/fetchReq از fetch با next.revalidate استفاده کن:
async function getDoctor(slug) {
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
next: { revalidate: 3600, tags: [`doctor-${slug}`] },
});
if (!res.ok) return null;
const json = await res.json();
return json?.data?.data;
}
- برای صفحاتی که داده per-user/per-session دارند (
dashboard,panel/*,appointment/[doctorId]در حالت لاگینشده) ازcache: 'no-store'یا اصلاً تغییری در رویکرد SSR فعلی نده. - چون مسیر axios هنوز برای client-side (
services/api.js/services/response.js) لازم است، این تغییر را فقط در فایلهای Server Component (app/.../page.js) اعمال کن — axios در client services دست نخورد.
۲. حذف فراخوانی تکراری در doctor/[slug]/page.js (و الگوی مشابه در clinic/[slug], blog/[slug])
از React.cache() برای dedup بین generateMetadata و کامپوننت صفحه استفاده کن:
import { cache } from "react";
const getDoctor = cache(async (slug) => {
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const json = await res.json();
return json?.data?.data ?? null;
});
export async function generateMetadata({ params }) {
const { slug } = await params;
const doctor = await getDoctor(slug);
// ...
}
async function Doctor({ params }) {
const { slug } = await params;
const doctor = await getDoctor(slug); // همان نتیجه cacheشده، بدون درخواست دوم
// ...
}
بررسی کن همین الگو در app/clinic/[slug]/page.js و app/blog/[slug]/page.js هم تکرار شده یا نه و در صورت وجود اصلاح کن.
۳. بازبینی middleware.js — محدود کردن matcher یا حذف وابستگی غیرضروری
اگر x-pathname فقط برای getCanonicalUrl() لازم است، بررسی کن آیا میتوان canonical را بدون middleware (مثلاً از headers() در خود generateMetadata با request.url معادل App Router، یا با محاسبه از params/segment) ساخت. اگر middleware واقعاً لازم است، matcher را به مسیرهایی که واقعاً به canonical نیاز دارند محدود کن (نه همهی سایت):
export const config = {
matcher: [
'/doctor/:path*',
'/clinic/:path*',
'/blog/:path*',
'/doctors',
'/clinics',
'/blogs',
'/specialties',
],
};
مستندسازی کن که این تغییر چه صفحاتی را از حالت force-dynamic خارج میکند.
۴. افزودن JSON-LD سراسری در app/layout.js
Organization و WebSite schema را یکبار در ریشه اضافه کن (نه در هر صفحه):
const orgJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: matchedCity?.site_name || "نوبت 724",
url: "https://www.nobat724.com",
logo: "https://www.nobat724.com/assets/images/logo.png",
};
const websiteJsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
url: "https://www.nobat724.com",
potentialAction: {
"@type": "SearchAction",
target: "https://www.nobat724.com/doctors?search={search_term_string}",
"query-input": "required name=search_term_string",
},
};
و BreadcrumbList در صفحات تکآیتمی (doctor/[slug], clinic/[slug], blog/[slug]) کنار JSON-LD موجود اضافه کن:
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: "https://www.nobat724.com" },
{ "@type": "ListItem", position: 2, name: "پزشکان", item: "https://www.nobat724.com/doctors" },
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
],
};
۵. تکمیل og:image/twitter:image در همه صفحات
app/doctors/page.js: اضافه کنimages: ["https://www.nobat724.com/assets/images/logo.png"](یا تصویر مرتبطتر اگر موجود است) بهopenGraphوtwitter.app/doctor/[slug]/page.js: مقدارdoctor.imgرا قبل از استفاده درimagesباimageUrl()(ازhelper/index.js) absolute کن — همان helper که در کار قبلی avatar استفاده شد — چون ممکن است relative path باشد و در og:image کرول نشود:
import { imageUrl } from "@/helper";
// ...
images: [imageUrl(doctor.img) || "https://www.nobat724.com/assets/images/logo.png"],
- همین بررسی را برای
app/clinic/[slug]/page.jsوapp/blog/[slug]/page.jsانجام بده.
۶. اضافه کردن loading.js برای مسیرهای دادهمحور
برای app/doctors/, app/doctor/[slug]/, app/clinics/, app/clinic/[slug]/, app/blogs/, app/blog/[slug]/ یک loading.js با اسکلت متناسب با CircularLoading/TextLoading/CustomLoading موجود در app/component/loading/ بساز (این کامپوننتها همین الان هم بهصورت دستی در صفحات استفاده میشوند؛ هدف اینجا یک Suspense boundary واقعی در سطح route است، نه تغییر کامپوننتهای فعلی).
۷. اضافه کردن error.js در سطح root و برای مسیرهای پرتقاضا
یک app/error.js (Client Component با "use client") برای گرفتن خطاهای رندر، و یک error.js در app/doctor/[slug]/ برای حالتی که fetch واقعاً fail میکند (بهجای برگرداندن null خاموش):
"use client";
export default function Error({ error, reset }) {
return (
<div className="p-8 text-center" dir="rtl">
<p>مشکلی پیش آمد. لطفاً دوباره تلاش کنید.</p>
<button onClick={() => reset()}>تلاش دوباره</button>
</div>
);
}
۸. بررسی app/sitemap.js و app/robots.js
تأیید کن:
sitemap.jsاز همان axios/fetchReq استفاده نمیکند بدون cache (احتمال timeout زیر بار)؛ در صورت لزوم به fetch بومی باrevalidateبزرگ (مثلاً ۲۴ ساعت) تغییر بده.- صفحات
panel/*وdashboardدر sitemap نباشند (نیاز auth دارند). robots.jsدر حالتDEV_MODE=TRUEهمهچیز را drop میکند (طبقCLAUDE.mdهمین الان این رفتار مستند است) — فقط تأیید کن پیادهسازی با مستندات همخوان است.
۹. گزارش نهایی بهصورت جدول
برای هر صفحهی زیر جدول را تکمیل کن — این لیست کامل صفحات app/ پروژه است (تمام موارد را پوشش بده، هیچکدام را رد نکن):
/, /about-us, /contact-us, /specialties, /blogs, /blog/[slug], /clinics, /clinic/[slug], /doctors, /doctor/[slug], /appointment/[doctorId], /login, /login-verify, /dashboard, /panel/add-doctor, /panel/dashboard (route group), /panel/turns, /panel/user-account, /payment/[uuid], /payment/result
| Page | Current Strategy | Recommended Strategy | Reason | SEO Impact | Performance Impact |
|---|
نکات مهم
- هیچ تغییری در
services/api.js/services/response.js(مسیر axios سمت کلاینت) ندهی — فقط فراخوانیهای Server Component (app/.../page.js) که باaxiosInstance/fetchReqکار میکنند هدف این پرامپت هستند. - صفحات
panel/*وdashboardچون نیاز به session/JWT کاربر دارند و داده per-user است، باید SSR/dynamic بمانند — این صفحات را به ISR/SSG تبدیل نکن؛ فقط در گزارش جدول توضیح بده چرا. getStateInfo()بهhostheader وابسته است (تشخیص subdomain چندشهری) — این یعنی صفحاتی که از آن استفاده میکنند (generateMetadataهمه صفحات public) را نمیتوان بهطور کامل static کرد مگر باgenerateStaticParamsمحدود به دامنههای شناختهشده درdata/city.json؛ اگر چنین تغییری پیشنهاد میشود، توضیح بده trade-off چندشهری بودن چیست.- پس از هر تغییر در
app/.../page.js، طبق قانون پروژه (CLAUDE.md): «همیشهawait params» را رعایت کن — این الگو همین الان در همه فایلها هست، نشکن. - بعد از تغییرات، حتماً
npm run buildرا اجرا کن و خروجی Route را بررسی کن — ستونSize/First Load JSباید تغییر معنادار (کاهش یا حداقل عدم افزایش) داشته باشد. npm run lintدر این پروژه به دلیل عدم migrate شدن ازnext lintبه ESLint CLI، interactive میپرسد و کار نمیکند (مشکل از قبل موجود، نه نتیجه این تغییرات) — برای validation فقط بهnpm run buildتکیه کن.- تمام متنهای جدید (پیام خطا، loading text و غیره) باید فارسی و RTL باشند، مطابق بقیهی پروژه.