Live video on the left, chat on the right. One HTML file, two custom tags. This guide goes from an empty file to two browsers talking, in about 30 minutes.
A chat panel sits beside almost every livestream because reacting in the moment is the point. Building both halves yourself is not a weekend. The video side needs ingest, transcoding, and delivery. The chat side needs sockets, storage, and a UI that survives a busy room.
You do not have to build either. FastPix turns your broadcast into a video URL that plays anywhere. TalkJS gives you a chat UI as a drop-in component. Both ship as web components, so the whole thing is one file with two custom tags in it.
To follow along you will need:
A FastPix account, for the stream and the player
A TalkJS account, for the chat
OBS Studio, the free broadcasting software
About 30 minutes
The plan: create a stream in FastPix, get video playing in the browser, add the TalkJS chatbox beside it, wire up who the viewer is, then broadcast from OBS and watch two browser windows talk to each other.
Creating the stream in FastPix
Sign in at fastpix.com and create a live stream from the dashboard. A stream in FastPix is a persistent object. You create it once and broadcast to it whenever you want, so you are not making a new one for every show.
The new stream gives you three values, and it is worth being precise about which does what:
The Stream Key is what OBS authenticates with. Treat it like a password. Anyone holding it can broadcast as you.
The RTMPS ingest URL is where broadcasts get pushed: rtmps://live.fastpix.io:443/live. It is the same for every FastPix customer. Your Stream Key is what identifies your stream, not the URL.
The Playback ID builds the public URL your viewers watch. This one is safe in frontend code, and it is the only FastPix value that will appear in the file we write.
Copy the Stream Key and the Playback ID somewhere handy.
While you are in the dashboard, turn on low latency for this stream. Default HLS delivery buys a few seconds of buffer to keep playback smooth, and for most video that trade is correct. For a stream with chat beside it, those seconds are the difference between people reacting to a moment and people reading about a moment that has not reached them yet. Low latency narrows that gap.
You can also create streams from your backend instead of the dashboard, with a POST to https://api.fastpix.com/v1/live/streams authenticated by an Access Token ID and Secret Key from Settings. That is the route once your product spins up streams on demand. The Secret Key belongs on a server and nowhere near a browser. The API reference covers the payload.
Putting the player on the page
Create an empty file called index.html. We start with nothing but video:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live stream test</title>
<style>
body { margin: 0; background: #111; }
fastpix-player { width: 100%; height: 100vh; }
</style>
</head>
<body>
<fastpix-player
playback-id="<YOUR-PLAYBACK-ID>"
stream-type="live-stream"
muted
autoplay
></fastpix-player>
<script src="https://cdn.jsdelivr.net/npm/@fastpix/fp-player@1/dist/player.js"></script>
</body>
</html>
Swap in your Playback ID and open the file in a browser.
A quick tour of what you just added. @fastpix/fp-player is FastPix's player, and it is a web component: load the script once and behaves like any other HTML tag. You give it a playback-id rather than a full URL, and it works out the manifest, handles buffering, and switches quality as the connection changes. Setting stream-type="live-stream" tells it to expect a live feed instead of a fixed-length video. The muted attribute is what makes autoplay work at all, because browsers block autoplaying audio. Viewers unmute using the player's own controls.
You will see a dark player and no picture, because nothing is being broadcast yet. That is expected. We fix it at the end.
Getting your TalkJS credentials
Setup on the chat side is short. Sign up at talkjs.com and open the dashboard. You need one value: your App ID, which sits on the dashboard home. New accounts get a test App ID and a live one, and the test ID is fine for everything in this guide.
One thing worth knowing before you start copying code from around the web. There are two generations of TalkJS in circulation. The older classic JavaScript SDK uses Talk.Session and talkSession.createChatbox(), and most tutorials you will find still show it. The current approach is TalkJS Web Components: plus the @talkjs/core data API. We are using the current one. If you paste a snippet that mentions Talk.ready.then(...), you have landed on the older path and it will not work alongside the code below.
That is the whole setup. Keep the App ID open in a tab.
Dropping the chat in
Loading TalkJS takes three tags in your <head>:
a stylesheet, an import map, and a module import. Add them under your existing <style> block:
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@talkjs/web-components@0.3.3/default.css"
/>
<script type="importmap">
{
"imports": {
"@talkjs/web-components": "https://cdn.jsdelivr.net/npm/@talkjs/web-components@0.3.3",
"@talkjs/core": "https://cdn.jsdelivr.net/npm/@talkjs/core@1.11.1"
}
}
</script>
The import map is doing something specific: it lets you write import "@talkjs/web-components" in a plain HTML file with no bundler, and the browser resolves the bare name to the CDN URL. Pin the versions rather than using a floating tag, so a CDN update never changes your page underneath you. Both packages are on npm if you would rather install them properly later.
Now the chat itself. Add this to your , right after the element:
<t-chatbox
id="chat"
app-id="<YOUR-APP-ID>"
conversation-id="livestream"
chat-header-visible="false"
></t-chatbox>
That is the entire chat UI. Message list, composer, timestamps, read state, scroll behaviour, all of it. The conversation-id is the room. Everybody who loads the page with conversation-id="livestream" lands in the same conversation and sees the same messages, which is exactly the shape a livestream wants. We hide the header with chat-header-visible="false" because the page already tells people what they are watching.
Refresh the page and the component renders, but it will not let you post yet. It does not know who you are. That is the next piece.
Deciding who is in the room
Every message needs an author, so before anyone can type, TalkJS needs a user. Add a module script at the end of your :
This happens in two scripts, and the order matters. First, a plain script directly after the element:
<script>
// Classic script: runs while the page is still parsing, which is before the
// module below executes. That ordering is the point, see the note underneath.
let viewerId = localStorage.getItem("viewerId");
if (!viewerId) {
viewerId = "viewer-" + crypto.randomUUID().slice(0, 8);
localStorage.setItem("viewerId", viewerId);
}
document.getElementById("chat").setAttribute("user-id", viewerId);
</script>
Then the module script, at the end of your :
<script type="module" async>
import "@talkjs/web-components";
import { getTalkSession } from "@talkjs/core";
// Read config off the element so the App ID lives in exactly one place.
const chatEl = document.getElementById("chat");
const APP_ID = chatEl.getAttribute("app-id");
const CONVERSATION_ID = chatEl.getAttribute("conversation-id");
const userId = chatEl.getAttribute("user-id");
const session = getTalkSession({ appId: APP_ID, userId });
await session.currentUser.createIfNotExists({
name: "Viewer " + userId.slice(-4),
});
const conversation = session.conversation(CONVERSATION_ID);
await conversation.createIfNotExists({ subject: "Live chat" });
await conversation.participant(userId).createIfNotExists();
</script>
One thing to watch: the App ID now appears only on the element, and the script reads it from there. Do not also hard-code it in the script. Keeping it in two places is how you end up with a chatbox that connects fine while the script that creates the user quietly fails against a placeholder, which leaves you staring at "Something went wrong" with a perfectly correct-looking app-id in the HTML.
Why two scripts instead of one: user-id is a required prop, and reads it the moment the component definition loads. Module scripts are deferred, so if you set the attribute from inside the module you are already too late and the console tells you so: Could not initialize because the following required props are missing: ["userId"]. A classic script runs during parsing, so the attribute is in place before the component ever looks for it.
Reading through it: we make up an ID for this viewer and keep it in localStorage, so someone who refreshes mid-stream keeps the same identity instead of turning into a stranger. getTalkSession opens the connection. createIfNotExists is the useful part of this API, since it means you can run the same setup on every page load without checking whether things already exist or handling duplicate errors. We create the user, create the conversation, and add the viewer to it.
In a real product you already have users, so you would pass your own account ID and display name instead of generating one, and the conversation would be created server-side when the stream is scheduled rather than by whoever loads the page first.
Refresh, type something, and it posts. Open the file in a second browser window and the two talk to each other.
Laying the page out
Both halves work, but they are stacked vertically and the chat is somewhere below the fold. Replace your <style> block with this:
<style>
body {
margin: 0;
background: #111;
height: 100vh;
display: grid;
grid-template-columns: 1fr 360px;
}
fastpix-player { width: 100%; height: 100%; }
t-chatbox { width: 100%; height: 100%; border-left: 1px solid #2a2a2a; }
@media (max-width: 800px) {
body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
}
</style>
Video takes the flexible column, chat gets a fixed 360px beside it, and on a narrow screen the two stack with chat filling whatever is left. That is the layout every livestream page converges on, and it is nine lines of CSS.
Stopping the chat from driving the video
There is one clash worth fixing before you broadcast, and it is not obvious until someone types a real sentence.
The player registers its keyboard shortcuts on document and does not check where the keystroke came from. That is fine on a page that is only a player. Put a chat box next to it and every letter someone types is also a player command: m mutes, f goes fullscreen, k pauses, c toggles captions. Type "confirm" in chat and the video mutes, goes fullscreen, and pauses on the way through.
Two ways out. The blunt one is disable-keyboard-controls on the player, which turns the shortcuts off everywhere. The better one is to stop chat keystrokes from reaching document at all, which keeps the shortcuts working when someone is actually focused on the video:
<script>
document
.getElementById("chat-panel")
.addEventListener("keydown", (e) => e.stopPropagation());
</script>
The event still does its job in the composer, because that happens at the target before it bubbles. It just never reaches the listener the player attached.
Going live from OBS
Time to put a picture on it. Open OBS and go to Settings → Stream. Set Service to Custom..., then:
Server: rtmps://live.fastpix.io:443/live
Stream Key: the Stream Key from your FastPix stream
Add a source or two on the main screen, a webcam or a window capture is plenty for a test, then click Start Streaming.
Give it a few seconds. Your stream flips to active in the FastPix dashboard, and the player picks up the feed on the next load. If the player still shows nothing after twenty seconds or so, check the stream status in the dashboard first: idle means the broadcast is not arriving and the problem is between OBS and FastPix, usually a mistyped Stream Key. active with a blank player means the broadcast landed and the issue is on the page, so check that your Playback ID is right.
If you have made it this far, you have a working livestream with live chat: video on the left, a conversation on the right, and one file holding both.
Start free. No credit card required, and the free trial covers 30 minutes of live streaming, which is enough to run everything above.
What you did not have to build
Worth pausing on what came along for free, because it is the reason to use a chat product rather than a raw messaging layer.
Messages persist. Somebody who joins forty minutes in sees what was already said, and after the stream ends the conversation is still there. Building that on top of a plain pub/sub layer means adding a database, a write path, and a history endpoint before anyone can scroll up.
The UI handles the awkward cases. Long messages wrap, links become links, the list stays pinned to the bottom unless you have scrolled up to read something, and it works on a phone. Those are small individually and they are most of the work.
Moderation exists. You can open any conversation in the TalkJS dashboard, read it, and remove messages or participants, which matters more than people expect the first time a public room gets a troll.
You can theme it. The default stylesheet is a starting point, not a ceiling, and the component's appearance is editable so the chat can match your product rather than looking bolted on.
Before you ship this
Three things stand between this file and something you put in front of real viewers.
The test-mode banner. Test credentials show a notice in the chat, and it clears on a paid TalkJS plan. On the FastPix side, watch the free trial's 30-minute live streaming allowance while you are testing, then move to Starter when you outgrow it.
Authentication. Right now any browser can claim any user ID, which is fine for a demo and not fine in public. TalkJS supports token authentication, where your server signs a token for the logged-in user and the page calls getTalkSession({ appId, userId }).setToken(token). Do this before launch.
Your Stream Key. It never belongs in frontend code. Only the Playback ID does. If you ever paste a curl example into a client-side file, check what you pasted.
Where to take it next
The file you have is the base case. A few directions from here, all of them things FastPix does on the same stream:
Recording and live-to-VOD. Streams can be archived automatically, so the broadcast becomes an on-demand video the moment it ends, without a re-upload.
DVR and rewind. Let viewers pause and jump back during the broadcast to catch what they missed.
Simulcast. Push the same feed to YouTube, Twitch, or LinkedIn while it runs.
Live captions. Generate captions on the stream as it goes out.
Clipping. Cut a moment out of a live stream while it is still running, which is how highlight posts go out during the event instead of the next morning.
FastPix Video Data. Playback happens in your player. Video Data captures what happens inside it, stitches events into sessions, adds geo and device context, and computes quality scores, so you can see startup time, rebuffering, and errors across real viewers rather than guessing from complaints in chat.
On the chat side, the obvious next steps are real user identity from your own accounts, and notifications for viewers who have the tab in the background.
The complete file
Here is everything, in one piece. Replace <YOUR-PLAYBACK-ID> and <YOUR-APP-ID> and it runs.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live stream with chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@talkjs/web-components@0.3.3/default.css"
/>
<script type="importmap">
{
"imports": {
"@talkjs/web-components": "https://cdn.jsdelivr.net/npm/@talkjs/web-components@0.3.3",
"@talkjs/core": "https://cdn.jsdelivr.net/npm/@talkjs/core@1.11.1"
}
}
</script>
<style>
:root {
--ink: #14121a;
--ink-raised: #1c1926;
--ink-line: #2b2637;
--violet: #6D22CD;
--violet-lit: #9a63f0;
--text: #ece9f2;
--text-dim: #9691a5;
--live: #ff4d5e;
--ui: "Inter", system-ui, -apple-system, sans-serif;
--display: "Space Grotesk", var(--ui);
--mono: "IBM Plex Mono", ui-monospace, monospace;
--rail: 56px;
--chat: 372px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--ink);
color: var(--text);
font-family: var(--ui);
display: grid;
grid-template-rows: var(--rail) 1fr;
grid-template-columns: 1fr var(--chat);
grid-template-areas:
"rail rail"
"stage chat";
overflow: hidden;
}
.rail {
grid-area: rail;
display: flex;
align-items: center;
gap: 16px;
padding: 0 18px;
background: var(--ink-raised);
border-bottom: 1px solid var(--ink-line);
overflow: hidden;
min-width: 0;
}
.mark {
flex: none;
display: flex;
align-items: center;
gap: 9px;
font-family: var(--display);
font-weight: 700;
font-size: 15px;
letter-spacing: -0.02em;
white-space: nowrap;
}
.mark .dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--violet);
box-shadow: 0 0 0 4px rgba(109, 34, 205, 0.22);
}
.mark .sep { color: var(--text-dim); font-weight: 500; }
.title {
font-size: 13px;
color: var(--text-dim);
border-left: 1px solid var(--ink-line);
padding-left: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-end {
margin-left: auto;
flex: none;
display: flex;
align-items: center;
gap: 10px;
}
.status {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 6px 11px;
border-radius: 999px;
border: 1px solid var(--ink-line);
color: var(--text-dim);
white-space: nowrap;
}
.status .bulb {
width: 7px; height: 7px; border-radius: 50%;
background: currentColor;
flex: none;
}
.status[data-state="live"] {
color: var(--live);
border-color: rgba(255, 77, 94, 0.42);
background: rgba(255, 77, 94, 0.1);
}
.status[data-state="live"] .bulb { animation: beat 1.6s ease-out infinite; }
.status[data-state="connecting"] .bulb { animation: beat 1.1s ease-out infinite; }
@keyframes beat {
0% { box-shadow: 0 0 0 0 rgba(255, 77, 94, 0.55); }
70% { box-shadow: 0 0 0 7px rgba(255, 77, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 77, 94, 0); }
}
@media (prefers-reduced-motion: reduce) {
.status .bulb { animation: none !important; }
}
.stage {
grid-area: stage;
position: relative;
min-width: 0;
min-height: 0;
background: radial-gradient(120% 90% at 50% 0%, #221d2e 0%, var(--ink) 62%);
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
}
.frame {
width: 100%;
height: 100%;
border-radius: 12px;
overflow: hidden;
background: #000;
border: 1px solid var(--ink-line);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5);
}
fastpix-player { width: 100%; height: 100%; display: block; }
.chat {
grid-area: chat;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--ink-raised);
border-left: 1px solid var(--ink-line);
}
.chat-head {
display: flex;
align-items: baseline;
gap: 8px;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--ink-line);
}
.chat-head h2 {
margin: 0;
font-family: var(--display);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
}
.chat-head .who {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
}
.chat-body { min-height: 0; position: relative; }
t-chatbox { display: block; width: 100%; height: 100%; }
.chat-foot {
padding: 9px 16px;
border-top: 1px solid var(--ink-line);
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--text-dim);
display: flex;
gap: 8px;
align-items: center;
}
.chat-foot .kbd {
border: 1px solid var(--ink-line);
border-radius: 4px;
padding: 1px 5px;
color: var(--text);
}
t-chatbox {
--t-color-bg: #1c1926;
--t-color-bg-subtle: #221e2e;
--t-color-bg-muted: #262133;
--t-color-text: #ece9f2;
--t-color-text-muted: #9691a5;
--t-color-border: #2b2637;
--t-color-border-strong: #3a3349;
--t-color-brand-primary: #6D22CD;
--t-color-brand-primary-hover: #7d33dd;
--t-color-brand-primary-active: #5c1cae;
--t-color-brand-primary-fg: #ffffff;
--t-color-brand-light: #F1ECF8;
--t-color-focus-ring: #9a63f0;
}
@media (max-width: 900px) {
body {
grid-template-columns: 1fr;
grid-template-rows: var(--rail) auto 1fr;
grid-template-areas:
"rail"
"stage"
"chat";
}
.stage { padding: 0; aspect-ratio: 16 / 9; }
.frame { border-radius: 0; border-left: 0; border-right: 0; box-shadow: none; }
.chat { border-left: 0; border-top: 1px solid var(--ink-line); }
.title { display: none; }
.rail { gap: 8px; padding: 0 10px; }
.status { padding: 5px 8px; font-size: 10px; letter-spacing: 0.04em; gap: 5px; }
.mark { font-size: 13px; }
}
:focus-visible { outline: 2px solid var(--violet-lit); outline-offset: 2px; }
</style>
</head>
<body>
<header class="rail">
<span class="mark">
<span class="dot" aria-hidden="true"></span>
FastPix <span class="sep">+</span> TalkJS
</span>
<span class="title">Building a livestream with live chat</span>
<span class="rail-end">
<span class="status" id="watching" data-state="idle" title="Viewers connected right now">
<span class="bulb" aria-hidden="true"></span>
<span id="watching-text">0 watching</span>
</span>
<span class="status" id="status" data-state="connecting" role="status" aria-live="polite">
<span class="bulb" aria-hidden="true"></span>
<span id="status-text">Connecting</span>
</span>
</span>
</header>
<main class="stage">
<div class="frame">
<fastpix-player
id="player"
playback-id="d90f8bcd-79f7-47f9-a8b1-ce73bfe40c13"
stream-type="live-stream"
muted
autoplay
></fastpix-player>
</div>
</main>
<aside class="chat" id="chat-panel">
<div class="chat-head">
<h2>Live chat</h2>
<span class="who" id="whoami">signing in</span>
</div>
<div class="chat-body">
<t-chatbox
id="chat"
app-id="tySrvObR"
conversation-id="livestream"
chat-header-visible="false"
></t-chatbox>
</div>
<div class="chat-foot">
<span class="kbd">Enter</span> to send
<span class="kbd">Shift</span>+<span class="kbd">Enter</span> for a new line
</div>
</aside>
<script>
// Runs during parsing, so user-id exists before <t-chatbox> initializes.
// randomUUID needs a secure context, so fall back on http:// and file://.
function makeId() {
if (window.crypto && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().slice(0, 8);
}
return Math.random().toString(36).slice(2, 10);
}
var viewerId = localStorage.getItem("viewerId");
if (!viewerId) {
viewerId = "viewer-" + makeId();
localStorage.setItem("viewerId", viewerId);
}
document.getElementById("chat").setAttribute("user-id", viewerId);
document.getElementById("whoami").textContent = viewerId;
// The player registers a document-level keydown handler with no target
// check, so m / f / k / c typed in chat would mute, fullscreen, pause and
// toggle captions. Stopping propagation at the panel keeps the keystroke
// in the composer while player shortcuts still work everywhere else.
document.getElementById("chat-panel").addEventListener("keydown", function (e) {
e.stopPropagation();
});
document.addEventListener("submit", function (e) { e.preventDefault(); }, true);
</script>
<script src="https://cdn.jsdelivr.net/npm/@fastpix/fp-player@1/dist/player.js"></script>
<script type="module" async>
import "@talkjs/web-components";
import { getTalkSession } from "@talkjs/core";
// App ID lives only on the <t-chatbox> element. One place to edit.
const chatEl = document.getElementById("chat");
const APP_ID = chatEl.getAttribute("app-id");
const CONVERSATION_ID = chatEl.getAttribute("conversation-id");
const userId = chatEl.getAttribute("user-id");
// Stream status pill, driven by real player events.
const pill = document.getElementById("status");
const label = document.getElementById("status-text");
const video = document.getElementById("player");
function setStatus(state, text) {
pill.dataset.state = state;
label.textContent = text;
}
video.addEventListener("playing", () => setStatus("live", "Live"));
video.addEventListener("waiting", () => setStatus("connecting", "Buffering"));
video.addEventListener("error", () => setStatus("offline", "Offline"));
video.addEventListener("stalled", () => setStatus("offline", "Offline"));
setTimeout(() => {
if (pill.dataset.state === "connecting") setStatus("offline", "Offline");
}, 15000);
try {
const session = getTalkSession({ appId: APP_ID, userId });
await session.currentUser.createIfNotExists({
name: "Viewer " + userId.slice(-4),
});
const conversation = session.conversation(CONVERSATION_ID);
await conversation.createIfNotExists({ subject: "Live chat" });
await conversation.participant(userId).createIfNotExists();
// How many people are watching. Participants is everyone who ever
// joined, so it is not a viewer count on its own. subscribeOnline
// gives isConnected per user, and the number connected right now
// is the number actually watching.
const connected = new Map();
const watched = new Set();
const watchPill = document.getElementById("watching");
const watchText = document.getElementById("watching-text");
function paint() {
const n = [...connected.values()].filter(Boolean).length;
watchText.textContent = n + " watching";
watchPill.dataset.state = n > 0 ? "live" : "idle";
}
conversation.subscribeParticipants((list) => {
for (const p of list || []) {
const id = p?.user?.id;
if (!id || watched.has(id)) continue;
watched.add(id);
session.user(id).subscribeOnline((o) => {
connected.set(id, !!o?.isConnected);
paint();
});
}
paint();
});
} catch (err) {
console.error("[chat] could not start session:", err);
document.getElementById("whoami").textContent = "chat offline";
}
</script>
</body>
</html>
Wrapping up
Two custom elements, one grid, and about thirty lines of JavaScript to say who the viewer is. That is a livestream with live chat, and the reason it stays that small is that the hard parts sit behind both components: ingest, transcoding, and delivery on the FastPix side, sockets, storage, and UI on the TalkJS side.
Point it at a real stream, swap the generated viewer IDs for your own accounts, and you have the foundation for a product. Everything in the "where to take it next" list runs on the same stream you created in step one.
FAQ
How do I start, and what does it cost? Both sides start free, with no credit card. The FastPix free trial covers 30 minutes of live streaming, 30 minutes of live recording, 10 encoded videos, and 100,000 streaming minutes. Past that, the Starter plan is $10/month and includes $25 of usage credit each month, with usage-based rates after the credit is spent. TalkJS has a free plan for development and charges by monthly active users in production.
Do I need a paid plan to follow this tutorial? No. Free credentials on both sides run the whole guide. Keep an eye on the 30-minute live streaming allowance in the FastPix trial, since a long test broadcast will use it up. The chat also shows a test-mode notice until you move to a paid TalkJS plan.
Can I use React, Vue, or Next.js instead of a plain HTML file? Yes. Both pieces are web components, so they work in any framework that renders HTML. TalkJS also ships React and React Native SDKs, and the FastPix player has framework wrappers, so you can swap either half without changing the structure above.
How do I stop viewers from impersonating each other? Turn on token authentication. Your server signs a token for the logged-in user and the page calls getTalkSession({ appId, userId }).setToken(token). Until you do that, any browser can claim any user ID, which is fine for a demo and not fine in public.
Why is my player black when the stream is running? Check the stream status in the FastPix dashboard first. idle means the broadcast is not arriving, so the problem sits between OBS and FastPix, usually a mistyped Stream Key. active with a blank player means the broadcast landed and the page is at fault, so check your Playback ID.
Does the chat survive after the stream ends? Yes. Messages are stored against the conversation, so the transcript is still there once the broadcast stops, and someone arriving late can scroll back through what was said.
Can I keep the video and reuse it afterwards? Yes. Turn on recording and the stream is archived as an on-demand video when it ends, playable through the same player with a different playback ID. No re-upload.
Start shipping video today
Create a FastPix workspace and start on the free trial, no credit card required: 30 minutes of live streaming, 30 minutes of live recording, 10 encoded videos, and 100,000 streaming minutes. Live over RTMPS and SRT, low-latency delivery, recording and live-to-VOD, an open-source player for web, iOS, Android, and Flutter, and per-session quality data behind it.
Try for free: https://dashboard.fastpix.com/signup







Top comments (0)