Performance, caching, and frontend integration
Performance, caching, and frontend integration
TL;DR
- Contentstack's CDN invalidates on publish -- no manual cache purge needed for the delivery layer, but your own application caches (Redis, edge, in-memory) need webhook-driven invalidation.
- Eliminate N+1 queries by using includeReference() for related entries and Promise.all for independent content type fetches.
- Match rendering mode to content volatility: SSG for stable pages, ISR for periodically updated content, SSR only when freshness on every request is non-negotiable.
Fetching content from Contentstack is straightforward. Fetching it efficiently - with the right caching strategy, the right rendering mode, and the right data-fetching patterns - is where production quality lives. This lesson covers the caching behavior of Contentstack's CDN, client-side caching strategies, framework integration patterns, and the common performance mistakes that slow down headless sites.
The running example throughout this lesson is
CDN behavior for the Contentstack Delivery API
Contentstack's Content Delivery API sits behind a CDN. When you make a GET request to https://cdn.contentstack.io/v3/content_types/product/entries, the response is served from an edge node close to the user. Subsequent identical requests are served from cache until the cache is invalidated.
Cache invalidation on publish
Cache invalidation is tied to the publish action. When an editor publishes or unpublishes an entry, Contentstack purges the relevant CDN cache entries. This means:
- After publish: New content is available within seconds globally. The CDN evicts stale entries and subsequent requests hit the origin, which returns the updated content.
- Between publishes: Content is served from cache. Identical API calls resolve at the CDN edge without hitting Contentstack's origin servers.
- Draft changes: Saving a draft does not affect the delivery cache. Only the publish action triggers cache invalidation.
This publish-driven invalidation model is why Contentstack recommends the Delivery API for production traffic. The CDN handles read scale, and cache freshness is managed through the editorial workflow.
Cache-Control headers
Contentstack's Delivery API responses include Cache-Control headers. The exact values depend on the request, but typical behavior is Cache-Control: public, max-age=0, must-revalidate.
The max-age=0 combined with must-revalidate means downstream caches (browser, reverse proxy) should revalidate on every request. Contentstack's own CDN handles the primary caching layer; it does not intend for browsers to cache API responses long-term on their own.
In practice, this means:
- Browser requests always check with the CDN edge.
- The CDN edge serves from its cache if the content has not been republished.
- There is no stale browser cache problem - the CDN is the source of truth for freshness.
If you need more aggressive client-side caching, implement it in your application layer. The Contentstack CDN does the heavy lifting, but your application can add another caching tier on top.
Client-side caching strategies
Depending on your rendering architecture, you have several options for caching Contentstack responses closer to the user.
Stale-while-revalidate
The stale-while-revalidate pattern serves cached content immediately while asynchronously fetching fresh content in the background. This is ideal for content that updates periodically but where a few seconds of staleness is acceptable.
// A minimal stale-while-revalidate cache for Contentstack responses const cache = new Map(); const STALE_THRESHOLD_MS = 60_000; // 1 minute async function fetchWithSWR (cacheKey: string, fetcher: () => Promise ): Promise { const cached = cache.get(cacheKey); const now = Date.now(); if (cached) { if (now - cached.timestamp < STALE_THRESHOLD_MS) { return cached.data as T; } // Serve stale, revalidate in background fetcher().then((fresh) => { cache.set(cacheKey, { data: fresh, timestamp: Date.now() }); }); return cached.data as T; } const data = await fetcher(); cache.set(cacheKey, { data, timestamp: now }); return data; } // Usage for a Veda product const product = await fetchWithSWR( `product:${slug}`, () => fetchProductBySlug(slug) );
For e-commerce sites, this pattern means a visitor sees the cached product immediately, and if the product was updated since their last visit, the next page load shows the fresh version.
Static site generation (SSG)
Static generation pre-renders pages at build time. Each page makes its Contentstack API calls during the build, and the resulting HTML files are deployed to a CDN. No API calls happen at runtime.
Advantages for an e-commerce site:
- Pages load instantly from the static CDN.
- No runtime dependency on the Contentstack API.
- Excellent for SEO - pages are fully rendered HTML.
Disadvantage: content updates require a rebuild. For a campaign site with time-sensitive launches, a rebuild pipeline that takes 5 minutes means the new content is 5 minutes stale.
Incremental static regeneration (ISR)
ISR is a hybrid: pages are statically generated but can be regenerated on demand or after a time interval. This combines the performance of SSG with the freshness of server-side rendering.
In a Next.js context:
// app/products/[line]/[slug]/page.tsx
import Contentstack from "@contentstack/delivery-sdk";
const stack = Contentstack.stack({
apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!,
deliveryToken: process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN!,
environment: "production",
region: Contentstack.Region.US,
});
async function getProduct(line: string, slug: string) {
const query = stack.contentType("product").entry().query();
const result = await query
.equalTo("url", `/products/${line}/${slug}`)
.includeReference("product_line")
.includeReference("category")
.find();
return result.entries[0] ?? null;
}
export const revalidate = 60; // Regenerate page every 60 seconds
export default async function ProductPage({
params,
}: {
params: { line: string; slug: string };
}) {
const product = await getProduct(params.line, params.slug);
if (!product) return <div>Product not found</div>;
return (
);
}The revalidate = 60 tells the engine to serve the cached page and regenerate it in the background at most every 60 seconds. For campaign launches, you might reduce this to 10 seconds. For evergreen product pages, 3600 seconds (one hour) might be appropriate.
Server-side rendering (SSR)
SSR generates the page on every request. Each visitor triggers a Contentstack API call, and the response is rendered into HTML on the server before being sent to the browser.
SSR guarantees the freshest content but introduces latency (API call + render) on every page load. For a Veda homepage that updates with new campaigns, SSR ensures no visitor sees stale hero content. For product pages that rarely change, SSR wastes resources.
Framework integration patterns
Next.js (App Router)
Next.js App Router uses React Server Components. Data fetching happens in server components by default, and Next.js handles caching through its fetch cache and route segment configuration.
// lib/contentstack.ts - centralized Contentstack client
import Contentstack from "@contentstack/delivery-sdk";
export const stack = Contentstack.stack({
apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!,
deliveryToken: process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN!,
environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!,
region: Contentstack.Region.US,
});
export async function getHomepageContent() {
const [pageResult, digitalDawnResult, earringsResult] = await Promise.all([
stack.contentType("page").entry().query().equalTo("url", "/").find(),
stack
.contentType("product")
.entry()
.query()
.equalTo("product_line", "blt_digital_dawn_001")
.orderByDescending("created_at")
.limit(4)
.includeReference("product_line")
.find(),
stack
.contentType("product")
.entry()
.query()
.equalTo("category", "blt_earrings_001")
.orderByDescending("created_at")
.limit(4)
.includeReference("category")
.find(),
]);
return {
hero: pageResult.entries[0]?.components?.[0],
digitalDawn: digitalDawnResult.entries,
earrings: earringsResult.entries,
};
}// app/page.tsx - homepage server component
import { getHomepageContent } from "@/lib/contentstack";
export const revalidate = 30;
export default async function HomePage() {
const { hero, digitalDawn, earrings } = await getHomepageContent();
return (
{digitalDawn.map((product) => (
))}
{earrings.map((product) => (
))}
);
}The Promise.all call completes queries simultaneously, reducing total data-fetch time compared to sequential blocking calls. This is a key pattern for pages that assemble content from multiple queries.
Nuxt
Nuxt 3 uses useAsyncData for server-side data fetching with built-in caching:
// composables/useContentstack.ts
import Contentstack from "@contentstack/delivery-sdk";
const stack = Contentstack.stack({
apiKey: useRuntimeConfig().public.contentstackApiKey,
deliveryToken: useRuntimeConfig().public.contentstackDeliveryToken,
environment: useRuntimeConfig().public.contentstackEnvironment,
region: Contentstack.Region.EU,
});
export function useProductsByCategory(categoryUid: string) {
return useAsyncData(`products-${categoryUid}`, async () => {
const query = stack.contentType("product").entry().query();
const result = await query
.equalTo("category", categoryUid)
.orderByDescending("created_at")
.limit(10)
.includeReference("product_line")
.find();
return result.entries;
});
}
const route = useRoute();
const { data: products } = useProductsByCategory(route.params.slug as string);
Nuxt's useAsyncData deduplicates requests: if multiple components request the same data during SSR, only one API call is made. The data is serialized into the page payload so the client does not re-fetch.
Astro
Astro defaults to static generation with opt-in SSR. Content from Contentstack is fetched at build time:
---
// src/pages/products/[line]/[slug].astro
import Contentstack from "@contentstack/delivery-sdk";
const stack = Contentstack.stack({
apiKey: import.meta.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY,
deliveryToken: import.meta.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN,
environment: "production",
region: Contentstack.Region.US,
});
export async function getStaticPaths() {
const result = await stack.contentType("product").entry().query().find();
return result.entries.map((product) => ({
params: { line: product.url.split("/")[2], slug: product.url.split("/")[3] },
props: { product },
}));
}
const { product } = Astro.props;
---
Astro's getStaticPaths fetches all products at build time and generates a static page for each. For a Veda catalog with hundreds of products, combine this with Astro's on-demand rendering for featured products and static generation for the full catalog.
Webhook-triggered rebuilds for static sites
Static sites need a mechanism to rebuild when content changes. Contentstack webhooks provide this trigger.
Configure a webhook in Contentstack (Settings > Webhooks) that fires on entry publish and unpublish events. Point it at your hosting platform's build hook:
- Vercel integrations
- Netlify build hooks
- Cloudflare Pages configurations
- Vercel: https://api.vercel.com/v1/integrations/deploy/{deploy-hook-id}
- Netlify: https://api.netlify.com/build_hooks/{hook-id}
- Cloudflare Pages: Use a Worker to trigger a build via the Pages API.
For a Veda site, configure the webhook to fire only for the product, page, and product_line content types to avoid unnecessary rebuilds when editors update internal reference data.
With webhook-triggered rebuilds, the flow is: editor publishes product → Contentstack fires webhook → hosting platform triggers rebuild → new static site deploys in 30-120 seconds → visitors see updated content.
For ISR-based sites, webhooks can call an explicit on-demand revalidation endpoint instead of triggering a full rebuild:
// app/api/revalidate/route.ts (Next.js)
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get("x-webhook-secret");
if (secret !== process.env.NEXT_PUBLIC_CONTENTSTACK_WEBHOOK_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const entryUrl = body?.data?.entry?.url;
if (entryUrl) {
revalidatePath(entryUrl);
return NextResponse.json({ revalidated: true, path: entryUrl });
}
revalidatePath("/");
return NextResponse.json({ revalidated: true, path: "/" });
}This approach revalidates only the affected page rather than rebuilding the entire site.
Avoiding N+1 query patterns
The N+1 problem occurs when your code makes one query to get a list of entries, then N additional queries to resolve data for each entry. This is the most common performance killer in headless CMS integrations.
The problem
// BAD: N+1 pattern
const productsResult = await stack
.contentType("product")
.entry()
.query()
.limit(20)
.find();
// This makes 20 additional API calls!
const productsWithLines = await Promise.all(
productsResult.entries.map(async (product) => {
const productLine = await stack
.contentType("product_line")
.entry(product.product_line[0].uid)
.fetch();
return { ...product, productLineData: productLine };
})
);Twenty products means twenty product line fetches. For a homepage with multiple content strips, each strip with its own N+1 pattern, you might make 50+ API calls to render a single page.
The solution: batch reference resolution
Use includeReference() to resolve references in the original query.
// GOOD: Single query with included references
const productsResult = await stack
.contentType("product")
.entry()
.query()
.limit(20)
.includeReference("product_line")
.includeReference("category")
.find();
// Product lines and categories are already resolved in each entry
productsResult.entries.forEach((product) => {
console.log(product.title, "from", product.product_line[0].title);
console.log("Category:", product.category[0].title);
});One API call instead of 21. The CDN caches this single response, making subsequent page loads even faster.
When you cannot use includes
If the data you need spans multiple unrelated content types (not connected by references), use Promise.all to parallelize the queries rather than making them sequentially:
// Parallel independent queries for the Veda homepage
const [products, productLines, categories] = await Promise.all([
stack.contentType("product").entry().query().limit(12).find(),
stack.contentType("product_line").entry().query().find(),
stack.contentType("category").entry().query().find(),
]);Three API calls in parallel complete in the time of the slowest call, not the sum of all three.
Edge caching considerations
Edge computing platforms (Cloudflare Workers, Vercel Edge Functions, Deno Deploy) execute code close to the user. When paired with Contentstack, you get specialized behaviors:
- Edge-cached API responses: Cache Contentstack API responses at the edge with a short TTL. This reduces latency compared to fetching from the nearest Contentstack CDN node.
- Edge rendering: Render HTML at the edge using cached Contentstack data. The user gets fully rendered HTML from the nearest edge node.
- Purge coordination: If you cache at the edge, you need a mechanism to purge when content is published. Contentstack webhooks can trigger edge cache purges.
The trade-off is complexity. Edge caching adds another layer to manage, another cache to invalidate, and another source of staleness. For most e-commerce sites like Veda, Contentstack's CDN plus framework-level caching (ISR or SWR) provides sufficient performance without custom edge caching.
When to use SSR vs SSG vs ISR
| Rendering mode | Best for | Content freshness | Performance |
| SSG | Archive pages, reference content, documentation | Only updated on rebuild | Fastest - served from static CDN |
| ISR | Articles, product pages, category pages | Updated within revalidation window (10s-3600s) | Fast - cached HTML, periodic refresh |
| SSR | Breaking news homepage, personalized feeds, search results | Always fresh | Depends on API latency + render time |
For a Veda e-commerce site, a practical split follows this distribution matrix:
- Homepage: ISR with 30-second revalidation, or SSR if campaign freshness is critical.
- Product pages: ISR with 60-second revalidation. Webhook-triggered revalidation for immediate updates.
- Category pages: ISR with 300-second revalidation or SSG with webhook-triggered rebuilds.
- Search results: SSR (search parameters vary per request; caching is impractical).
Common mistakes
Common pitfall:
Adding your own caching layer (Redis, in-memory, edge cache) without a webhook-driven purge mechanism means editors publish content that never appears on the site -- Contentstack only invalidates its own CDN, not yours.
Not invalidating application-level caches
Contentstack invalidates its own CDN on publish. But if your application adds its own caching layer (Redis, in-memory, edge cache) without a purge mechanism, editors publish content and it does not appear on the site. Always pair application caching with webhook-driven invalidation.
Over-fetching in SSR
Fetching all fields of all entries on every SSR request wastes bandwidth and increases Time to First Byte (TTFB). Use the only[] REST parameter or GraphQL to request only the fields your page needs. For a product listing, you need title, url, price, short_description, and media - not the full description HTML.
GET /v3/content_types/product/entries?environment=production&only[BASE][]=title&only[BASE][]=url&only[BASE][]=price&only[BASE][]=short_description&limit=20
N+1 queries from template loops
The N+1 pattern often hides inside template rendering. A loop over products that fetches product line data per iteration is invisible in code review but devastating to page load time. Audit data-fetching patterns: if any fetch call appears inside a loop or map, it is likely an N+1.
Ignoring error handling in data fetching
Contentstack API calls can fail (network issues, rate limits, token expiry). SSR pages that do not handle fetch errors crash the entire page. Always wrap Contentstack calls in standard block catch logic and provide fallback route behavior:
async function getProductsSafe(categoryUid: string) {
try {
const query = stack.contentType("product").entry().query();
const result = await query
.equalTo("category", categoryUid)
.limit(10)
.includeReference("product_line")
.find();
return result.entries;
} catch (error) {
console.error("Failed to fetch products:", error);
return []; // Render empty state rather than crashing
}
}Not measuring actual performance
Set up monitoring for Contentstack API call latency, page TTFB, and Core Web Vitals. Without metrics, you cannot distinguish between a caching misconfiguration and a content model issue. Tools like Vercel Analytics, web-vitals library, or custom logging provide visibility into real user performance.