Skip to content




A really nice Mux video component

A really nice Mux video component

Finding the best way to host video with Mux and Sanity? Well we've got the fastest possible way to get them working together


You might have come across some of the videos on our website and thought: "Gee wowsers, they’re still hitting near perfect performance scores in Lighthouse". Well, let me tell you: it was a struggle.

However, the good news is, today we want to share what took us far too long to perfect, and give you the opportunity to use Mux yourself, without tanking performance with the Mux Player.

It's also worth mentioning that the Mux player (opens in new tab) has more compatibility and better video experience (see Feedback from Dylan Jhaveri for more context) but frankly, I'm fairly certain folks aren't browsing our website on IE8, nor do we want to build for IE8, so this is the compromise we are willing to make (sorry dinosaurs).

The code

typescript
'use client';
// Swap these out for another icon set if you want
import { Loader2, PlayIcon } from 'lucide-react';
// To dynamically load the video player
import dynamic from 'next/dynamic';
import { FC, useState } from 'react';
import { preload } from 'react-dom';
// This is from Sanity type generation
import { MuxVideo, Video as VideoType } from '~/sanity.types';

const getVideoToPlayUrl = (muxId: string) => {
  return `https://stream.mux.com/${muxId}.m3u8?min_resolution=1080p&rendition_order=desc`;
};

const getPosterUrl = (muxId: string) => {
  return `https://image.mux.com/${muxId}/thumbnail.webp?fit_mode=smartcrop&time=0`;
};

const getMuxVideoProps = (muxId: string) => {
  return {
    poster: getPosterUrl(muxId),
    vidUrl: getVideoToPlayUrl(muxId),
  };
};

type VideoPlay = {
  poster: string;
  vidUrl: string;
  loop?: boolean;
  muted?: boolean;
  control?: boolean;
};

const VideoAutoPlay = ({ control, loop, muted, poster, vidUrl }: VideoPlay) => {
  const HlsVideo = dynamic(
    () => import('./hls-video').then((mod) => mod.HlsVideo),
    {
      loading: () => {
        return (
          <div className="relative size-full">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={poster}
              // Pick your most commonly used video size to avoid CLS
              width={1920}
              height={1080}
              alt="a"
              className="mx-auto  size-full "
            />
            <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform ">
              <Loader2 className="animate-spin" size={112} strokeWidth={0.5} />
            </div>
          </div>
        );
      },
    },
  );
  return (
    <HlsVideo
      autoPlay
      poster={poster}
      vidUrl={vidUrl}
      loop={control ? loop : undefined}
      muted={control ? muted : undefined}
      controls={control}
      width={1920}
      height={1080}
      className="mx-auto size-full"
    />
  );
};

const VideoWithOutAutoPlay = (
  props: VideoPlay & { loading?: 'lazy' | 'eager' },
) => {
  const { poster, loading } = props;
  const [play, setPlay] = useState(false);

  return (
    <div className=" size-full">
      {play ? (
        <div className="relative size-full ">
          <div className="my-4">
            <VideoAutoPlay {...props} />
          </div>
        </div>
      ) : (
        <div className="relative size-full">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={poster}
            width={1920}
            height={1080}
            loading={loading === 'lazy' ? 'lazy' : 'eager'}
            alt="a"
            className="mx-auto  size-full "
          />
          <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform ">
            <button
              className="grid place-items-center rounded-full bg-black p-8 hover:bg-hotpink-400"
              onClick={() => setPlay(true)}
            >
              <PlayIcon className="h-8 w-8 fill-white text-white" />
              <span className="sr-only">Play</span>
            </button>
          </div>
        </div>
      )}
    </div>
  );
};

export type VideoProps = VideoType & {
  videoEmbed: NonNullable<VideoType['videoEmbed']> & {
    video: MuxVideo & {
      asset: {
        playbackId: string;
      };
    };
  };
};

export const Video: FC<VideoProps & { loading?: 'lazy' | 'eager' }> = ({
  videoEmbed,
  loading = 'eager',
}) => {
  const { video, autoPlay, control, loop, muted } = videoEmbed ?? {};

  const { poster, vidUrl } = getMuxVideoProps(video?.asset?.playbackId);

  preload(vidUrl, {
    as: 'video',
    fetchPriority: 'high',
  });
  preload(poster, {
    as: 'image',
    fetchPriority: 'high',
  });

  const videoProps = { poster, loading, vidUrl, loop, muted, control };

  return (
    <div className="mx-auto flex max-w-7xl">
      {control && autoPlay ? (
        <VideoAutoPlay {...videoProps} />
      ) : (
        <VideoWithOutAutoPlay {...videoProps} loading={loading} />
      )}
    </div>
  );
};

Performance improvements

Well remember how there was some bloke called Charles Goodhart said: "When a measure becomes a target, it ceases to be a good measure". Well we basically ignored everything that person said, and decided to optimise to see if it was possible to hit 100/100 on a website that still has a lot of images, fancy libraries and analytics.

If you're looking to try and get the perfect score on your website, get in touch with us. We sure as hell know a lot about this stuff after all the hassle we've been through.

Services
$ Next.js development
We build fast, well-architected Next.js apps. Performance included.
>Learn more

No spam, only good stuff

Subscribe, for more hot takes

Only god knows why anybody would purposefully subscribe themselves to a newsletter that moans about development. These poor souls did though
Profile 1
Profile 2
Profile 3
Profile 4
Profile 5

A quick explainer what this is good for

Feedback from Dylan Jhaveri

We posted this on Vercel Community (opens in new tab), and got some great feedback and better context. So, before you go ahead and straight up copy the code without knowing the tradeoffs make sure you read this bit. "Love the work you’ve done in your example to optimize for lighthouse score. There is a fundamental tradeoff here that I think folks should think about:

web vitals (lighthouse score) vs. video performance

The biggest change that I can see, if I’m following correctly is waiting to load <hls-video> until after the user clicks play.

That’s going to make your lighthouse score great, (which is great!) – but the tradeoff is that when the user clicks play it’s going to take more time for playback to start and the user to see the first frame of the video.

For your use case on roboto.studio I think that probably makes perfect sense. For other use cases where you want the video to load fast you may want to lazy load the player 2 (https://www.mux.com/docs/guides/player-lazy-loading (opens in new tab)), which tries to balance the tradeoffs:

  • Doesn’t block the initial page load (despite this, your lighthouse scores will be impacted negatively)
  • Lazy loads the player without requiring the user to click play so that when the user does click play the video starts up immediately"

Cheers Dylan, and thanks for the free content. Go read his blog posts here (opens in new tab)

Frequently asked questions

How do I use Mux video in Next.js without hurting Lighthouse scores?
Show a Mux poster image with a play button first and only load the HLS player, via a dynamic import, once the user clicks play. The page loads an image instead of a video player, which is how we kept near-perfect Lighthouse scores on a site with plenty of video, images and analytics.
What is the trade-off of loading the Mux player on click?
Web vitals versus video performance. As Dylan Jhaveri from Mux pointed out, deferring the player until play is clicked makes the Lighthouse score great but adds a delay before the first frame appears. If fast playback matters more, Mux's player lazy-loading guide balances the two, at some cost to Lighthouse.
Why not just use the Mux Player?
The Mux Player has wider compatibility and a better video experience, but it costs performance on initial load. We chose the lighter approach because our visitors are not on very old browsers, and we call that a deliberate compromise rather than a recommendation for everyone.
How do I get a poster image from a Mux playback ID?
Use the Mux image URL: https://image.mux.com/{playbackId}/thumbnail.webp with fit_mode=smartcrop and time=0. The component builds this from the playbackId on the Sanity Mux asset and uses it both as the poster and as the loading placeholder.
What stream URL does the component use for Mux playback?
It plays the HLS manifest at https://stream.mux.com/{playbackId}.m3u8 with min_resolution=1080p and rendition_order=desc, so the player tries the highest renditions first.
How does the component avoid layout shift?
The poster img and the HlsVideo element are both given a fixed width of 1920 and height of 1080, matching the most commonly used video size. The component also preloads the video URL and the poster with high fetch priority.
Does the component support autoplay, loop and muted?
Yes. The Sanity videoEmbed object carries autoPlay, control, loop and muted flags. When control and autoPlay are both set, the VideoAutoPlay variant renders straight away; otherwise the poster-and-play-button variant is used and the player only mounts on click.

About the authors

Jono Alford

Founder of Roboto Studio, specializing in headless CMS implementations with Sanity and Next.js. A Sanity Pioneer and first-cohort Sanity Community Ambassador, focused on editorial experiences that help teams ship faster.

Hrithik Prasad

Tech Lead with expertise in React, Next.js, and Sanity CMS. Loves building performant web applications and sharing knowledge through technical content.



Related posts





Get in touch

Tell us what you're building. We reply within one working day. Jono or someone on the team picks up every message personally.

By sending this you agree to our privacy policy. We only use your details to reply.