You have a live video stream in your homelab. Maybe it’s a security camera feed from Frigate, a local channel from an HDHomeRun tuner, or a curated IPTV playlist. You want to watch it on a custom dashboard, not in a clunky app. The standard advice is to spin up a heavy media server like Jellyfin or write a custom media proxy in Node.js to handle the stream.
That’s usually overkill.
If your source already outputs a standard HLS (HTTP Live Streaming) stream, you can play it directly in any modern browser with a few lines of JavaScript. By building your dashboard with Astro, you can create a fast, static front end that serves the video player, which then connects directly to your stream source. No media proxy, no backend server, no unnecessary resource drain.
The “No Media Proxy” Philosophy
A media proxy sits between your video source and your browser. It fetches the video stream and “re-serves” it. Proxies can be useful for transcoding, authentication, or bypassing network restrictions. They also add a significant point of failure and complexity.
This setup skips that layer entirely. Here’s the data flow:
- Your browser loads a static HTML page from your Astro site.
- The page contains a JavaScript video player.
- The player connects directly to the HLS stream URL on your local network (e.g.,
http://192.168.1.50:8080/stream.m3u8). - The browser handles fetching the HLS manifest and video segments.
Your Astro server’s job is done after serving the initial HTML and JavaScript. It isn’t involved in the video streaming itself. This is brutally efficient. The only things that matter are the client’s network connection to the stream source and the source’s ability to serve the stream.
Prerequisite: A Working HLS Source
This whole idea depends on you having a device or service on your network that provides an HLS stream. You can’t just point it at a raw video file. HLS breaks video into small .ts chunks and provides a .m3u8 playlist file that tells the player where to find them.
Many homelab tools can do this. A common one is go2rtc, which re-streams RTSP cameras and other sources and serves each stream as HLS from its built-in API on port 1984. Its README documents both the HLS URL format and a CORS switch, which is exactly what a static site needs.
A minimal compose.yaml:
services:
go2rtc:
image: alexxit/go2rtc
container_name: go2rtc
network_mode: host
volumes:
- ./go2rtc.yaml:/config/go2rtc.yaml
restart: unless-stopped
And go2rtc.yaml, with one stream and CORS enabled for the API:
api:
origin: "*" # send Access-Control-Allow-Origin for browser players
streams:
camera1: rtsp://user:password@192.168.1.60:554/stream1
The HLS playlist for that stream is then http://<go2rtc-host>:1984/api/stream.m3u8?src=camera1 (add &mp4 for fMP4 segments).
The Critical Part: CORS Headers
Your browser, for security reasons, will block a script on dashboard.your.domain from fetching a video stream from 192.168.1.50:1984 unless the server at that IP explicitly allows it. This is CORS (Cross-Origin Resource Sharing).
Your HLS source must send the Access-Control-Allow-Origin header in its HTTP responses. In go2rtc that’s the api.origin setting above. For other tools, check their documentation, or put them behind a reverse proxy that adds the header.
Also note that go2rtc’s API has no authentication by default: anyone on your network can watch the streams. Keep it on the LAN or behind authentication.
Building the Astro Component
Now for the Astro part. This is surprisingly simple. We’ll use the popular hls.js library because browser-native HLS support can be inconsistent, especially with error handling and recovery.
Create a new component at src/components/HlsPlayer.astro.
---
// src/components/HlsPlayer.astro
export interface Props {
streamUrl: string;
}
const { streamUrl } = Astro.props;
---
<!-- hls.js is tiny, loading from a CDN is fine for this. -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<div class="video-container">
<video id="hls-video" controls muted autoplay></video>
</div>
<script define:vars={{ streamUrl }}>
document.addEventListener('DOMContentLoaded', () => {
const video = document.getElementById('hls-video');
const hls = new Hls();
if (Hls.isSupported()) {
console.log(`Attaching HLS stream: ${streamUrl}`);
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
video.play().catch(() => {
console.log("Autoplay was blocked by the browser. User interaction is needed.");
});
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Fallback for browsers with native HLS support (like Safari)
video.src = streamUrl;
video.addEventListener('loadedmetadata', function () {
video.play().catch(() => {
console.log("Autoplay was blocked by the browser. User interaction is needed.");
});
});
}
// Basic error handling
hls.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('Fatal network error encountered, trying to recover');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Fatal media error encountered, trying to recover');
hls.recoverMediaError();
break;
default:
// Cannot recover
console.error('Unrecoverable HLS error. Destroying HLS instance.');
hls.destroy();
break;
}
}
});
});
</script>
<style>
.video-container {
width: 100%;
aspect-ratio: 16 / 9;
background-color: #000;
}
#hls-video {
width: 100%;
height: 100%;
}
</style>
Here’s what this component does:
- It accepts a
streamUrlprop. - It pulls in the
hls.jslibrary from a CDN. - It defines a standard HTML
<video>element. Theautoplayandmutedattributes are important; most browsers block un-muted autoplay. - The client-side
<script>tag (not processed by Astro) initializeshls.js, attaches it to the<video>element, and points it at your stream URL. - It includes a fallback for Safari, which has good native HLS support.
- It has some basic error handling to try and recover from network hiccups.
Now, you can use this component anywhere on your Astro site.
---
// src/pages/dashboard.astro
import HlsPlayer from '../components/HlsPlayer.astro';
const securityCamFeed = "http://192.168.1.110:8083/stream.m3u8";
const localNewsFeed = "http://192.168.1.50:1984/api/stream.m3u8?src=camera1";
---
<html lang="en">
<head>
<title>Homelab Dashboard</title>
</head>
<body>
<h1>Live Streams</h1>
<div class="grid">
<div>
<h2>Security Camera</h2>
<HlsPlayer streamUrl={securityCamFeed} />
</div>
<div>
<h2>Local News</h2>
<HlsPlayer streamUrl={localNewsFeed} />
</div>
</div>
</body>
</html>
Build your site (npm run build), and the resulting static files in the dist/ directory contain everything needed. Serve them with any static web server.
Real-World Gotchas
This setup works incredibly well, but you have to respect the architecture.
- Network Path is King: The browser playing the video must have a direct network route to the
streamUrl. If you access your Astro dashboard from outside your home network, you need a VPN like Tailscale or WireGuard to access the local stream IP. The Astro site itself can be hosted anywhere, but the stream source must be reachable by the client. - No Transcoding: This is a raw feed. If your camera outputs a 20 Mbps 4K stream, that’s what the browser will try to download. There’s no server in the middle to downscale it for a slow connection. You get what the source gives you.
- Authentication: The stream URL is sitting in plain text in the HTML/JS source. This method is not suitable for streams that require robust authentication. It’s designed for trusted networks.
It takes surprisingly little code. Often the hardest part isn’t Astro or HLS, but getting a clean stream out of the camera or tuner in the first place: check its firmware documentation for RTSP or HLS output. The Astro side of things was trivial once the source was solid.
What’s Next
Once you have a direct HLS pipeline, you can build all sorts of interesting things on top of it.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.