Skip to content

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

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", correct sizes, 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