← Blog

How I Built a Full IPTV Frontend with Next.js 16 + Docker in One Weekend

· 10 min read

I didn't set out to build an IPTV platform. I set out to stop fighting five different apps on five different screens. One long weekend later I had a working Streamly tab — live TV, movies, series, EPG, a custom player, TV-browser layout, and a Docker image I could hand to friends. This is how that weekend was actually spent, not a LinkedIn carousel version.

The stack is boring on purpose: Next.js 16, React 19, Tailwind CSS v4, HLS.js, Zustand, TanStack Query, SQLite via Drizzle, and Docker for deployment. Boring stacks ship.

Friday night: make one channel play in a browser

The first blocker wasn't UI — it was physics. Browsers don't let you point a <video> tag at a random Xtream .m3u8 URL from another domain and call it a day. CORS, mixed content, and tokenized URLs kill naive approaches fast.

So the first real commit was two API routes:

  • /api/xtream — JSON proxy to player_api.php with credentials in headers, not the query string.
  • /api/stream — media proxy that rewrites HLS manifests so segment URLs loop back through your origin.

Once one live channel played in Chrome without opening DevTools every thirty seconds, I let myself touch CSS. That rule saved the weekend.

Saturday morning: Xtream as a typed client, not a junk drawer

Xtream panels speak a deceptively simple API: categories, streams, VOD, series, EPG actions. The pain is inconsistency — missing fields, numeric category names, series payloads that look different panel to panel.

I wrapped everything in src/lib/xtream.ts: one call() function hitting our proxy, plus helpers like liveCatalogBundle() and vodCatalogBundle()that merge categories with streams on the server when possible. That cut initial live-page load from "download half the panel" to "fetch what the shelf needs."

M3U support came later the same day — same login screen, different tab. Some providers only hand you a URL. Fine. Parse it, don't preach.

Saturday afternoon: the player (where weekends usually die)

I could have embedded Video.js and moved on. I didn't, because IPTV isn't a single MP4 — it's live HLS, VOD HLS, occasional weird codecs, stall recovery, and "why is there no audio on this channel but the next one is fine."

Player.tsxgrew into a proper overlay: seek bar, audio tracks where the manifest exposes them, playback speed for VOD, EPG drawer on live, cast hooks, and a transcode fallback path when the browser can't decode what the provider sent (common with MKV/HEVC).

HLS.js on desktop, native HLS where it's better (hello iOS live AC-3 quirks), and a server-side ffmpeg transcode lane for the stubborn stuff. Not glamorous. Very IPTV.

Saturday evening: catalogs that don't melt the tab

A full live category list can be tens of thousands of channels. Rendering that as React cards is how you learn what "main thread long task" means in production.

Fixes that actually mattered:

  • TanStack Virtual for channel grids and episode lists.
  • Server-built live/VOD catalogs (/api/live/catalog, /api/vod/catalog) so the browser isn't parsing megabytes of JSON on a phone.
  • Shelf-based browsing (Netflix-style rows) instead of one infinite grid on first paint.
  • EPG with a three-tier fallback: provider short EPG → full schedule → public XMLTV when the channel name gives us a country hint.

Sunday: auth, SQLite, and "oh right, production"

Personal-use IPTV could stay client-only forever. I wanted optional Streamly accounts — save providers encrypted, sync favorites, TV pairing — so Sunday was Drizzle + SQLite + Auth.js (NextAuth v5 beta, because I live dangerously).

SQLite is the right call for a single-node IPTV frontend: one file, easy backups, no Postgres babysitting on a $5 box. Migrations via drizzle-kit push. Done.

Resend for verification email, Turnstile on first Xtream probe, rate limits on auth routes, HttpOnly cookies for IPTV sessions — the unsexy checklist that keeps a public URL from becoming a credential honeypot.

Sunday night: Docker and the first real deploy

docker compose up with a named volume for data/stream.db, environment secrets from .env, port 3000 behind nginx/Caddy on the host. The Dockerfile is multi-stage: install, build, run as a slim production image.

First VPS build took longer than my laptop (expected). Health check at /api/health, systemd unit, rsync deploy script for later pushes. I wrote the longer Docker guide after I’d already broken production twice — see the self-host post if you want the polished version.

What I deliberately did not build that weekend

  • A reseller panel or playlist marketplace (never).
  • Native iOS/Android apps — it's a URL; TV browsers exist.
  • Real-time chat, social features, or a blockchain anything.
  • Perfect EPG for every channel on earth — heuristics + fallbacks won.

Scope is a feature. The goal was a calm IPTV web playerI'd actually open after work.

The stack today (if you're cloning the repo)

Next.js 16 · React 19 · Tailwind v4
HLS.js · Zustand · TanStack Query + Virtual
Drizzle ORM · better-sqlite3 · NextAuth v5
Framer Motion · Vitest · Sentry (optional)

Source is on GitHub (MIT). The public instance at iptvwebplayer.org runs the same tree I deploy from my laptop.

Could you do this in a weekend?

A minimalplayer — login, one grid, proxied HLS — yes, if you've done video on the web before. Thisfeature set (live shelves, VOD detail pages, TV mode, EPG, accounts, transcode fallback) is a hungry weekend plus a few evenings the following week for polish. I'm not selling a course; I'm saying the architecture is what made iteration fast: proxy first, typed Xtream client, virtual lists, SQLite, Docker.

Hosting costs? That's the next post — running Streamly on a $5 VPS with real numbers from the box that serves iptvwebplayer.org.

Disclaimer: Streamly is a media player. It does not provide channels or copyrighted content. You need your own IPTV subscription and must follow applicable law.