Tune caching: shorter revalidate, lighter fetches, on-demand purge.

Use paginated list fetches with stripped list payloads, recent-item helpers for carousels, fetch tags + POST /api/revalidate, and 5-minute page ISR instead of 1 hour.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-14 11:32:18 +03:30
co-authored by Cursor
parent f341b010c0
commit 02c7dd51d2
13 changed files with 227 additions and 52 deletions
+4
View File
@@ -0,0 +1,4 @@
# Runtime env (set in PM2 ecosystem on the websites VM — not committed)
# Secret for POST /api/revalidate (on-demand cache purge after CMS publish)
REVALIDATE_SECRET=
+4 -2
View File
@@ -14,7 +14,9 @@ import { Reveal } from "@/components/Reveal";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { aboutPage } from "@/data/site";
import { getPortfolios, toDisplayProject } from "@/lib/api";
import { getRecentPortfolios, toDisplayProject } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export const metadata: Metadata = {
title: "درباره ما",
@@ -23,7 +25,7 @@ export const metadata: Metadata = {
};
export default async function AboutPage() {
const portfolios = await getPortfolios();
const portfolios = await getRecentPortfolios(12);
const displayProjects = portfolios.map(toDisplayProject);
return (
+69
View File
@@ -0,0 +1,69 @@
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
type RevalidateBody = {
/** Revalidate a fetch cache tag (portfolios, blogs, portfolio:slug, blog:slug, …). */
tag?: string;
/** Revalidate a path (/portfolios/foo, /, …). */
path?: string;
/** Shorthand: portfolios | blogs | all */
scope?: "portfolios" | "blogs" | "all";
};
/**
* On-demand cache purge — call after CMS publish/update.
*
* POST /api/revalidate
* Header: x-revalidate-secret: <REVALIDATE_SECRET>
* Body (JSON, optional): { "scope": "all" } | { "tag": "portfolios" } | { "path": "/portfolios/item" }
*
* nginx may still serve cached HTML for up to its TTL (5m) unless purged separately.
*/
export async function POST(req: NextRequest) {
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
return NextResponse.json({ ok: false, error: "REVALIDATE_SECRET not configured" }, { status: 503 });
}
const provided =
req.headers.get("x-revalidate-secret") ?? req.nextUrl.searchParams.get("secret");
if (provided !== secret) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
let body: RevalidateBody = {};
try {
body = (await req.json()) as RevalidateBody;
} catch {
body = {};
}
const scope = body.scope ?? "all";
const revalidated: string[] = [];
if (body.tag) {
revalidateTag(body.tag);
revalidated.push(`tag:${body.tag}`);
} else if (body.path) {
revalidatePath(body.path);
revalidated.push(`path:${body.path}`);
} else if (scope === "portfolios" || scope === "all") {
revalidateTag("portfolios");
revalidatePath("/");
revalidatePath("/portfolios");
revalidatePath("/aboutus");
revalidatePath("/our-services");
revalidated.push("scope:portfolios");
}
if (scope === "blogs" || scope === "all") {
revalidateTag("blogs");
revalidateTag("blogs:news");
revalidatePath("/news");
revalidatePath("/blog");
revalidatePath("/news-and-blog");
revalidated.push("scope:blogs");
}
return NextResponse.json({ ok: true, revalidated, at: new Date().toISOString() });
}
+4 -4
View File
@@ -4,9 +4,9 @@ import { notFound } from "next/navigation";
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getBlogArticles, getBlogBySlug, toDisplayNewsItem } from "@/lib/api";
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
async function getBlogArticleBySlug(slug: string) {
const item = await getBlogBySlug(slug);
@@ -17,8 +17,8 @@ async function getBlogArticleBySlug(slug: string) {
}
export async function generateStaticParams() {
const posts = await getBlogArticles();
return posts.map((item) => ({ slug: item.slug }));
const slugs = await getBlogSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
+1 -1
View File
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
description: "فهرست کامل مقالات منتشرشده‌ی گروه ساختمانی رئوفی.",
};
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export default async function BlogListPage({
searchParams,
+1 -1
View File
@@ -18,7 +18,7 @@ export const metadata: Metadata = {
description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.",
};
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export default async function NewsAndBlogPage() {
const posts = await getBlogs();
+4 -4
View File
@@ -4,9 +4,9 @@ import { notFound } from "next/navigation";
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getBlogBySlug, getBlogs, toDisplayNewsItem } from "@/lib/api";
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
async function getNewsBySlug(slug: string) {
const item = await getBlogBySlug(slug);
@@ -17,8 +17,8 @@ async function getNewsBySlug(slug: string) {
}
export async function generateStaticParams() {
const posts = await getBlogs("news");
return posts.map((item) => ({ slug: item.slug }));
const slugs = await getBlogSlugs("news");
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
+1 -1
View File
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
description: "فهرست کامل اخبار و رویدادهای منتشرشده‌ی گروه ساختمانی رئوفی.",
};
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export default async function NewsListPage({
searchParams,
+4 -2
View File
@@ -11,7 +11,9 @@ import { ProjectGallery } from "@/components/shared/ProjectGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { servicesPage } from "@/data/site";
import { getPortfolios, toDisplayProject } from "@/lib/api";
import { getRecentPortfolios, toDisplayProject } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export const metadata: Metadata = {
title: "خدمات ما",
@@ -20,7 +22,7 @@ export const metadata: Metadata = {
};
export default async function ServicesPage() {
const portfolios = await getPortfolios();
const portfolios = await getRecentPortfolios(12);
const displayProjects = portfolios.map(toDisplayProject);
return (
+5 -3
View File
@@ -8,7 +8,9 @@ import { Services } from "@/components/Services";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { contact, site } from "@/data/site";
import { getBlogs, getPortfolios, toDisplayNewsItem, toDisplayProject } from "@/lib/api";
import { getRecentNews, getRecentPortfolios, toDisplayNewsItem, toDisplayProject } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
const organizationJsonLd = {
"@context": "https://schema.org",
@@ -29,9 +31,9 @@ const organizationJsonLd = {
};
export default async function HomePage() {
const [portfolios, newsPosts] = await Promise.all([getPortfolios(), getBlogs("news")]);
const [portfolios, newsPosts] = await Promise.all([getRecentPortfolios(12), getRecentNews(3)]);
const displayProjects = portfolios.map(toDisplayProject);
const displayNews = newsPosts.slice(0, 3).map(toDisplayNewsItem);
const displayNews = newsPosts.map(toDisplayNewsItem);
return (
<>
+4 -4
View File
@@ -9,13 +9,13 @@ import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { portfoliosPage, projectVideos } from "@/data/site";
import { getPortfolioBySlug, getPortfolios, toDisplayProject } from "@/lib/api";
import { getPortfolioBySlug, getPortfolioSlugs, toDisplayProject } from "@/lib/api";
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export async function generateStaticParams() {
const portfolios = await getPortfolios();
return portfolios.map((item) => ({ slug: item.slug }));
const slugs = await getPortfolioSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
+1 -1
View File
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
description: "نمونه‌کارهای منتشرشده‌ی گروه ساختمانی رئوفی، از پروژه‌های مسکونی تا برج‌های اداری-تجاری.",
};
export const revalidate = 3600;
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export default async function PortfoliosPage() {
const portfolios = await getPortfolios();
+125 -29
View File
@@ -17,8 +17,15 @@ import { cache } from "react";
const API_BASE = "https://api.meshkee.com/api/v1";
const SITE_DOMAIN = "raoofigroup.com";
/** How long fetched portfolio data is cached before Next.js revalidates it. */
const REVALIDATE_SECONDS = 3600;
/** Page-level ISR — kept in sync with fetch revalidate below. */
export const PAGE_REVALIDATE_SECONDS = 300;
/** List endpoints refresh more often; detail payloads are heavier so TTL is slightly longer. */
const REVALIDATE_LIST_SECONDS = PAGE_REVALIDATE_SECONDS;
const REVALIDATE_DETAIL_SECONDS = 600;
/** Default page size when paginating tenant list endpoints. */
const LIST_PAGE_SIZE = 24;
/**
* api.meshkee.com has been observed to hang for minutes at a time rather than
@@ -59,11 +66,19 @@ type PortfoliosResponse = {
pageSize: number;
};
async function apiFetch(path: string): Promise<Response> {
type FetchOptions = {
revalidate?: number;
tags?: string[];
};
async function apiFetch(path: string, options?: FetchOptions): Promise<Response> {
try {
return await fetch(`${API_BASE}${path}`, {
headers: { Accept: "application/json" },
next: { revalidate: REVALIDATE_SECONDS },
next: {
revalidate: options?.revalidate ?? REVALIDATE_LIST_SECONDS,
tags: options?.tags,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (cause) {
@@ -71,8 +86,8 @@ async function apiFetch(path: string): Promise<Response> {
}
}
async function apiGet<T>(path: string): Promise<T> {
const res = await apiFetch(path);
async function apiGet<T>(path: string, options?: FetchOptions): Promise<T> {
const res = await apiFetch(path, options);
if (!res.ok) {
throw new Error(`meshkee API ${path} responded ${res.status}`);
@@ -81,8 +96,8 @@ async function apiGet<T>(path: string): Promise<T> {
return res.json() as Promise<T>;
}
async function apiGetOptional<T>(path: string): Promise<T | null> {
const res = await apiFetch(path);
async function apiGetOptional<T>(path: string, options?: FetchOptions): Promise<T | null> {
const res = await apiFetch(path, options);
if (res.status === 404) {
return null;
@@ -95,27 +110,81 @@ async function apiGetOptional<T>(path: string): Promise<T | null> {
return res.json() as Promise<T>;
}
/** All published portfolio items, every page merged, in the CMS's own order. */
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
const first = await apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios`);
/** Drop body HTML and gallery arrays from list responses — not needed for cards/grids. */
function stripListHeavyFields<T extends ApiPortfolioItem | ApiBlogItem>(item: T): T {
return {
...item,
mainTextHtml: "",
gallery: [],
galleryMediaIds: [],
};
}
async function fetchAllPages<T extends { items: unknown[]; total: number; pageSize: number }>(
basePath: string,
options?: FetchOptions,
): Promise<T["items"]> {
const first = await apiGet<T>(basePath, options);
const pageCount = Math.ceil(first.total / first.pageSize);
const rest = await Promise.all(
Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) =>
apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios?page=${i + 2}`),
),
Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) => {
const separator = basePath.includes("?") ? "&" : "?";
return apiGet<T>(`${basePath}${separator}page=${i + 2}`, options);
}),
);
return [first, ...rest]
.flatMap((page) => page.items)
return [first, ...rest].flatMap((page) => page.items);
}
/** All published portfolio items, every page merged, in the CMS's own order. */
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
const items = await fetchAllPages<PortfoliosResponse>(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
{ tags: ["portfolios"] },
);
return (items as ApiPortfolioItem[])
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder);
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(stripListHeavyFields);
});
/** Recent published portfolios for home / about / services carousels. */
export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfolioItem[]> => {
const pageSize = Math.min(Math.max(limit, 1), 50);
const first = await apiGet<PortfoliosResponse>(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
{ tags: ["portfolios"] },
);
return first.items
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder)
.slice(0, limit)
.map(stripListHeavyFields);
});
/** Slugs only — for generateStaticParams without retaining full list payloads. */
export const getPortfolioSlugs = cache(async (): Promise<string[]> => {
const items = await fetchAllPages<PortfoliosResponse>(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
{ tags: ["portfolios"] },
);
return (items as ApiPortfolioItem[])
.filter((item) => item.status === "published")
.map((item) => item.slug);
});
/** Single portfolio by slug — avoids loading the full list on detail pages. */
export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => {
const data = await apiGetOptional<{ portfolio: ApiPortfolioItem }>(
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
{
revalidate: REVALIDATE_DETAIL_SECONDS,
tags: ["portfolios", `portfolio:${slug}`],
},
);
const item = data?.portfolio;
@@ -234,30 +303,57 @@ type BlogsResponse = {
pageSize: number;
};
function blogListPath(type?: BlogType): string {
const query = type ? `?type=${type}` : "";
const prefix = `/tenants/${SITE_DOMAIN}/blogs${query}`;
const separator = query ? "&" : "?";
return `${prefix}${separator}page=1&pageSize=${LIST_PAGE_SIZE}`;
}
/** All published blog/news items, every page merged. Optionally filtered by `type`. */
export const getBlogs = cache(async (type?: BlogType): Promise<ApiBlogItem[]> => {
const query = type ? `?type=${type}` : "";
const first = await apiGet<BlogsResponse>(`/tenants/${SITE_DOMAIN}/blogs${query}`);
const pageCount = Math.ceil(first.total / first.pageSize);
const tag = type ? `blogs:${type}` : "blogs";
const items = await fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] });
const rest = await Promise.all(
Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) =>
apiGet<BlogsResponse>(
`/tenants/${SITE_DOMAIN}/blogs${query}${query ? "&" : "?"}page=${i + 2}`,
),
),
return (items as ApiBlogItem[])
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(stripListHeavyFields);
});
/** Recent news posts for the home page carousel. */
export const getRecentNews = cache(async (limit = 3): Promise<ApiBlogItem[]> => {
const pageSize = Math.min(Math.max(limit, 1), 50);
const first = await apiGet<BlogsResponse>(
`/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`,
{ tags: ["blogs", "blogs:news"] },
);
return [first, ...rest]
.flatMap((page) => page.items)
return first.items
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder);
.sort((a, b) => a.sortOrder - b.sortOrder)
.slice(0, limit)
.map(stripListHeavyFields);
});
/** Slugs for generateStaticParams. */
export const getBlogSlugs = cache(async (type?: BlogType): Promise<string[]> => {
const tag = type ? `blogs:${type}` : "blogs";
const items = await fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] });
return (items as ApiBlogItem[])
.filter((item) => item.status === "published" && (type ? item.type === type : item.type !== "news"))
.map((item) => item.slug);
});
/** Single blog/news item by slug — avoids loading the full list on detail pages. */
export const getBlogBySlug = cache(async (slug: string): Promise<ApiBlogItem | null> => {
const data = await apiGetOptional<{ blog: ApiBlogItem }>(
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
{
revalidate: REVALIDATE_DETAIL_SECONDS,
tags: ["blogs", `blog:${slug}`],
},
);
const item = data?.blog;