Context
Balaconah is a Next.js App Router platform combining an Arabic media surface (news, articles, opinion, live reports, video, glossary) with a brand directory and identities registry, plus events, partnerships, calendars, and brand-comparison tooling — 50+ public routes in total, with Contentful as the editorial CMS.
Because it is content-heavy, Arabic-first, and SEO-driven, performance is not cosmetic: real-user monitoring showed LCP consistently above 3.5s on mobile — well inside Google's "Needs Improvement" band and approaching the "Poor" threshold. Core Web Vitals directly affect organic search rankings and first-impression bounce rate, so this workstream was prioritised.
Problem
The brief was clear: get LCP below 2.5s (Google's "Good" threshold) on mobile CrUX data. Starting measurement: 3.8s. The investigation needed to identify root causes, not just apply a generic checklist.
Hypotheses going in:
- Hero image not using
next/image— likely largest contentful paint element unoptimised - Render-blocking third-party scripts above the fold
- Client-rendered above-the-fold content (SSR gap)
- Fonts loaded synchronously with no
font-displaystrategy
Architecture — before and after
Implementation
1. Hero image — next/image with priority
The hero image was served as a plain <img> tag with a full-resolution URL. No width/height, no responsive sizes, no preload. Replacing it with next/image and adding priority immediately triggers a <link rel="preload"> in the document <head>, so the browser fetches it before it even parses the JS bundle.
// Before
<img src="/hero.jpg" className="hero" />
// After
<Image
src="/hero.jpg"
alt="Balaconah"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 50vw"
className="hero"
/>The sizes attribute was the second lever — without it, Next.js generates the wrong srcset and the browser downloads the 1200px image on a 375px screen.
2. Dynamic imports for below-the-fold widgets
Three heavy components (a charting library, a rich-text renderer, and a carousel) were rendering synchronously as part of the initial bundle, adding ~180kb to the critical path.
const Chart = dynamic(() => import('@/components/Chart'), { ssr: false })
const RichText = dynamic(() => import('@/components/RichText'))ssr: false on the chart component removes it from the SSR output entirely — it doesn't contribute to LCP at all, and any hydration cost is deferred until after first paint.
3. ISR tuning — why 60s revalidate
The page was fully SSR on every request. For content that changes at most a few times per day, this burns server CPU and adds per-request latency. Switching to ISR with revalidate: 60:
- First request after a revalidation cycle hits the server once
- Subsequent requests within 60s serve the stale-while-revalidate cached HTML
- No visible staleness for users; no meaningless per-request SSR overhead
60s was chosen by looking at the content update frequency in the CMS — editors publish at most 3–4 times per day, so even a 300s revalidation would be fine. 60s was the conservative choice.
4. Font loading
Fonts were loaded via a standard @import in the global CSS file — a well-known render-blocker. Migrated to next/font/google with display: 'swap', which:
- Generates a preconnect hint to Google Fonts
- Uses
font-display: swapto allow the browser to render with a fallback font immediately - Eliminates FOUT (flash of unstyled text) by sizing the fallback metric precisely
Outcome
| Metric | Before | After |
|---|---|---|
| LCP (mobile) | 3.8s | 1.2s |
| LCP category | Needs Improvement | Good |
| Improvement | — | −68% |
Real-user CrUX data confirmed the improvement within one week of deployment. The page moved from the "Needs Improvement" band to Google's "Good" threshold, which has measurable downstream effects on organic search ranking.
The investigation also surfaced two additional CLS issues (layout shifts from late-loading images without explicit dimensions) that were fixed as part of the same pass.