You've built a content-driven application with Strapi and Next.js. The development experience was smooth, but now you're facing slow page loads, layout shifts, and frustrated users. Your Lighthouse scores are in the red, and you're not sure where to start optimizing.
Performance problems in Strapi and Next.js applications typically stem from architectural decisions made during initial development. These mistakes compound over time, creating bottlenecks that affect everything from Time to First Byte (TTFB) to Cumulative Layout Shift (CLS). The good news is that most of these issues follow predictable patterns with well-documented solutions.
In Brief:
- Fix N+1 query patterns by consolidating Strapi API requests with targeted populate parameters and avoid
populate=*wildcard queries. - Eliminate hydration chaos by migrating to React Server Components instead of overusing dynamic imports (use dynamic imports only for browser-dependent libraries).
- Optimize content blocks and rich text rendering with
@strapi/blocks-react-renderer, selective population, and server-side processing. - Implement proper caching strategies across Request Memoization, Data Cache with ISR revalidation, Full Route Cache, and CDN layers with tag-based invalidation.
Mistake #1: One Page = 12 CMS Requests
When you fetch a collection of content entries and make separate API requests for each entry's related data, you create an N+1 query problem. This pattern manifests when working with dynamic zones, relational content, and media assets.
The official Strapi REST API documentation does not describe a typical scenario involving one initial query followed by N additional queries for related data; instead, it focuses on using the populate parameter to fetch related data in a single request.
The performance impact is severe. Production evidence from real-world applications shows N+1 patterns limiting concurrent users to approximately 200, causing critical database bottlenecks. Your application makes one initial query followed by N additional requests for related data when a consolidated request with proper population would suffice.
// ❌ INCORRECT: Makes 1 + N requests
async function getPage(slug) {
const page = await fetch(`/api/pages/${slug}`);
const sections = page.sections;
// N additional requests - one per section
const sectionData = await Promise.all(
sections.map(section =>
fetch(`/api/sections/${section.id}?populate=*`)
)
);
}This anti-pattern appears frequently in applications with pages containing multiple sections, each requiring media assets and author information. Each additional request adds network latency, increases server load, and degrades First Contentful Paint (FCP) and overall content rendering performance.
Consolidate API Requests with Targeted Populate Queries
Use the qs library to build complex populate queries that fetch all required data in a single request. Strapi's populate parameter supports deep nesting and selective relation loading.
// ✅ CORRECT: Single consolidated request
const qs = require('qs');
const query = qs.stringify({
populate: {
sections: {
populate: {
media: true,
author: {
populate: ['avatar']
}
}
},
seo: {
populate: ['metaImage']
}
}
}, { encodeValuesOnly: true });
async function getPage(slug) {
const response = await fetch(
`${process.env.STRAPI_URL}/api/pages/${slug}?${query}`,
{ next: { revalidate: 3600 } }
);
const page = await response.json();
return page; // All data included in single request
}For Next.js App Router applications, leverage Server Components with automatic request memoization. The framework automatically deduplicates identical fetch requests, preventing redundant API calls even when multiple components request the same data.
// app/page/[slug]/page.tsx
async function getPageData(slug: string) {
const query = qs.stringify({
populate: {
sections: {
populate: {
media: true,
cta: true
}
}
}
});
// Automatically memoized by Next.js
const res = await fetch(
`${process.env.STRAPI_URL}/api/pages/${slug}?${query}`,
{ next: { revalidate: 3600 } }
);
return res.json();
}// app/page/[slug]/page.tsx
import { cache } from 'react';
import { PageRenderer } from '@/components/PageRenderer';
const getPageData = cache(async (slug: string) => {
const query = qs.stringify({
populate: {
sections: {
populate: {
media: true,
cta: true
}
}
}
});
// Automatically memoized by Next.js
const res = await fetch(
`${process.env.STRAPI_URL}/api/pages/${slug}?${query}`,
{ next: { revalidate: 3600 } }
);
if (!res.ok) throw new Error('Failed to fetch page');
return res.json();
});
export default async function Page({ params }: { params: { slug: string } }) {
const data = await getPageData(params.slug);
return <PageRenderer data={data} />;
}When you need data from multiple endpoints, initiate requests in parallel rather than sequentially. This parallel data fetching pattern reduces total wait time by overlapping network requests.
async function Page({ params }: { params: { slug: string } }) {
// Initiated in parallel, not sequentially
const pagePromise = getPageData(params.slug);
const settingsPromise = getGlobalSettings();
// Wait for both to resolve simultaneously
const [page, settings] = await Promise.all([
pagePromise,
settingsPromise
]);
return <Layout settings={settings}><PageContent data={page} /></Layout>;
}Beyond API consolidation, add composite indexes to your Strapi database on frequently queried relation fields, and monitor query counts during development to catch N+1 patterns before they reach production.
These database-level strategies—combined with API consolidation through targeted populate queries—are essential for scaling beyond initial performance bottlenecks and handling significantly higher concurrent user loads.
Mistake #2: Everything Is Dynamic Import
Dynamic imports seem like an optimization until they create hydration chaos. When you wrap components with Next.js's dynamic() function excessively, you introduce timing mismatches between server-rendered HTML and client-side React rendering.
According to the Next.js lazy loading documentation, next/dynamic and Suspense render a fallback component (such as a loading placeholder) first, followed by the lazy-loaded component once the dynamic import resolves.
The fundamental issue occurs when server-rendered HTML doesn't match client-rendered output. React's hydration process attaches event listeners to existing DOM, but mismatches force complete client-side re-rendering, causing visible flickering and degraded user experience.
// ❌ INCORRECT: Dynamic import for Strapi content
// According to Next.js documentation, this pattern causes hydration chaos
// because the server renders a placeholder while the client waits for the
// dynamic component bundle to download, creating a DOM structure mismatch
const StrapiContent = dynamic(() => import('./StrapiContent'));
export default function Page({ content }) {
return <StrapiContent data={content} />;
}// ✅ Server Components don't participate in React's client-side hydration, so hydration mismatches can't occur within the Server Component parts of the tree (but Client Components can still have hydration errors).
async function Page({ params }) {
const res = await fetch(
`http://localhost:1337/api/posts/${params.slug}`,
{ next: { revalidate: 60 } }
);
const post = await res.json();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}Use Server Components for CMS Content
The Next.js App Router introduces Server Components that render entirely on the server and ship zero JavaScript to the client.
This architectural shift changes how code is split (for example, Next.js automatically code splits by route segments and React Server Components are automatically code-split), but dynamic imports are still documented and recommended for many Client Component use cases, and automatic code splitting is described primarily at the route and server-component level rather than as a general component-level feature without manual configuration.
// ✅ CORRECT: Server Component fetches Strapi data
// app/blog/[slug]/page.js
async function BlogPost({ params }) {
const res = await fetch(
`${process.env.STRAPI_URL}/api/posts/${params.slug}`,
{ next: { revalidate: 60 } }
);
const { data } = await res.json();
return (
<article>
<h1>{data.title}</h1>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
</article>
);
}Implement Draft Mode with Conditional Rendering
For content preview functionality, the modern approach involves using Draft Mode with the App Router for better integration with React Server Components. However, for primary content rendering, one possible pattern is using Server Components with selective data fetching through targeted populate parameters in Strapi API calls, as demonstrated in some examples.
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// Verify secret token
if (secret !== process.env.PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
// Enable Draft Mode
draftMode().enable();
// Redirect to the path
return Response.redirect(new URL(`/posts/${slug}`, request.url));
}Conditionally fetch data based on whether Draft Mode is enabled. This approach serves published content with full caching benefits while providing real-time previews when needed.
import { draftMode } from 'next/headers';
async function getData(slug: string) {
const { isEnabled } = draftMode();
const endpoint = isEnabled
? `${process.env.STRAPI_URL}/api/posts/${slug}?publicationState=preview`
: `${process.env.STRAPI_URL}/api/posts/${slug}`;
const res = await fetch(endpoint, {
next: { revalidate: isEnabled ? 0 : 3600 }
});
return res.json();
}
export default async function Post({ params }) {
const data = await getData(params.slug);
return <article>{data.content}</article>;
}Mistake #3: Overusing Rich Components for Content Blocks
Performance problems with Strapi Dynamic Zones and rich text editors stem from three primary issues: increased API payload complexity from fetching multiple component types, JavaScript bundle bloat from including all component implementations, and expensive client-side hydration of server-rendered content.
Each Dynamic Zone requires fetching multiple component types simultaneously, increasing both response payload size and query execution time.
Production evidence from GitHub Issue #24462 documents input lag when editing content in Strapi's admin with large documents, demonstrating that performance problems affect the editing experience.
// Typical Dynamic Zone component mapping
import dynamic from 'next/dynamic';
const componentMap = {
'blocks.hero': dynamic(() => import('@/components/blocks/HeroBlock')),
'blocks.form': dynamic(() => import('@/components/blocks/InteractiveForm')),
'blocks.richtext': dynamic(() => import('@/components/blocks/RichText'))
};// ✅ CORRECT: Component mapping pattern
const componentMap = {
'blocks.hero': dynamic(() => import('@/components/blocks/HeroBlock')),
'blocks.form': dynamic(() => import('@/components/blocks/InteractiveForm')),
'blocks.richtext': dynamic(() => import('@/components/blocks/RichText'))
};
export default function DynamicZoneRenderer({ zones }) {
return zones.map((zone) => {
const Component = componentMap[zone.__component];
return <Component key={zone.id} {...zone} />;
});
}Without proper code splitting via dynamic imports, your initial JavaScript payload includes all possible Dynamic Zone component implementations, even those not present on the current page. Rich text editors add substantial dependency weight to client bundles when rendered client-side rather than server-side.
The primary solution is leveraging React Server Components to render static content entirely on the server, eliminating client-side JavaScript for non-interactive blocks. For components requiring interactivity, strategic lazy loading with next/dynamic should load only the component types actually present in the current content.
Render Content Blocks as Server Components
The highest-impact optimization involves leveraging React Server Components to eliminate client-side JavaScript for non-interactive Dynamic Zone blocks. According to Strapi's rich text integration guide, the official renderer (@strapi/blocks-react-renderer) must be used inside a Client Component (with the "use client" directive), because it relies on client-side JavaScript.
// Server Component - no client JavaScript needed
import { BlocksRenderer } from '@strapi/blocks-react-renderer';
export default async function BlogPost({ params }) {
const post = await fetchStrapiContent(params.slug);
return (
<article>
<BlocksRenderer content={post.content} />
</article>
);
}Mistake #4: Improper Image Optimization Configuration
JavaScript developers frequently fail to properly configure Next.js Image optimization when serving images from Strapi's Media Library. A common mistake is failing to configure remotePatterns in next.config.js when using Strapi media assets, which causes the Next.js Image component to reject those external images with a 400 Bad Request error until the host is configured.
This causes Next.js to skip optimization entirely and serve unoptimized images directly from Strapi. Images bypass the optimization pipeline, resulting in no automatic WebP/AVIF conversion, no responsive srcset generation, and file sizes typically 60-80% larger than optimized versions.
Configure Next.js Image Optimization for Strapi
Configure remotePatterns in next.config.js to enable Next.js Image optimization for Strapi media assets. Without this configuration, Next.js skips optimization and serves images unoptimized directly from Strapi.
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'your-strapi-domain.com',
pathname: '/uploads/**',
},
],
},
};Always include width and height props to prevent layout shifts, and use the priority prop for above-the-fold images. Strapi automatically generates thumbnail (156px), small (500px), medium (750px), and large (1000px) variants for uploaded images according to the upload plugin documentation, which can be leveraged for responsive image handling in Next.js Image components.
// Component usage
import Image from 'next/image';
export default function Article({ data }) {
return (
<Image
src={data.coverImage.url}
alt={data.coverImage.alternativeText}
width={data.coverImage.width}
height={data.coverImage.height}
priority // For LCP images to load immediately
sizes="(max-width: 768px) 100vw, 50vw" // Responsive sizing
/>
);
}According to Next.js Image Optimization documentation and Strapi's "Next.js Image Optimization: A Guide for Web Developers," properly configuring Next.js Image optimization when serving images from Strapi requires both configuration setup and correct component implementation.
First, configure remotePatterns in next.config.js to enable optimization for Strapi media assets:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'your-strapi-domain.com',
pathname: '/uploads/**',
},
],
},
};Then implement the Image component with all required attributes to prevent layout shifts and enable LCP optimization:
// Example component using Strapi cover image
export default function Article({ data }) {
return (
<Image
src={data.coverImage.url}
alt={data.coverImage.alternativeText}
width={data.coverImage.width}
height={data.coverImage.height}
priority // For LCP images to load immediately
sizes="(max-width: 768px) 100vw, 50vw" // Responsive sizing
/>
);
}This pattern ensures that images are automatically optimized to WebP/AVIF formats, responsive srcsets are generated, and Cumulative Layout Shift (CLS) is prevented through explicit width and height dimensions.
The sizes prop tells Next.js which image sizes to generate for different viewport widths, enabling efficient responsive images. This configuration ensures browsers download appropriately sized images rather than oversized originals.
Configure Strapi's Media Library to use appropriate storage providers for production. Consider using cloud storage like AWS S3 or Cloudinary for better CDN integration and automatic image transformations.
Mistake #5: API Over-Fetching with populate=*
Using Strapi's populate=* wildcard parameter drastically increases response sizes. According to Strapi's populate documentation, the wildcard fetches all relations and nested relations without limit.
Real-world production evidence shows using populate=* on 3,500 elements increased response time from milliseconds to nearly 20 seconds. Query performance degrades significantly when the population exceeds 10+ related collections, creating exponential query complexity that overwhelms both your Strapi backend and Next.js frontend.
// ❌ AVOID: Wildcard population causes over-fetching
fetch(`${process.env.STRAPI_URL}/api/products?populate=*`)
// ✅ CORRECT: Selective population with specific fields
const qs = require('qs');
const query = qs.stringify({
populate: {
category: {
fields: ['name']
},
images: {
fields: ['url', 'alternativeText']
}
},
fields: ['title', 'price', 'sku']
});
fetch(`${process.env.STRAPI_URL}/api/products?${query}`)This approach fetches every possible relation, including deeply nested associations you don't need for the current view. The resulting payload contains unnecessary data, increasing network transfer time, JSON parsing overhead, and memory consumption.
Implement Selective Field and Relation Queries
Use the qs library to construct precise populate queries for Strapi that fetch only required data in a single request. According to Strapi's populate documentation, this approach consolidates multiple API calls into one, eliminating the N+1 query problem.
For example, build complex populate queries using qs to specify exact relations and nested fields needed, avoiding the inefficient populate=* wildcard that fetches all relations regardless of actual requirements.
// ✅ CORRECT: Selective population
const qs = require('qs');
const query = qs.stringify({
populate: {
category: {
fields: ['name']
},
images: {
fields: ['url', 'alternativeText']
}
},
fields: ['title', 'price', 'sku']
});
const response = await fetch(
`${process.env.STRAPI_URL}/api/products?${query}`
);This approach reduces payload size by 80% compared to wildcard population. You fetch only the title, price, and SKU fields for products, along with category names and image URLs. All other fields and relations are excluded from the response.
Implement pagination to prevent large data loads. Use reasonable page sizes between 10-50 records depending on content complexity.
const query = qs.stringify({
pagination: {
page: 1,
pageSize: 20
},
populate: {
category: {
fields: ['name']
}
}
});Add database indexing on frequently queried fields to improve query performance. Configure maxLimit in your Strapi API settings (in config/api.js) to prevent clients from requesting excessive records. According to Strapi's API configuration documentation, this setting controls the maximum allowed limit parameter to protect server resources.
// config/api.js
module.exports = {
rest: {
defaultLimit: 25,
maxLimit: 100,
},
};For complex queries requiring multiple relations with precise field selection, consider using Strapi's GraphQL API instead. GraphQL requires explicit field specification by design, making over-fetching unlikely.
Mistake #6: Missing ISR Cache Revalidation Strategies
Developers implementing Incremental Static Regeneration fail to set up proper cache invalidation. According to the Next.js ISR documentation, pages use a stale‑while‑revalidate pattern: requests within the revalidate timeframe return cached data, and after that timeframe the next request still returns the cached (stale) data while regeneration happens in the background.
However, without explicit cache invalidation via revalidatePath or revalidateTag, content updates in Strapi won't reflect on the frontend until the entire interval expires. The solution involves configuring a revalidation endpoint that Strapi webhooks can call to trigger cache invalidation when content is published or updated.
The architectural issue is that ISR cache is local to each Node.js instance. In multi-instance deployments without a shared cache layer, different instances serve inconsistent stale content. Your content editors update a blog post in Strapi, but until the ISR revalidation period expires, users on different server instances may see different versions of your content.
// Proper ISR cache revalidation
async function getData() {
const res = await fetch(`${process.env.STRAPI_URL}/api/articles`, {
next: { revalidate: 3600 } // ISR: Revalidate every hour
});
return res.json();
}Relying solely on time-based revalidation has significant limitations. When you publish urgent content updates, users must wait for the full revalidation period before seeing changes. Instead, implement tag-based revalidation with the revalidateTag function.
Configure Strapi webhooks to trigger cache invalidation immediately when content is published, ensuring urgent updates appear instantly without waiting for the time-based revalidation interval to expire.
Implement Tag-Based Cache Revalidation
Use Next.js tag-based revalidation with revalidateTag() for on-demand cache invalidation triggered by Strapi content updates through webhook-configured API routes.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 });
}
const { tag } = await request.json();
revalidateTag(tag);
return Response.json({ revalidated: true, now: Date.now() });
}Configure your data fetching functions with Next.js revalidation strategies to manage cache invalidation. In Next.js 15, route handlers and caches are uncached by default, requiring explicit opt-in using export const dynamic = 'force-static' for static caching.
For dynamic data, use the revalidate option with next.revalidate in fetch calls or implement tag-based revalidation with revalidateTag() to invalidate cached responses programmatically when Strapi content updates via webhooks.
async function getData() {
const res = await fetch(`${process.env.STRAPI_URL}/api/articles`, {
next: {
revalidate: 3600,
tags: ['articles']
}
});
return res.json();
}Configure Strapi webhooks to call your Next.js revalidation endpoint (typically /api/revalidate) when content updates. Using tag-based revalidation via revalidateTag, this marks tagged data as stale so that fresh content is fetched on the next request to affected pages, and users may still see stale content briefly rather than guaranteed updates within seconds. Verify webhook requests with a secret token to prevent unauthorized cache purges.
For Vercel deployments, leverage their managed ISR infrastructure which coordinates cache across instances. For other hosting providers, ISR cache is local to each Node.js instance—in multi-instance deployments without coordination, different instances serve inconsistent stale content. Implement a shared cache layer (such as Redis) or use a centralized cache invalidation service to coordinate ISR state across instances.
Add monitoring to track cache hit rates and revalidation frequency. Implement tag-based revalidation with revalidateTag to invalidate cache when content updates occur. This helps you balance between serving fresh content and maintaining optimal performance through effective caching strategies across Next.js ISR, API response caching, and CDN layers.
Start Optimizing Today: Your Performance Checklist
Performance optimization in Strapi and Next.js applications requires addressing API query patterns, rendering strategies, and caching at multiple layers. Start by auditing your codebase for N+1 queries and wildcard populate parameters, migrate interactive components to Server Components where appropriate, and implement tag-based cache revalidation for immediate content updates.
These changes typically improve page load times by 40-60% and dramatically reduce server load. Explore Strapi's deployment documentation to optimize your production infrastructure for the architecture patterns covered in this guide.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.