Fix LCP Issues on Strapi (Loading Speed) | OpsBlu Docs

Fix LCP Issues on Strapi (Loading Speed)

Improve Strapi LCP by optimizing REST/GraphQL response sizes, using responsive image formats from uploads, and server-rendering your frontend.

Largest Contentful Paint (LCP) measures how quickly the main content of your Strapi-powered site loads. Since Strapi is a headless CMS, LCP optimization focuses on both backend API performance and frontend rendering efficiency.

Target: LCP under 2.5 seconds Good: Under 2.5s | Needs Improvement: 2.5-4.0s | Poor: Over 4.0s

For general LCP concepts, see the global LCP guide.

Strapi-Specific LCP Issues

1. Slow API Response Time (TTFB)

The most critical factor for Strapi-powered sites is Time to First Byte (TTFB) - how quickly your Strapi API responds.

Problem: Slow Strapi API responses delay entire page render.

Diagnosis:

  • Open Chrome DevTools → Network tab
  • Look for requests to your Strapi API
  • Check "Waiting (TTFB)" time
  • Target: TTFB under 200ms

Solutions:

A. Enable Strapi Response Caching

# Install Strapi cache plugin
npm install @strapi/plugin-rest-cache
// config/plugins.js
module.exports = {
  'rest-cache': {
    enabled: true,
    config: {
      provider: {
        name: 'memory',
        options: {
          max: 100,
          maxAge: 3600000, // 1 hour
        },
      },
      strategy: {
        contentTypes: [
          'api::article.article',
          'api::category.category',
          'api::author.author',
        ],
        maxAge: 3600000,
      },
    },
  },
};

B. Optimize Strapi Database Queries

Add indexes to frequently queried fields:

// Strapi model schema
{
  "kind": "collectionType",
  "collectionName": "articles",
  "info": {
    "singularName": "article",
    "pluralName": "articles"
  },
  "attributes": {
    "slug": {
      "type": "string",
      "unique": true,
      "index": true  // Add index for faster queries
    },
    "publishedAt": {
      "type": "datetime",
      "index": true  // Index for sorting/filtering
    }
  }
}

C. Use Field Selection to Reduce Payload

Only fetch needed fields:

// Bad - fetches everything
const response = await fetch(
  `${STRAPI_URL}/api/articles?populate=*`
);

// Good - only fetch what you need
const response = await fetch(
  `${STRAPI_URL}/api/articles?fields[0]=title&fields[1]=slug&fields[2]=excerpt&populate[featuredImage][fields][0]=url&populate[featuredImage][fields][1]=alternativeText`
);

D. Implement CDN for Strapi API

Use a CDN like Cloudflare to cache API responses:

// Add cache headers in Strapi
// config/middlewares.js
module.exports = [
  // ...
  {
    name: 'strapi::public',
    config: {
      defer: false,
      maxAge: 31536000, // 1 year for static assets
    },
  },
];

Or use Next.js as a caching layer:

// app/api/articles/[slug]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { slug: string } }
) {
  const response = await fetch(
    `${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`,
    {
      next: { revalidate: 3600 }, // Cache for 1 hour
    }
  );

  const data = await response.json();
  return Response.json(data);
}

2. Unoptimized Images from Strapi

Images from Strapi's media library often lack proper optimization.

Problem: Large, unoptimized images as LCP element.

Diagnosis:

  • Run PageSpeed Insights
  • Check if Strapi images flagged as LCP element
  • Look for "Properly size images" warning

Solutions:

A. Use Next.js Image Component

// components/StrapiImage.tsx
import Image from 'next/image';

interface StrapiImageProps {
  image: {
    data: {
      attributes: {
        url: string;
        alternativeText: string | null;
        width: number;
        height: number;
      };
    };
  };
  priority?: boolean;
}

export function StrapiImage({ image, priority = false }: StrapiImageProps) {
  const { url, alternativeText, width, height } = image.data.attributes;

  return (
    <Image
      src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${url}`}
      alt={alternativeText || ''}
      width={width}
      height={height}
      priority={priority} // For LCP images
      quality={80}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

Usage:

// app/articles/[slug]/page.tsx
<StrapiImage
  image={article.attributes.featuredImage}
  priority={true} // Mark as LCP element
/>

B. Configure Image Optimization Provider

Use Cloudinary, ImageKit, or similar for Strapi:

npm install @strapi/provider-upload-cloudinary
// config/plugins.js
module.exports = {
  upload: {
    config: {
      provider: 'cloudinary',
      providerOptions: {
        cloud_name: process.env.CLOUDINARY_NAME,
        api_key: process.env.CLOUDINARY_KEY,
        api_secret: process.env.CLOUDINARY_SECRET,
      },
      actionOptions: {
        upload: {},
        uploadStream: {},
        delete: {},
      },
    },
  },
};

Then use Cloudinary transformations:

const optimizedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/f_auto,q_auto,w_800/${publicId}`;

C. Implement Responsive Images

// Gatsby example
import { GatsbyImage, getImage } from 'gatsby-plugin-image';

export const ArticleTemplate = ({ data }) => {
  const image = getImage(data.strapiArticle.featuredImage.localFile);

  return (
    <GatsbyImage
      image={image}
      alt={data.strapiArticle.featuredImage.alternativeText || ''}
      loading="eager" // For LCP images
    />
  );
};

export const query = graphql`
  query($slug: String!) {
    strapiArticle(slug: { eq: $slug }) {
      featuredImage {
        alternativeText
        localFile {
          childImageSharp {
            gatsbyImageData(
              width: 1200
              placeholder: BLURRED
              formats: [AUTO, WEBP, AVIF]
            )
          }
        }
      }
    }
  }
`;

D. Preload LCP Image

// app/layout.tsx or page.tsx
export default function ArticlePage({ article }) {
  const featuredImageUrl = `${process.env.NEXT_PUBLIC_STRAPI_URL}${article.attributes.featuredImage.data.attributes.url}`;

  return (
    <>
      <head>
        <link
          rel="preload"
          as="image"
          href={featuredImageUrl}
          imageSrcSet={`${featuredImageUrl}?width=640 640w, ${featuredImageUrl}?width=1024 1024w, ${featuredImageUrl}?width=1920 1920w`}
          imageSizes="100vw"
        />
      </head>
      <article>
        {/* Article content */}
      </article>
    </>
  );
}

3. Framework Rendering Strategy

Choose the right rendering strategy for your use case.

Problem: Wrong rendering strategy causes slow initial load.

Next.js Rendering Options

Static Site Generation (SSG) - Best for LCP:

// app/articles/[slug]/page.tsx
export async function generateStaticParams() {
  const articles = await fetch(`${process.env.STRAPI_URL}/api/articles?fields[0]=slug`).then(r => r.json());

  return articles.data.map((article: any) => ({
    slug: article.attributes.slug,
  }));
}

export default async function ArticlePage({ params }: { params: { slug: string } }) {
  // This is generated at build time
  const article = await fetch(
    `${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`
  ).then(r => r.json());

  return <ArticleContent article={article.data[0]} />;
}

Incremental Static Regeneration (ISR) - Good balance:

export const revalidate = 3600; // Revalidate every hour

export default async function ArticlePage({ params }: { params: { slug: string } }) {
  const article = await fetch(
    `${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`,
    { next: { revalidate: 3600 } }
  ).then(r => r.json());

  return <ArticleContent article={article.data[0]} />;
}

Server-Side Rendering (SSR) - Use sparingly:

// Only use SSR when content must be fresh every request
export const dynamic = 'force-dynamic';

export default async function ArticlePage({ params }: { params: { slug: string } }) {
  // Fetched on every request - slower LCP
  const article = await fetch(
    `${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${params.slug}&populate=*`,
    { cache: 'no-store' }
  ).then(r => r.json());

  return <ArticleContent article={article.data[0]} />;
}

Gatsby - Optimized by Default

Gatsby builds static pages at build time:

// gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions;

  const result = await graphql(`
    query {
      allStrapiArticle {
        nodes {
          id
          slug
        }
      }
    }
  `);

  result.data.allStrapiArticle.nodes.forEach(article => {
    createPage({
      path: `/articles/${article.slug}`,
      component: require.resolve('./src/templates/article.js'),
      context: {
        id: article.id,
      },
    });
  });
};

4. Multiple API Calls Blocking Render

Problem: Multiple sequential API calls delay LCP.

Bad - Sequential Calls:

// Each call waits for previous one
const article = await fetch(`${STRAPI_URL}/api/articles/${id}`).then(r => r.json());
const author = await fetch(`${STRAPI_URL}/api/users/${article.data.attributes.author}`).then(r => r.json());
const category = await fetch(`${STRAPI_URL}/api/categories/${article.data.attributes.category}`).then(r => r.json());

Good - Use Populate:

// Single call with all data
const article = await fetch(
  `${STRAPI_URL}/api/articles/${id}?populate[author]=*&populate[category]=*&populate[featuredImage]=*`
).then(r => r.json());

Best - Use GraphQL:

// Fetch exactly what you need in one request
const { data } = await fetch(`${STRAPI_URL}/graphql`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `
      query GetArticle($slug: String!) {
        articles(filters: { slug: { eq: $slug } }) {
          data {
            id
            attributes {
              title
              content
              author {
                data {
                  attributes {
                    name
                  }
                }
              }
              category {
                data {
                  attributes {
                    name
                  }
                }
              }
              featuredImage {
                data {
                  attributes {
                    url
                    alternativeText
                    width
                    height
                  }
                }
              }
            }
          }
        }
      }
    `,
    variables: { slug },
  }),
}).then(r => r.json());

5. Heavy JavaScript Bundles

Problem: Large JavaScript bundles delay interactivity and LCP.

Diagnosis:

  • Use bundle analyzer:
# Next.js
npm install @next/bundle-analyzer

# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // config
});

# Run analysis
ANALYZE=true npm run build

Solutions:

A. Code Splitting

// Lazy load non-critical components
import dynamic from 'next/dynamic';

const Comments = dynamic(() => import('./Comments'), {
  loading: () => <p>Loading comments...</p>,
  ssr: false, // Don't render on server
});

const RelatedArticles = dynamic(() => import('./RelatedArticles'));

export default function ArticlePage({ article }) {
  return (
    <article>
      <h1>{article.attributes.title}</h1>
      <div>{article.attributes.content}</div>

      {/* Lazy loaded below fold */}
      <RelatedArticles articles={article.attributes.related} />
      <Comments articleId={article.id} />
    </article>
  );
}

B. Optimize Dependencies

// Bad - imports entire library
import { format } from 'date-fns';

// Good - import only what you need
import format from 'date-fns/format';

// Or use lighter alternatives
import { formatDate } from '@/utils/dates'; // Custom lightweight utility

6. Font Loading

Problem: Custom fonts block text rendering.

Solution - Next.js with next/font:

// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // Use fallback font immediately
  preload: true,
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

Solution - Manual Font Loading:

/* Use font-display: swap */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately */
  font-weight: 400;
  font-style: normal;
}

Framework-Specific Optimizations

Next.js

// next.config.js
module.exports = {
  images: {
    domains: [process.env.STRAPI_DOMAIN],
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
  },
  compiler: {
    removeConsole: process.env.NODE_ENV === 'production',
  },
  experimental: {
    optimizeCss: true,
  },
};

Gatsby

// gatsby-config.js
module.exports = {
  plugins: [
    {
      resolve: 'gatsby-plugin-image',
      options: {
        defaults: {
          formats: ['auto', 'webp', 'avif'],
          placeholder: 'blurred',
          quality: 80,
        },
      },
    },
    {
      resolve: 'gatsby-source-strapi',
      options: {
        apiURL: process.env.STRAPI_URL,
        collectionTypes: ['article'],
        singleTypes: ['homepage'],
        queryLimit: 1000,
      },
    },
  ],
};

Nuxt

// nuxt.config.ts
export default defineNuxtConfig({
  image: {
    provider: 'strapi',
    strapi: {
      baseURL: process.env.STRAPI_URL,
    },
    formats: ['webp', 'avif'],
  },
  nitro: {
    compressPublicAssets: true,
    prerender: {
      crawlLinks: true,
      routes: ['/'],
    },
  },
});

Testing & Monitoring

Test LCP

Tools:

  1. PageSpeed Insights - Lab and field data
  2. WebPageTest - Detailed waterfall
  3. Chrome DevTools Lighthouse - Local testing

Test Different Pages:

  • Homepage (often uses Strapi Single Type)
  • Article pages (Collection Type)
  • Category pages (List views)
  • Search results

Test Different Rendering Methods:

  • SSG pages
  • ISR pages
  • SSR pages
  • Client-side rendered content

Monitor LCP Over Time

Real User Monitoring:

// app/layout.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    if (metric.name === 'LCP') {
      // Send to analytics
      fetch('/api/analytics', {
        method: 'POST',
        body: JSON.stringify({ metric }),
      });
    }
  });
}

Quick Wins Checklist

Start here for immediate LCP improvements:

  • Enable Strapi REST cache plugin
  • Use Next.js Image component (or framework equivalent)
  • Implement SSG/ISR instead of SSR
  • Use populate parameter to reduce API calls
  • Add priority prop to LCP images
  • Optimize Strapi database queries (add indexes)
  • Configure CDN for Strapi media
  • Use GraphQL for precise data fetching
  • Lazy load below-fold components
  • Implement code splitting
  • Use font-display: swap for custom fonts
  • Test with PageSpeed Insights

When to Hire a Developer

Consider hiring a Strapi or frontend developer if:

  • LCP consistently over 4 seconds after optimizations
  • Complex SSR/SSG/ISR implementation needed
  • Custom Strapi plugin required for caching
  • Large-scale performance optimization project
  • Need to migrate rendering strategy

Next Steps

For general LCP optimization strategies, see LCP Optimization Guide.