Responsive Video Delivery in Next.js and React

Video delivery in component-driven applications demands more discipline than static <video> embeds. As part of the broader Responsive Image & Video Delivery architecture, this guide focuses on the layer between your transcode pipeline and the React component tree: source negotiation across codecs, deferred hydration to protect Largest Contentful Paint, poster pre-loading, and Cache-Control header configuration for edge caching. The output is a <ResponsiveVideo> component that ships zero bytes of player JS until the element enters the viewport, supports the full AV1 → VP9 → H.264 fallback chain, and passes WCAG 2.2 AA audit.

Concept & Architecture

The browser’s built-in <video> codec negotiation works identically to <picture> format negotiation: it reads <source> elements in document order and plays the first src whose type codec string passes HTMLMediaElement.canPlayType(). This means the media pipeline has three distinct responsibilities that must be designed separately.

Responsive video pipeline: Transcode → Serve → Negotiate Diagram showing three pipeline stages: (1) FFmpeg transcode producing AV1, VP9, H.264 variants; (2) Next.js + CDN serving with immutable Cache-Control and Range headers; (3) Browser codec negotiation selecting the first supported source. 1 · Transcode FFmpeg → AV1 (mp4) FFmpeg → VP9 (webm) FFmpeg → H.264 (mp4) 2 · Serve Cache-Control: immutable Accept-Ranges: bytes Content-Type: video/* 3 · Negotiate canPlayType() → AV1? else VP9, else H.264 preload="metadata" CI/CD CDN edge Responsive Video Pipeline Transcode once in CI → serve from edge → browser negotiates the best codec

Stage 1 — Transcode. FFmpeg converts source media into codec/container pairs during your build or CI job. Each output targets a different browser generation.

Stage 2 — Serve. Next.js route handlers and CDN edge rules apply Cache-Control: immutable, enforce Accept-Ranges: bytes (mandatory for mobile seek), and set correct MIME types for WebM and MP4 containers.

Stage 3 — Negotiate. The browser evaluates <source> elements in order. Explicit codecs= strings in the type attribute let the browser decide without a network round-trip.

Hydration control lives across stages 2 and 3: React defers mounting the <video> element until IntersectionObserver signals viewport entry, eliminating eager preload requests for below-the-fold video.

Benchmark Reference

Codec selection has measurable file-size and decode-latency consequences. Use these baselines to justify the transcode overhead in your pipeline.

Codec Container Typical bitrate saving vs H.264 Decode hardware accel Safari 14 Safari 16 Chrome 85+ Firefox 93+ Edge 18+
AV1 (libsvtav1) mp4 50–60 % Chrome 90+, Safari 16.4+ No Partial (hw only) Yes Yes Yes
VP9 (libvpx-vp9) webm 30–40 % Limited (software decode) Yes Yes Yes Yes Yes
H.264 (libx264) mp4 baseline Universal Yes Yes Yes Yes Yes
HEVC/H.265 mp4 40–50 % Apple silicon, A-series Yes (hw) Yes (hw) No No Partial

Tradeoff: AV1 encode time is 5–20x slower than H.264 at equivalent quality. Run AV1 encodes on fast presets (-preset 6 for libsvtav1) in CI and reserve slower presets for offline archival masters.

Step-by-Step Implementation

Step 1 — Transcode Multi-Codec Variants

Run FFmpeg once per source file in your build pipeline. Output three variants: AV1 in mp4 (highest compression, modern browsers), VP9 in webm (broad coverage), and H.264 in mp4 (universal fallback).

# AV1 in MP4 container via libsvtav1
# -crf 30     — constant-rate factor: lower = higher quality (0–63 scale, NOT inverted like avifenc)
# -preset 6   — speed/quality tradeoff; 0=slowest, 12=fastest; 6 is a CI-safe default
# -c:a libopus — Opus audio; required for AV1 containers in Chrome
ffmpeg -i input.mp4 \
  -c:v libsvtav1 -crf 30 -preset 6 \
  -c:a libopus -b:a 96k \
  -movflags +faststart \
  output_av1.mp4

# VP9 in WebM — broader Safari 14+ support than AV1
# -b:v 0      — enable constant-quality mode (required when -crf is set for VP9)
# -row-mt 1   — row-based multi-threading; reduces encode time ~40% on multi-core CI
ffmpeg -i input.mp4 \
  -c:v libvpx-vp9 -crf 30 -b:v 0 -row-mt 1 \
  -c:a libopus -b:a 96k \
  output_vp9.webm

# H.264 in MP4 — IE11+ universal fallback
# -movflags +faststart moves the moov atom to the front for progressive streaming
ffmpeg -i input.mp4 \
  -c:v libx264 -crf 23 -preset fast -profile:v high \
  -c:a aac -b:a 128k \
  -movflags +faststart \
  output_h264.mp4

Step 2 — Configure Next.js Caching Headers

Set Cache-Control: immutable on all video routes. Use content-hashed filenames (e.g. hero-a1b2c3.mp4) so the immutable directive is safe to deploy.

// next.config.js
// All /videos/* paths receive 1-year immutable caching.
// Warning: only use immutable on content-hashed filenames — a filename collision
// will serve stale video from CDN edge until the hash changes.
module.exports = {
  async headers() {
    return [
      {
        source: '/videos/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable'
            // 31536000 seconds = 1 year; immutable tells CDNs never to revalidate
          },
          {
            key: 'Accept-Ranges',
            value: 'bytes'
            // Required for mobile seek — without this iOS Safari cannot scrub
          }
        ]
      }
    ];
  }
};

Step 3 — Build the <ResponsiveVideo> Component

The component uses IntersectionObserver to defer mounting the <video> element until it is within 200px of the viewport. Before intersection, only the poster <img> is rendered — no video bytes are fetched.

// components/ResponsiveVideo.tsx
'use client'; // Next.js App Router: this component requires browser APIs

import { useEffect, useRef, useState } from 'react';

interface VideoSources {
  av1: string;   // AV1 in mp4 container — primary, highest compression
  vp9?: string;  // VP9 in webm — broad coverage; optional but strongly recommended
  h264: string;  // H.264 in mp4 — universal fallback; always required
}

interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
  src: VideoSources;
  poster: string;
  aspectRatio?: string; // e.g. '16/9', '4/3'; defaults to '16/9'
}

export function ResponsiveVideo({
  src,
  poster,
  aspectRatio = '16/9',
  ...props
}: VideoProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(false);

  useEffect(() => {
    if (!containerRef.current) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setActive(true);
          io.disconnect(); // one-shot — stop observing after first intersection
        }
      },
      { rootMargin: '200px' } // pre-load 200px before entering viewport to avoid flash
    );
    io.observe(containerRef.current);
    return () => io.disconnect();
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        position: 'relative',
        width: '100%',
        aspectRatio, // reserve layout space before video loads → CLS = 0
        background: '#000',
        overflow: 'hidden'
      }}
    >
      {active ? (
        <video
          {...props}
          preload="metadata"  // fetch only duration/dimensions, not payload
          playsInline          // mandatory for iOS Safari inline playback (no fullscreen forced)
          controls
          poster={poster}
          aria-label={props['aria-label'] ?? 'Video player'}
          style={{ width: '100%', height: '100%', display: 'block' }}
        >
          {/* Explicit codecs= string lets canPlayType() decide without a network probe */}
          <source src={src.av1} type='video/mp4; codecs="av01.0.05M.08"' />
          {src.vp9 && (
            <source src={src.vp9} type='video/webm; codecs="vp9"' />
          )}
          <source src={src.h264} type='video/mp4; codecs="avc1.42E01E"' />
          {/* Static fallback when JS/video is unavailable */}
          <img src={poster} alt={props['aria-label'] ?? 'Video placeholder'} loading="lazy" />
        </video>
      ) : (
        // Poster-only state: renders until IO triggers, zero video bytes fetched
        <img
          src={poster}
          alt=""                // decorative — the video element will replace this
          aria-hidden="true"
          fetchPriority={props.autoPlay ? 'high' : 'auto'}
          style={{ width: '100%', height: '100%', objectFit: 'cover' }}
        />
      )}
    </div>
  );
}

Step 4 — Add Captions and Reduced-Motion Support

Captions are a WCAG 2.2 Level AA requirement (Success Criterion 1.2.2). The prefers-reduced-motion media query disables autoplay for users who have requested reduced animation.

// Usage: wrap with reduced-motion guard at the call site
import { useMediaQuery } from '@/hooks/useMediaQuery'; // project-specific hook

export function HeroVideo() {
  // prefers-reduced-motion: disable autoplay for accessibility
  const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <ResponsiveVideo
      src={{
        av1: '/videos/hero-a1b2c3_av1.mp4',
        vp9: '/videos/hero-a1b2c3_vp9.webm',
        h264: '/videos/hero-a1b2c3_h264.mp4'
      }}
      poster="/videos/hero-poster.jpg"
      aria-label="Product overview video"
      autoPlay={!reduced}   // honour OS-level animation preference
      muted                 // required for autoPlay in any browser
      loop
    >
      {/* VTT captions — WCAG 2.2 AA SC 1.2.2 */}
      <track
        kind="captions"
        src="/videos/hero-captions-en.vtt"
        srclang="en"
        label="English"
        default
      />
    </ResponsiveVideo>
  );
}

Parameter Reference

Attribute / flag Where used Effect
preload="metadata" <video> Browser fetches only the moov atom (duration, dimensions); avoids full payload download on page load
playsInline <video> Required on iOS Safari — without it Safari forces fullscreen on play
codecs="av01.0.05M.08" <source type> Profile 0, level 5.0, Main tier, 8-bit; matches libsvtav1 default; allows canPlayType() to resolve without a byte fetch
codecs="avc1.42E01E" <source type> H.264 Baseline Profile 3.0; broadest device support including older Android
-movflags +faststart FFmpeg Moves moov atom to start of mp4 file; enables progressive streaming without full download
-crf 30 (libsvtav1) FFmpeg Constant-rate factor; 0 = lossless, 63 = worst; 30 gives ~0.93 SSIM at typical web bitrates
-b:v 0 (libvpx-vp9) FFmpeg Disables VBR bitrate cap; required to activate CQ (constant-quality) mode alongside -crf
rootMargin: '200px' IntersectionObserver Pre-triggers 200px before the element enters the viewport; prevents visible poster→video flash on fast connections
aspectRatio CSS prop Container <div> Reserves layout space before the video element mounts; maintains CLS = 0
fetchPriority="high" Poster <img> Prioritises poster fetch for above-the-fold videos; do not apply to more than one element per page to avoid fetch-priority starvation

Tradeoffs & Edge Cases

Tradeoff: AV1 encode time. libsvtav1 at -preset 6 is 3–5x slower than libx264 at -preset fast. For CI pipelines with many video assets, parallelise FFmpeg jobs and cache outputs by content hash. Reserve AV1 generation for assets over 10 seconds; shorter clips may not recoup the transcode cost.

Warning: Safari 16.4 AV1 software fallback. Safari 16.4 supports AV1 decode but falls back to software decoding on Macs without Apple silicon or Intel Ice Lake+. Software AV1 decode can spike CPU to 100% on a 1080p clip, causing frame drops. Test on real hardware; for Safari-heavy audiences keep VP9 in the source order.

Warning: fetchpriority="high" starvation. Applying fetchpriority="high" to more than one element on a page causes the browser’s preloader to deprioritise CSS and fonts. Limit high-priority poster fetches to the single above-the-fold video element.

Tradeoff: rootMargin size. A 200px root margin pre-fetches video on fast connections before the user sees the element, but on a slow 3G connection it can initiate a large download the user may never watch. Consider reading navigator.connection.saveData and reducing the root margin to 0px when saveData === true.

Warning: SSR hydration mismatch. The active state in <ResponsiveVideo> starts as false on the server and true only after IO triggers on the client. React 18 Suspense boundaries and startTransition do not help here — use a suppressHydrationWarning prop on the container div if the poster/video difference triggers a hydration error in strict mode.

Tradeoff: HEVC/H.265 as a second fallback. H.265 achieves better compression than VP9 on Apple hardware but is absent from Chrome and Firefox. Adding it between VP9 and H.264 in the source list has no effect on Chrome/Firefox (they skip to H.264), while Safari selects it over H.264. Only add H.265 if your analytics show >20% Safari share; the extra transcode/storage cost is rarely justified otherwise.

Debugging & Validation

Confirm codec negotiation via canPlayType() in the browser console before filing a “video won’t play” bug:

// Run in the browser console to check what the current UA actually supports
const v = document.createElement('video');
console.table({
  'AV1 mp4':  v.canPlayType('video/mp4; codecs="av01.0.05M.08"'),
  'VP9 webm': v.canPlayType('video/webm; codecs="vp9"'),
  'H264 mp4': v.canPlayType('video/mp4; codecs="avc1.42E01E"')
  // Returns: "probably", "maybe", or "" (empty = not supported)
});

Inspect response headers to confirm Accept-Ranges and Cache-Control are set correctly:

# -sI: silent mode, headers only; replace with your actual video URL
curl -sI https://example.com/videos/hero_av1.mp4 | grep -E 'Content-Type|Cache-Control|Accept-Ranges'
# Expected:
# Content-Type: video/mp4
# Cache-Control: public, max-age=31536000, immutable
# Accept-Ranges: bytes

Check for eager preload in the Network panel. In Chrome DevTools → Network → filter by Media: if video requests fire on page load for below-the-fold elements, IntersectionObserver is not wiring up correctly — the most common cause is the containerRef being null on mount due to a conditional render.

Lighthouse audit. Run Lighthouse in mobile simulation. Key signals: LCP should be driven by the poster <img> (not the video element itself); aspect-ratio on the container ensures CLS = 0 across all viewports. Any CLS > 0 on a video element usually means the container is missing its aspect-ratio rule.

Validate VTT captions with the W3C validator at https://quuz.org/webvtt/ or via the vtt npm package. Missing or malformed cues cause the captions track to silently fail in Safari — it will show no error in the console.