Tutorials

A React app with low-latency video

A Next.js app with sign-in on your backend, streams created from your server, every player state handled, cleanup on unmount and error handling.

You will build a Next.js app in which:

  • your staff create a stream from your own admin route, with the secret key kept on your server;
  • only your signed-in users can watch, each through a seat your backend asks for;
  • the page handles every state the player can be in: waiting in line, a seat offered, live, catching up, reconnecting, a lost seat, an unsupported browser, and errors, with a way to try again;
  • leaving the page, or removing the player, gives the seat up at once.

It grows out of the react-next sample in the SDK repository. Seven files, about 200 lines.

Before you start

  • Your keys from the Quickstart, and something that sends video (OBS or ffmpeg).
  • Node.js 20 or newer, and your own sign-in (a session cookie, NextAuth, Clerk, or another). This page leaves that one function to you.
npx create-next-app@15 live-app --js --app --yes
cd live-app
npm install @nearglass/react@0.1.0-rc.1 @nearglass/player@0.1.0-rc.1

The paths below are from the app's folder. If your answers made a src/ folder, put app/ and lib/ under it.

Put the API address and your secret key in .env.local. They stay on the server: Next.js passes to the browser only variables whose name starts with NEXT_PUBLIC_.

NEARGLASS_API=https://api.nearglass.com
NEARGLASS_SECRET_KEY=sk_...

1. Sign-in: one function

Every route asks this function who is calling. It answers no one until you connect your own sign-in, so nothing is open by accident:

// lib/auth.js
// Return the signed-in user as { id, isAdmin }, or null.
// Connect your own sign-in here: read your session cookie, or call
// NextAuth's auth(), Clerk's auth(), and so on.
export async function currentUser(request) {
  // Development only: NEARGLASS_DEV_USER=alice (or alice:admin) signs every request in as that user
  const dev = process.env.NODE_ENV === 'development' && process.env.NEARGLASS_DEV_USER;
  if (dev) {
    const [id, role] = dev.split(':');
    return { id, isAdmin: role === 'admin' };
  }
  return null;
}

2. The control API, from your server only

One module calls the control API. It never throws for an answer the API gives; it returns { status, body }, so each route decides what the browser sees. A network failure or a timeout becomes a 502 or a 504:

// lib/video.js
const SEAT = /^se_[0-9a-f]{32}$/;
const STREAM = /^st_[0-9a-f]{24}$/;

async function call(path, { method = 'GET', body, timeoutMs = 5000 } = {}) {
  const api = process.env.NEARGLASS_API;
  const key = process.env.NEARGLASS_SECRET_KEY;
  if (!api || !key) throw new Error('NEARGLASS_API and NEARGLASS_SECRET_KEY must be set');
  let r;
  try {
    r = await fetch(`${api.replace(/\/+$/, '')}${path}`, {
      method,
      headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
      signal: AbortSignal.timeout(timeoutMs),
      cache: 'no-store',
    });
  } catch (e) {
    const timeout = e?.name === 'TimeoutError';
    return { status: timeout ? 504 : 502, body: { error: { code: timeout ? 'upstream_timeout' : 'upstream_unreachable', message: 'the video service did not answer' } } };
  }
  if (r.status === 204) return { status: 204, body: null };
  try {
    return { status: r.status, body: await r.json() };
  } catch {
    return { status: 502, body: { error: { code: 'bad_gateway', message: `HTTP ${r.status} without JSON` } } };
  }
}

export const isStreamId = (id) => STREAM.test(String(id));

export function createStream({ name, viewerCap = 100 }) {
  return call('/v1/streams', {
    method: 'POST',
    body: { name, viewer_cap: viewerCap, ingest: ['srt', 'rtmp'], admission: 'server' },
  });
}

export function deleteStream(id) {
  return call(`/v1/streams/${id}`, { method: 'DELETE' });
}

export function mintViewerToken({ streamId, seat, join, viewerRef }) {
  return call(`/v1/streams/${streamId}/viewer-tokens`, {
    method: 'POST',
    body: {
      viewer_ref: String(viewerRef),
      // The seat this page already holds: a reconnect keeps it instead of taking a second one
      ...(typeof seat === 'string' && SEAT.test(seat) ? { seat } : {}),
      // false until the viewer presses Join on an offered seat
      ...(typeof join === 'boolean' ? { join } : {}),
    },
  });
}

3. Creating and deleting streams: admin routes

// app/api/streams/route.js
import { currentUser } from '../../../lib/auth.js';
import { createStream } from '../../../lib/video.js';

export async function POST(request) {
  const user = await currentUser(request);
  if (!user?.isAdmin) return Response.json({ error: { code: 'forbidden', message: 'admins only' } }, { status: 403 });
  const { name, viewerCap } = await request.json().catch(() => ({}));
  if (typeof name !== 'string' || name.length < 1 || name.length > 100) {
    return Response.json({ error: { code: 'invalid_request', message: 'name: 1 to 100 characters' } }, { status: 400 });
  }
  const { status, body } = await createStream({ name, viewerCap });
  if (status !== 201) return Response.json(body, { status });
  // The SRT and RTMP addresses hold the stream key, shown only now: give them to the admin, store nothing in the browser
  return Response.json({ id: body.id, srt: body.publish?.srt, rtmp: body.publish?.rtmp }, { status: 201, headers: { 'cache-control': 'no-store' } });
}
// app/api/streams/[id]/route.js
import { currentUser } from '../../../../lib/auth.js';
import { deleteStream, isStreamId } from '../../../../lib/video.js';

export async function DELETE(request, { params }) {
  const user = await currentUser(request);
  if (!user?.isAdmin) return Response.json({ error: { code: 'forbidden', message: 'admins only' } }, { status: 403 });
  const { id } = await params;
  if (!isStreamId(id)) return Response.json({ error: { code: 'not_found', message: 'no such stream' } }, { status: 404 });
  const { status, body } = await deleteStream(id);
  return status === 204 ? new Response(null, { status: 204 }) : Response.json(body, { status });
}

Deleting a stream closes every viewer's session at once.

4. A seat for a signed-in viewer

The player calls this route for every token it needs, with the page's cookies, so your session reaches it. It answers the control API's status and body unchanged, which the player understands: 201 with the token, 409 while the viewer waits in line, anything else an error:

// app/api/watch/[id]/route.js
import { currentUser } from '../../../../lib/auth.js';
import { isStreamId, mintViewerToken } from '../../../../lib/video.js';

export async function POST(request, { params }) {
  const user = await currentUser(request);
  if (!user) return Response.json({ error: { code: 'unauthorized', message: 'sign in to watch' } }, { status: 401 });
  const { id } = await params;
  if (!isStreamId(id)) return Response.json({ error: { code: 'not_found', message: 'no such stream' } }, { status: 404 });
  const { seat, join } = await request.json().catch(() => ({}));
  const { status, body } = await mintViewerToken({ streamId: id, seat, join, viewerRef: user.id });
  if (status >= 500) console.error('viewer-tokens', id, status, body?.error);
  return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
}

5. The page: every state, and cleanup

The player is a client component. useNearglassPlayer follows the element and re-renders on each change; the text below the picture says what is happening in every state:

// app/watch/[id]/live-player.jsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { NearglassPlayer, useNearglassPlayer } from '@nearglass/react';

function describe(state, info, label) {
  switch (state) {
    case 'idle':
    case 'admitting': return 'Getting you a seat...';
    case 'waiting': return info?.full ? 'The stream is full. Waiting for a place in line...' : `In line, number ${info?.position}`;
    case 'offer': return `A seat is free: press Join within ${info?.seconds} s`;
    case 'connecting':
    case 'joining': return 'Connecting...';
    case 'live':
    case 'fallback': return label;
    case 'stalled': return 'Reconnecting...';
    case 'seat-lost': return 'Your seat was given up.';
    case 'unsupported': return info?.message || 'This browser cannot play this stream.';
    case 'error': return info?.message || 'Something went wrong.';
    case 'closed': return 'Stopped.';
    default: return state;
  }
}

const RETRY = new Set(['seat-lost', 'error', 'closed']);

function Player({ streamId, onRetry }) {
  const ref = useRef(null);
  const { state, info, mode, label } = useNearglassPlayer(ref);
  const [soundOn, setSoundOn] = useState(false);

  // Our own listener: removed when the component unmounts
  useEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    const on = (e) => setSoundOn(e.detail.state !== 'muted');
    el.addEventListener('audiochange', on);
    return () => el.removeEventListener('audiochange', on);
  }, []);

  return (
    <>
      <NearglassPlayer ref={ref} tokenUrl={`/api/watch/${streamId}`} />
      <p aria-live="polite">
        {mode === 'moq' && <strong>Live </strong>}
        {mode === 'll-hls' && <strong>Catching up </strong>}
        {describe(state, info, label)}
        {soundOn ? ' (sound on)' : ''}
      </p>
      {RETRY.has(state) && <button type="button" onClick={onRetry}>Try again</button>}
    </>
  );
}

export default function LivePlayer({ streamId }) {
  // A new key mounts a new player: the old one gives its seat up, the new one asks for a seat again
  const [attempt, setAttempt] = useState(0);
  return <Player key={attempt} streamId={streamId} onRetry={() => setAttempt((n) => n + 1)} />;
}
// app/watch/[id]/page.jsx
import LivePlayer from './live-player.jsx';

export default async function Watch({ params }) {
  const { id } = await params;
  return (
    <main style={{ maxWidth: 960, margin: '2rem auto', padding: '0 1rem' }}>
      <LivePlayer streamId={id} />
    </main>
  );
}

Cleanup is built in. When React removes <NearglassPlayer> (the viewer navigates away, the component unmounts, or "Try again" swaps it), the element stops the player, which gives the seat up with the API at once and closes the relay session. Closing the tab does the same with a beacon. Your own listeners are yours to remove, as the audiochange effect does.

The element draws its own controls: the Join button for an offered seat and the Sound button (video starts muted, as browsers require). You do not need to build either.

6. Run it

NEARGLASS_DEV_USER=alice:admin npm run dev

Create a stream as the admin, and send video to the srt address it answers (OBS and SRT, or ffmpeg as in the Quickstart):

curl -s localhost:3000/api/streams -H "Content-Type: application/json" -d '{"name":"members","viewerCap":100}'

Open http://localhost:3000/watch/st_... with the id it answered. The text below the picture goes from "Getting you a seat..." to Live "Low latency". Open more tabs than the stream's viewer_cap and the extra ones wait in line, then get a seat as others close.

What to change for production

  • Sign-in: replace the development branch of currentUser with your own. Without NEARGLASS_DEV_USER, every route answers 401 or 403.
  • Who may watch which stream: currentUser decides who is signed in; add your own check per stream (a ticket, a plan) in the watch route before asking for a seat.
  • More seats during an event: raise the cap without a restart, and the people in line are seated as they next ask: PATCH /v1/streams/{id} with {"viewer_cap": 500} from your server.
  • Rate limits: the API allows at most 6 tokens a minute per seat and answers 429 beyond it. The player reuses a token whose connection never got a session to stay under that; if a 429 does reach it, the player goes to error and "Try again" starts over.

Limits

  • Safari, iPhone and iPad play the compatibility mode (LL-HLS), which is expected to work for this stream (it is sent from an encoder) but has not been verified on a real device yet (Phones and Safari).
  • hls.js for a browser with no native HLS (Firefox for Android) must be global before the first render; see Things to know.
  • Every prop change restarts the player and takes a new seat. Keep tokenUrl stable.

On this page