10.1 — Homepage playbook¶
Module 10 · Lesson 1 · 🟡 Intermediate · ~25 min
Your most‑linked, most‑screenshotted, most‑politically‑contested page. Everyone wants a module on it, and every module costs milliseconds.
Profile¶
| Share of sessions | ~14% |
| Cacheability | High — same for everyone (modulo market) |
| LCP element | Hero image or hero heading |
| Dominant risks | Oversized hero, CMS payload, carousel JS, third‑party tags |
| Rendering strategy | ISR with on‑demand revalidation on CMS publish |
Budgets¶
| Metric | Target |
|---|---|
| LCP | ≤ 2.2 s |
| INP | ≤ 200 ms |
| CLS | ≤ 0.05 |
| TTFB | ≤ 300 ms |
| JS (gz) | ≤ 200 KB |
| Above‑fold images | ≤ 250 KB |
The structure¶
// app/page.tsx
export const revalidate = 300; // plus on-demand purge from the CMS webhook
export default async function HomePage() {
// One call for the whole page's content — merchandisers change everything together
const content = await getHomepageContent();
return (
<main>
{/* LCP: server-rendered, priority, correct sizes. Nothing above it. */}
<HeroBanner banner={content.hero} />
{/* Above the fold on most viewports — server-rendered, no JS */}
<CategoryTiles tiles={content.categories} />
{/* Below the fold — stream, and use CSS scroll-snap not a carousel lib */}
<Suspense fallback={<RailSkeleton />}>
<FeaturedProducts collectionId={content.featuredCollection} />
</Suspense>
<Suspense fallback={<div className="min-h-[420px]" />}>
<EditorialBlocks blocks={content.editorial} />
</Suspense>
{/* Personalized: a dynamic hole, or client-side from localStorage */}
<Suspense fallback={<div className="min-h-[380px]" />}>
<RecentlyViewed />
</Suspense>
</main>
);
}
// components/hero-banner.tsx — Server Component, zero client JS
import Image from 'next/image';
export function HeroBanner({ banner }: { banner: HeroContent }) {
return (
<section className="relative">
{/* Art direction: different crops for mobile and desktop */}
<picture>
<source
media="(min-width: 1024px)"
srcSet={`${banner.wideUrl}?w=1920&f=avif 1920w, ${banner.wideUrl}?w=1280&f=avif 1280w`}
sizes="100vw"
type="image/avif"
/>
<img
src={`${banner.squareUrl}?w=828&f=webp`}
srcSet={`${banner.squareUrl}?w=640&f=webp 640w, ${banner.squareUrl}?w=828&f=webp 828w`}
sizes="100vw"
alt={banner.alt}
width={828}
height={828}
fetchPriority="high" // the LCP element
decoding="async"
className="w-full object-cover"
/>
</picture>
{/* Text overlay — server-rendered, no entrance animation on the LCP area */}
<div className="absolute inset-0 flex items-end p-6 lg:items-center lg:p-16">
<div className="max-w-lg text-white">
<h1 className="text-3xl font-medium lg:text-5xl">{banner.headline}</h1>
<p className="mt-3">{banner.subhead}</p>
<a href={banner.ctaHref} className="mt-6 inline-block rounded-full bg-white px-8 py-3 text-neutral-900">
{banner.ctaLabel}
</a>
</div>
</div>
</section>
);
}
The homepage‑specific problems¶
1. The hero carousel¶
Every merchandising team wants five rotating hero slides. Every one of them costs you.
| Problem | Fix |
|---|---|
| Which slide is the LCP element varies | Server‑render slide 1; only it is priority |
| Five hero images downloaded on load | Slides 2–5 lazy; load on interaction or after LCP |
| Carousel library JS | CSS scroll‑snap (4.3) |
| Auto‑advance before LCP finalizes | Delay auto‑advance until after load, or drop it |
| CLS on slide change | Fixed aspect ratio container |
// Slide 1 server-rendered and prioritized; the rest hydrate later
<div className="flex snap-x snap-mandatory overflow-x-auto" role="region" aria-label="Featured">
{slides.map((slide, i) => (
<div key={slide.id} className="w-full shrink-0 snap-start">
<HeroSlide slide={slide} priority={i === 0} loading={i === 0 ? 'eager' : 'lazy'} />
</div>
))}
</div>
The conversation to have with merchandising: measure the click‑through on slides 2–5. On most commerce sites it's under 2% combined. That data usually ends the carousel debate faster than any performance argument.
2. CMS payload size¶
// ❌ The CMS returns every field of every block, including unused localizations
const content = await cms.getEntry('homepage', { include: 10 }); // 340 KB
// ✅ Request only the fields you render, at the depth you need
const content = await cms.getEntry('homepage', {
select: ['hero', 'categories', 'featuredCollection', 'editorial'],
include: 2,
locale: currentLocale, // one locale, not all 14
}); // 28 KB
Then shape it before it reaches React (7.1), so the RSC payload stays small.
3. Third‑party tags¶
The homepage is where marketing adds tags, because it's "the front door". It's also 14% of sessions and the entry point for a large share of first visits — the users with cold caches who can least afford it.
Apply 2.3 rigorously here. Facades for chat, idle loading for retargeting, server‑side for analytics.
4. Personalization on the homepage¶
"Recommended for you" and "recently viewed" rails are the most‑requested homepage features and the least‑measured.
// Prefer client-side from local data — no server cost, no cache fragmentation
'use client';
export function RecentlyViewed() {
const [items, setItems] = useState<Product[] | null>(null);
useEffect(() => {
const ids: string[] = JSON.parse(localStorage.getItem('recently_viewed') ?? '[]');
if (!ids.length) { setItems([]); return; }
// One batched request; the rail is below the fold so this isn't on the critical path
fetch(`/api/products/batch?ids=${ids.slice(0, 8).join(',')}`)
.then((r) => r.json())
.then(setItems);
}, []);
if (items?.length === 0) return null;
// Reserve the space so the section appearing doesn't shift the footer
return (
<section className="min-h-[380px]">
<h2>Recently viewed</h2>
{items ? <ProductRail products={items} /> : <RailSkeleton />}
</section>
);
}
Before building server‑side personalization for the homepage, ask for the A/B result that justifies it. Homepage personalization is one of the most commonly built and least commonly validated features in commerce.
Diagnosis order¶
Homepage LCP > 2.5s?
├─ Is the hero image in the initial HTML? → [2.1] Bug 1
├─ Is `sizes` correct for the hero? → [2.1] Bug 2
├─ Is it AVIF/WebP at a sane quality? → [2.1] Format
├─ Is TTFB > 300ms? → check ISR is working, [6.4]
├─ Are 5 hero slides downloading? → lazy slides 2-5
├─ Is the CMS payload huge? → field selection
└─ Are third parties blocking? → [2.3]
Homepage CLS > 0.05?
├─ Promo bar inserted after paint? → server-render from cookie, [6.3]
├─ Hero without fixed aspect ratio? → aspect-ratio container
├─ Font swap? → [2.2]
└─ Rails appearing late? → reserve min-height
Homepage INP > 200ms?
├─ Carousel library? → CSS scroll-snap
├─ Third-party tags during load? → [2.3], [6.2] Section A
└─ Too much hydration? → [4.4]
Checklist¶
- ISR with on‑demand revalidation on CMS publish
- Hero server‑rendered,
fetchPriority="high", correctsizes, AVIF - Only slide 1 is eager; slides 2+ lazy
- No carousel library — CSS scroll‑snap
- CMS query uses field selection and one locale
- Below‑fold sections streamed with reserved space
- Promo/consent bars server‑rendered or overlaid
- Recently viewed / recommendations below the fold, client‑hydrated
- Third‑party tags deferred or server‑side
- Every personalization module has an A/B result behind it
Next: 10.2 Category / PLP