September 17, 2026

Playing HLS in SvelteKit

Masroor Ahmed
Masroor Ahmed
AI/ML Engineer

Two errors arrive before HLS is involved at all. One is window is not defined during server rendering, because a player library touched a browser API while Node was rendering your page. The other is a module that imports but behaves as if it were a different copy of itself. That's Vite's dependency pre-bundling doing its job on a package that didn't want it done. Both have specific fixes, and neither has anything to do with video.

TL;DR

Safari and iOS have always played HLS natively, desktop Chrome has since version 142, and Firefox still doesn't, so anything hand-rolled carries a browser branch you maintain. Before you get there, two SvelteKit-specific errors arrive. Import the player inside onMount, because SvelteKit server-renders components and a module-scope import of hls.js or most third-party players touches browser APIs in Node. And if a package with its own client runtime misbehaves only in dev, add it to optimizeDeps.exclude in vite.config.js and restart. Vite pre-bundles dependencies and caches the result. <fastpix-player> is a web component, so Svelte needs no wrapper for it.

Which browsers need an HLS library in SvelteKit

Safari and iOS play HLS through the native <video> element, and desktop Chrome has since version 142. Firefox doesn't, and neither do older Chrome and Edge builds. Those browsers need a JavaScript library that fetches the manifest, requests segments and feeds them to Media Source Extensions.

That means any hand-rolled implementation carries a branch: use the native element where it works, load a library where it doesn't. Getting that branch right, and keeping it right as browsers change, is work that recurs.

BrowserNative HLSNeeds a library
Safari, iOSYesNo
Chrome, EdgeYes, since Chrome 142Not for basic playback
FirefoxNoYes
Older Chrome and EdgeNoYes

What you need before you start

  • A SvelteKit application with server-side rendering enabled.
  • A playback ID for a FastPix video, or any .m3u8 URL to test against.
  • Access to vite.config.js, because one of the two fixes lives there.

Fix one: import the SvelteKit video player in onMount

SvelteKit renders your components on the server for the initial HTML. Any module that touches window, document or customElements at import time fails there.

<fastpix-player> is a web component, so it registers itself against customElements when imported. Version 1.0.21 and later guard that registration and survive server rendering, but hls.js and most third-party players don't, and importing in onMount also keeps the player out of the server payload. The element sits in the markup from the first render:

text
<script lang="ts">
  import { onMount } from "svelte";

  let { data } = $props();

  onMount(() => {
    import("@fastpix/fp-player");
  });
</script>

<fastpix-player playback-id={data.playbackId} stream-type="on-demand"></fastpix-player>

It upgrades itself as soon as the definition arrives. Nothing flashes and nothing needs a loading state, because an unupgraded custom element is just an inert tag.

The same rule applies to hls.js if you go that route. Import it dynamically inside onMount, never at the top of the file.

Fix two: exclude the player from Vite's dependency optimiser

This is the error that looks like a bug in the library and isn't.

Vite pre-bundles dependencies during development to keep the dev server fast. For most packages that's invisible. Some packages carry a client runtime that has to be the same instance everywhere. Pre-bundling can produce two copies. The symptom is a module that imports fine and then behaves as though it's talking to a different object.

Exclude it:

javascript
// vite.config.js
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [sveltekit()],
  optimizeDeps: {
    exclude: ["hls.js"],
  },
});

Name whatever package is being duplicated. Excluding it takes it out of the pre-bundle, so dev and production load the same module graph and the duplicate disappears.

This is a general Vite behaviour rather than a SvelteKit one, and it isn't specific to video. Any package that keeps state in module scope, registers a custom element, or checks instanceof against its own classes can hit it.

Vite hashes optimizeDeps into its cache key, so editing it invalidates the pre-bundle and the dev server restarts itself. If you're on an older Vite or the restart doesn't happen, stop the server and delete node_modules/.vite.

Using a player removes the browser branch

<fastpix-player> handles the browser branch internally. The native-versus-library decision stops being code you own:

text
<script lang="ts">
  import { onMount } from "svelte";
  let { data } = $props();
  onMount(() => { import("@fastpix/fp-player"); });
</script>

<fastpix-player
  playback-id={data.playbackId}
  stream-type="on-demand"
  accent-color="#14CC80"
></fastpix-player>

<style>
  fastpix-player { width: 100%; aspect-ratio: 16 / 9; }
</style>

It needs a size. It fills whatever container it sits in, and a container with no height renders nothing. The full attribute list is in install the FastPix web player.

You can test this against a real adaptive stream first. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.

Because it's a web component rather than a React component, Svelte needs no bridge for it. That's the opposite of the upload side, where the prebuilt uploader is a React component and mounting it costs you a React root. Upload video from SvelteKit without adding React covers both routes.

Fetching the playback ID in a SvelteKit load function

Fetch the media in +page.server.ts. Credentials stay on the server and only the ID crosses to the browser:

typescript
// src/routes/play/[mediaId]/+page.server.ts
import { error } from "@sveltejs/kit";
import { fastpix } from "$lib/fastpix.server";

export async function load({ params }) {
  const media = await fastpix.manageVideos.get({ mediaId: params.mediaId });

  if (!("data" in media)) throw error(404, "Media not found.");

  return { playbackId: media.data!.playbackIds![0].id! };
}

A playback ID isn't a media ID. One media asset can carry several playback IDs, with different access policies. With a public policy the ID alone is enough to play the video. A private or DRM-protected video needs a signed token minted on the server, covered in secure video playback. That's another reason this fetch belongs in a load function rather than in the component.

For what the manifest contains, and why an adaptive stream has several, a complete guide to M3U8 files in HLS streaming covers the protocol side.

Get an adaptive stream playing in your SvelteKit app

Fetch the playback ID in +page.server.ts, import the player inside onMount, and give the element a width and an aspect ratio. Those three steps clear both of the errors that arrive before HLS does. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. That gives you a real adaptive stream to test the browser branch against.

Frequently Asked Questions (FAQs)

How do I play HLS video in SvelteKit?

Use a player that handles the browser branch for you. Import it inside onMount, which is where a browser-only library has to load and where the player's own bundle stays out of the server payload. <fastpix-player> is a web component, so it goes straight into Svelte markup with no wrapper. Give it a width and an aspect-ratio because it fills its container, and a container with no height renders nothing.

Why do I get "window is not defined" in SvelteKit with a video player?

Because a player library is imported at module scope and touches a browser API at import time. SvelteKit renders components on the server for the initial HTML, so that import runs in Node. hls.js and most third-party players do this; the FastPix player has guarded it since 1.0.21. Move the import inside onMount, which never runs on the server, and the element upgrades itself once the definition arrives.

Why does hls.js behave oddly only in SvelteKit dev mode?

Usually because Vite pre-bundles dependencies during development. A package whose client runtime must be a single shared instance can end up duplicated. Add it to optimizeDeps.exclude in vite.config.js and restart the dev server. Vite hashes the option into its cache key, so the dev server invalidates the pre-bundle and restarts itself.

Do I need hls.js if Safari plays HLS natively?

For Firefox and for older Chrome and Edge, yes. Safari and iOS have always played HLS through the native <video> element, and desktop Chrome has since version 142, but Firefox still needs a library feeding segments through Media Source Extensions. A hand-rolled implementation therefore carries a browser branch you have to maintain, which is what a player component removes.

Why does my video element render nothing in SvelteKit?

Most often because it has no size. The player fills whatever container it sits in, so a parent with no height gives it no height. Set width: 100% and an aspect-ratio on the element. If it still renders nothing, check that the playback ID is right and that the media has finished encoding.

Share

Stay Ahead of Video
Streaming Trends

Start shipping video today.