Build a Next.js WebRTC Video Platform with rstream

Build state-of-the-art real-time WebRTC video streaming in Next.js with rstream tunnels, while keeping the app deployable on serverless platforms.


The first guide in this series built the device side of a real-time WebRTC video stack. A Go producer captured video, served the viewer and signaling WebSocket, published itself through an rstream tunnel, and obtained TURN credentials from the local rstream context. That flow is ideal for validating the media path, but it is still an operator-driven setup.

This guide keeps the same streamer and moves the product responsibilities into a Next.js application. The device no longer installs the rstream CLI, does not run rstream login, and does not hold long-lived rstream credentials. It only knows the URL of your product API and a device secret generated by that product. The Next.js app owns device inventory, user authentication, producer provisioning, TURN issuance, viewer authorization, and real-time tunnel state.

The result is a serverless-friendly architecture for state-of-the-art WebRTC video streaming in Next.js. Next.js manages the product backend. rstream tunnels carry the long-lived signaling path to the remote device, issue short-lived access tokens, provide TURN credentials, and expose tunnel state to the dashboard. The media path remains the one built in the first guide, with Trickle ICE, managed STUN/TURN, QUIC as the producer-to-rstream tunnel transport, WebSocket over QUIC on the public tunnel when the browser negotiates it, TWCC, NACK, RTX, and optional adaptive bitrate.

Clone the platform reference implementation:

rstream examples / webrtc-video-platformOpen the Next.js platform sample.
git clone https://github.com/rstreamlabs/rstream-examples.git
cd rstream-examples/webrtc-video-platform

Scope

This guide covers the platform sample, not a new media pipeline. The video producer is still the Go streamer from Build Device-to-Browser Video Streaming with WebRTC and rstream. What changes is how the producer is provisioned and how viewers are authorized.

The sample uses Next.js App Router, NextAuth with GitHub OAuth, Prisma with PostgreSQL, @rstreamlabs/tunnels for server-side Engine operations, @rstreamlabs/rstream for shared SDK contracts and schemas, @rstreamlabs/react for live tunnel state in the dashboard, and small shadcn-style UI primitives. The application lets a signed-in user create a video device, copy a one-time device secret, run the producer in provisioning mode, see whether the device tunnel is online, and open the stream from the product UI.

The code is intentionally compact. It is a reference integration, not a full device-management product. It still includes the pieces that matter in this architecture: product-owned device secrets, short-lived producer tokens, short-lived viewer tokens, TURN credentials issued by the backend, required resources.tunnels boundaries, and a real-time tunnel watch instead of database polling for online state.

The sample also preserves the streaming behavior from the standalone guide. Device-to-browser video still uses Trickle ICE, ICE restart, TURN relay fallback, NACK/RTX retransmission, and congestion feedback. The same producer can also run the adaptive bitrate profiles from the first sample. The Next.js app does not replace the WebRTC stack. It makes that stack product-owned.

Architecture

The platform owns the product model. PostgreSQL stores users and devices. GitHub OAuth identifies the dashboard user. The device secret is generated by the platform, shown once, and stored only as a hash. The rstream application credentials stay on the server.

The producer receives only two product-level values.

API_URL=http://localhost:3000
DEVICE_SECRET=dev_...

With those values, the producer calls the platform API to obtain the rstream material it needs at the moment it needs it.

  • POST /api/devices/tunnel validates the device secret and returns a short-lived token plus the tunnel configuration required to create one published HTTP tunnel.
  • POST /api/devices/turn validates the same device secret and returns fresh TURN credentials whenever the producer needs them.
  • POST /api/devices/:id/viewer validates the signed-in product user, checks that the selected device belongs to that user, creates a short-lived viewer token, creates TURN credentials for the browser, and returns the viewer payload.
  • GET /api/rstream/watch returns a short-lived watch token used by the dashboard to follow tunnel state through the rstream React helpers.

The browser never receives the device secret. It also never receives the application client secret. Fine-grained resources.tunnels boundaries make the viewer token usable only for the selected tunnel WebRTC path. The product API remains the only place that decides which signed-in user may watch which device.

The signaling path remains standard WebRTC signaling over WebSocket. The difference is that the WebSocket URL is generated by the product backend and includes a short-lived rstream token. The public rstream HTTP tunnel can carry that WebSocket over QUIC when the browser and network path negotiate it, but the application code treats it as a normal WebSocket and does not depend on a specific browser transport.

This is what makes the model serverless-friendly. The Next.js application does not host the long-lived WebSocket session and does not proxy WebRTC media. It creates short-lived rstream credentials, stores product state, and renders the dashboard. The producer keeps serving /ws locally, rstream publishes that endpoint through an outbound tunnel, and the browser connects to the signed tunnel URL. A Vercel deployment can therefore own the product backend without trying to keep WebRTC signaling or media inside a serverless function.

Prepare the rstream project

Use a dedicated rstream project for this sample. The project gives the example a clean tunnel namespace and makes it easier to reason about token scope, labels, and demo cleanup.

Create an application credential for that project. Store its client id and client secret in the Next.js environment. This is a server-side credential. It should be able to create short-lived auth tokens, create TURN credentials, and inspect tunnel state for the project, but it should not be distributed to devices or browsers.

The sample resolves the project from its project endpoint. RSTREAM_PROJECT_ENDPOINT lets the SDK resolve the current project and engine, and short-lived tokens minted by that SDK client are project-scoped by default.

RSTREAM_CLIENT_ID="rstream-app-client-id"
RSTREAM_CLIENT_SECRET="hex-encoded-rstream-app-client-secret"
RSTREAM_PROJECT_ENDPOINT="rstream-project-endpoint"

Run the platform locally

Create the environment file.

cp .env.example .env

Fill the product settings.

POSTGRES_PRISMA_POOL_URL="postgresql://..."
POSTGRES_PRISMA_DIRECT_URL="postgresql://..."
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="replace-with-a-random-secret"
GITHUB_CLIENT_ID="github-oauth-client-id"
GITHUB_CLIENT_SECRET="github-oauth-client-secret"
CRON_SECRET="replace-with-a-random-secret"

Use the pooled PostgreSQL URL for application traffic and the direct, non-pooled PostgreSQL URL for migrations. With Neon, that usually means the pooler hostname for POSTGRES_PRISMA_POOL_URL and the direct hostname for POSTGRES_PRISMA_DIRECT_URL.

Then install dependencies, generate Prisma, apply migrations, and start the app.

npm install
npm run prisma:migrate
npm run dev

Open http://localhost:3000, sign in with GitHub, create a device, and copy the generated secret. The secret is shown once because the database stores only its hash.

Next.js WebRTC video platform device creation modal with one-time producer secret

Device creation returns a one-time product secret. The producer uses that secret to ask the platform for short-lived rstream credentials.

Use the hosted demo

You can also use the hosted demo as the product backend.

https://webrtc-video-platform.demo.rstream.io

The flow is the same. Sign in, create a device, copy the generated device secret, and run the producer with the demo URL as API_URL.

API_URL=https://webrtc-video-platform.demo.rstream.io \
DEVICE_SECRET=dev_... \
./webrtc-video-streaming -config ./config.provisioning.h264.yaml

The demo is disposable. It is meant to make the producer flow easy to test without setting up a database, OAuth app, and Vercel project first. Demo data may be reset periodically.

Configure the rstream SDK once

Keep rstream setup behind a small server-side module. The module reads the environment, creates the configured client, and reuses it during local development.

import "server-only";
 
import { RstreamTunnelsClient } from "@rstreamlabs/tunnels";
 
import { rstreamEnv } from "@/lib/env";
 
function createRstreamClient() {
  const env = rstreamEnv();
  return new RstreamTunnelsClient({
    credentials: {
      clientId: env.RSTREAM_CLIENT_ID,
      clientSecret: env.RSTREAM_CLIENT_SECRET,
    },
    projectEndpoint: env.RSTREAM_PROJECT_ENDPOINT,
  });
}
 
const rstream =
  process.env.NODE_ENV === "production"
    ? createRstreamClient()
    : (globalThis.rstream ?? createRstreamClient());
 
if (process.env.NODE_ENV !== "production") {
  globalThis.rstream = rstream;
}
 
export default rstream;

Business logic then imports the configured client directly.

import rstream from "@/lib/rstream";

That small boundary matters in a sample. It keeps SDK configuration in one predictable place. The rest of the code can then focus on product decisions and call rstream only where the product needs a tunnel token, TURN credentials, tunnel inventory, or a real-time tunnel watch.

Create a device secret

When a user creates a device, the platform creates a device record and returns a secret once. The database stores only a hash of that secret, plus stable metadata such as the device id, owner id, display name, and tunnel name.

The secret is a product credential, not a rstream credential. It authenticates the producer to the platform API. The platform then decides which short-lived rstream token or TURN credentials should be issued for that device.

This is the important design boundary. The device does not receive a PAT, application credential, or account-level rstream token. It receives a product secret that can be revoked or rotated by the product, and each rstream capability is minted on demand.

Provision the producer tunnel

The producer tunnel endpoint has one product responsibility and one rstream responsibility. It validates the device secret against the platform database, then mints the short-lived rstream token that lets this producer create its tunnel.

export async function tunnelPayload(device: Device) {
  const env = rstreamEnv();
  const [resolvedEngine, token] = await Promise.all([
    engine(),
    createTunnelToken(device),
  ]);
  return {
    device: device.id,
    engine: resolvedEngine,
    token,
    name: device.tunnelName,
    labels: labels(device),
    expires: new Date(
      Date.now() + env.DEVICE_TOKEN_TTL_SECONDS * 1000,
    ).toISOString(),
  };
}

The important SDK call is client.auth.createAuthToken. In the production path, the token only allows creation of the expected published HTTP tunnel for that device.

export async function createTunnelToken(
  device: Pick<Device, "id" | "tunnelName" | "userId">,
) {
  const env = rstreamEnv();
  const token = await rstream.auth.createAuthToken({
    expires_in: env.DEVICE_TOKEN_TTL_SECONDS,
    resources: {
      tunnels: {
        scopes: {
          tunnels: {
            create: {
              filters: {
                name: { exact: device.tunnelName },
                protocol: "http",
                publish: true,
                token_auth: true,
                labels: labels(device),
              },
            },
          },
        },
      },
    },
  });
  return token.token;
}

Those filters are the security boundary for the producer token. The producer can create its own tunnel, with the expected name, protocol, publication mode, token-auth requirement, and labels. It does not receive the application secret. It cannot create another device tunnel, publish a different tunnel type, or remove token authentication from the tunnel.

Issue TURN credentials on demand

TURN issuance is separate from tunnel provisioning. The producer calls the platform API whenever it needs fresh credentials. After the product API validates DEVICE_SECRET, it delegates credential generation to rstream.

const credentials = await rstream.turn.createCredentials({
  ttlSeconds: 120,
});

The returned payload is passed directly to the producer or browser as a WebRTC ICE server.

const iceServers = [
  {
    urls: credentials.urls,
    username: credentials.username,
    credential: credentials.credential,
  },
];

This separation keeps the operational boundaries clear. Tunnel provisioning creates the public signaling surface. TURN credentials are network credentials with their own lifetime and can be refreshed independently for the WebRTC path. The producer receives those credentials without holding rstream application credentials.

Authorize the viewer

The viewer path starts from the product user, not from the device secret. When the dashboard opens a stream, the backend first checks product ownership. A user can only request a viewer payload for a device that belongs to that user.

Only after that product check does the backend look up the online rstream tunnel, create TURN credentials for the browser, mint a viewer token, and return a signed WebSocket URL.

export async function viewerPayload(device: Device) {
  const tunnel = await onlineTunnel(device);
  if (!tunnel) {
    return null;
  }
  const [token, turn] = await Promise.all([
    createViewerToken(device, tunnel),
    turnPayload(device.id),
  ]);
  const base = publicUrl(tunnel);
  if (!base) {
    return null;
  }
  const wsBase = base.replace(/^http/, "ws");
  return {
    endpoints: {
      ws: withToken(`${wsBase}/ws`, token),
    },
    turn,
  };
}

The token only allows connection to the selected online tunnel on the WebRTC signaling path.

export async function createViewerToken(
  device: Pick<Device, "id" | "userId">,
  tunnel: Tunnel,
) {
  const env = rstreamEnv();
  const token = await rstream.auth.createAuthToken({
    expires_in: env.VIEWER_TOKEN_TTL_SECONDS,
    resources: {
      tunnels: {
        scopes: {
          tunnels: {
            connect: {
              filters: {
                id: tunnel.id,
                status: "online",
                protocol: "http",
                publish: true,
                labels: labels(device),
              },
              params: {
                path: { regex: "^/ws$" },
              },
            },
          },
        },
      },
    },
  });
  return token.token;
}

That is the product integration pattern. The frontend asks the product API for access. The backend checks product ownership. The backend creates a short-lived rstream token that expresses the edge-level tunnel policy. The browser then opens the WebSocket URL returned by the backend.

If user A guesses the database id of a device owned by user B, the product query returns nothing and no rstream token is minted. If user A obtains a stale viewer payload, the token is short-lived. The token is also bound to one online tunnel and the /ws path, so it cannot be reused to browse the producer UI or connect to another device tunnel.

Watch tunnel state in real time

The dashboard should not infer online state from the device table. A device record only means that the product knows about a device. Online state comes from the rstream tunnel inventory.

The server mints a short-lived watch token for the dashboard. That token is restricted to listing published HTTP tunnels that belong to this sample and to the signed-in user. Browser watch streams send that token as rstream.token on the engine streaming endpoint, so the token is minted on demand and is not stored as durable browser session state.

const token = await rstream.auth.createAuthToken({
  expires_in: env.VIEWER_TOKEN_TTL_SECONDS,
  resources: {
    tunnels: {
      scopes: {
        tunnels: {
          list: {
            filters: {
              labels: {
                app: APP_LABEL,
                [USER_LABEL]: user.id,
              },
              protocol: "http",
              publish: true,
            },
          },
        },
      },
    },
  },
});

On the client side, the dashboard uses the React SDK helpers.

const watchOptions = useMemo(() => rstreamWatchOptions(watch), [watch]);
const rstream = useRstream(watchOptions);
const liveOnlineIds = useMemo(
  () => onlineDeviceIds(rstream.tunnels),
  [rstream.tunnels],
);

The mapping is label-based. Every tunnel created by the producer carries the application label, the owner user label, and the device id label. The dashboard reads the live tunnel list, extracts the device id label from online tunnels, and merges that status into the product device list.

function labels(device: Pick<Device, "id" | "userId">) {
  return {
    app: APP_LABEL,
    [DEVICE_LABEL]: device.id,
    [USER_LABEL]: device.userId,
  };
}

That keeps the UI responsive without building a separate polling loop or inventing a second online-state database.

For durable lifecycle state, add a project webhook on tunnel.created and tunnel.deleted and keep the same labels as the product key. The live watch stream tells the dashboard what is online now; webhook events let the backend persist fields such as onlineSince and lastSeenAt, rebuild device state after a restart, and run cleanup when a producer disappears.

For server-rendered snapshots or one-off checks, the backend can use the same labels through the tunnels SDK.

export async function onlineTunnel(device: Pick<Device, "id" | "userId">) {
  const activeTunnels = await rstream.tunnels.list({
    limit: 20,
    filters: {
      name: device.tunnelName,
      status: "online",
      publish: true,
      protocol: "http",
      labels: labels(device),
    },
  });
  return newestTunnel(activeTunnels);
}

Fine-grained resources

Fine-grained resources.tunnels boundaries are the normal production path for this sample. They let the backend create tokens that are short-lived and constrained by tunnel name, tunnel id, labels, protocol, publication mode, status, and WebSocket path.

That gives the application two layers of authorization.

  • The product layer checks ownership in PostgreSQL and decides whether the signed-in user may access the device.
  • The rstream layer receives a token that is already narrowed to the tunnel operation the caller needs.

The split is intentionally narrow.

CallerToken issued by the backendAllowed operationNot allowed
DashboardWatch tokenList the signed-in user's sample tunnels to compute online stateCreate tunnels or connect to tunnels
Browser viewerViewer tokenConnect to one selected online tunnel on /wsList tunnels, create tunnels, open the producer UI, or connect to another device
Video producerProducer tokenCreate one published HTTP tunnel with the expected name, labels, and token authenticationList tunnels, connect to tunnels, create a tunnel for another device, or remove token authentication

This is the key difference between a development setup and a product integration. The browser does not get a broad project token. The device does not get the application credential. Each runtime gets one short-lived token for one rstream operation.

The sample always issues producer, viewer, and watch tokens with resources.tunnels boundaries. The example is meant to show the production integration shape where product-layer authorization and rstream edge-level authorization work together.

Run the producer in platform mode

Build the device-side producer from the first sample.

cd ../webrtc-video-streaming
make build

Then run it with the product API URL and the device secret.

API_URL=http://localhost:3000 \
DEVICE_SECRET=dev_... \
./webrtc-video-streaming -config ./config.provisioning.h264.yaml

The provisioning profile disables the embedded product viewer and asks the platform for rstream configuration.

web:
  viewer:
    enabled: false
tunnel:
  provisioning:
    mode: remote
    endpoint: ${API_URL}
    secret: ${DEVICE_SECRET}

The producer still runs the same WebRTC and GStreamer code. It still serves the signaling WebSocket locally. It still creates an rstream HTTP tunnel. The difference is that all rstream credentials and TURN credentials come from your platform API instead of a local CLI context.

Next.js WebRTC video platform showing an online device stream

Once the producer is running, live tunnel state marks the device online and the viewer connects through the short-lived URL issued by the platform.

The browser side still uses a normal WebRTC offer/answer flow with Trickle ICE. The platform only signs the WebSocket URL and returns TURN credentials. It does not relay signaling messages, keep the WebSocket open, or proxy media.

That distinction is important for Vercel-style deployments. The Next.js app can run as a serverless product backend because the long-lived WebSocket goes to the producer through the rstream tunnel, not to the Next.js route handler. The WebRTC implementation details remain in the sample repository and in the standalone guide.

What changed from the standalone sample

The standalone sample is still the best way to understand the media path and to debug a device in isolation. It keeps the viewer, signaling, TURN bootstrap, tunnel publication, and diagnostics in one Go process.

The platform sample moves the product responsibilities out of the device. The device becomes a video producer that asks your API for provisioning. Your app owns users, devices, secrets, access policy, viewer URLs, and the UI. rstream provides the tunnel layer, short-lived tokens, TURN credentials, and real-time tunnel state.

That separation is the main production design. Devices hold producer-scoped credentials instead of long-lived rstream credentials, and browsers receive viewer tokens instead of device secrets. Your backend can rotate secrets, revoke devices, restrict viewer access, observe tunnel state, and decide which parts of the producer surface are reachable through product-issued tokens.

Next steps

This sample keeps the media path direct between the browser and the remote device. That is the right shape for this demo because each device belongs to one signed-in user and the product flow is one viewer opening one device stream.

If the product needs several viewers on the same device, the next architecture change is not to stretch the device or the Next.js app. Put a media server in the middle. It can subscribe to remote devices through rstream tunnels on demand, then redistribute the stream without asking the device to maintain one WebRTC session per viewer.

The operational work still builds on the same rstream surfaces. Tunnel inventory, labels, connection logs, exports, and real-time events can feed support tooling, audit views, permissions, limits, and fleet operations.

References