Viewer Embed

/

Next.js Integration

Viewer Embed in Next.js

Next.js apps can mount the viewer with either router. In both cases the recipe is the same:

  1. A server route that exchanges your MOSHPIT_SECRET_KEY for a session token.
  2. A client component that fetches that token and mounts the iframe.

Pick the tab that matches your router.

Environment variables

Add to your .env.local:

SH

Bash

MOSHPIT_PUBLIC_KEY=mpk_YOUR_PUBLIC_KEY
MOSHPIT_SECRET_KEY=msk_YOUR_SECRET_KEY

The secret stays server-side. Don't prefix it with NEXT_PUBLIC_. Get the key pair from Create an Integration.

Server-side session route

This route is identical between routers — it just lives in different folders.

TS

TypeScript

// app/api/moshpit/viewer-session/route.ts
import { NextResponse } from 'next/server';
 
export async function POST(request: Request) {
  const body = await request.json().catch(() => ({}));
 
  const upstream = await fetch(
    'https://moshpit.studio/api/editor/embed-sessions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.MOSHPIT_SECRET_KEY}`,
      },
      body: JSON.stringify({
        publicKey: process.env.MOSHPIT_PUBLIC_KEY,
        type: 'viewer',
        splatId: body.splatId,
      }),
    }
  );
 
  const data = await upstream.json();
  return NextResponse.json(data, { status: upstream.status });
}

Authorize the request

Add your own auth checks before the upstream fetch: only mint a session if the request comes from a logged-in user, includes a CSRF token, etc. The session endpoint is exposed to the public internet.

Show your signed-in user in multiplayer

On multiplayer-enabled scenes the viewer shows a presence widget with each player's name and avatar. Sessions minted without a profile join as an editable guest (Guest 3f9a1). To show your own logged-in user instead, resolve their profile from your auth inside the session route and pass it as user — it is signed into the session token, so the browser can't tamper with it:

TS

TypeScript

// app/api/moshpit/viewer-session/route.ts
export async function POST(request: Request) {
  const body = await request.json().catch(() => ({}));
  const viewer = await getCurrentUser(request); // your auth; null for guests
 
  const upstream = await fetch(
    'https://moshpit.studio/api/editor/embed-sessions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${process.env.MOSHPIT_SECRET_KEY}`,
      },
      body: JSON.stringify({
        publicKey: process.env.MOSHPIT_PUBLIC_KEY,
        type: 'viewer',
        splatId: body.splatId,
        // Omit `user` for anonymous visitors — they join as editable guests.
        user: viewer
          ? { name: viewer.displayName, avatarUrl: viewer.avatarUrl }
          : undefined,
      }),
    }
  );
 
  const data = await upstream.json();
  return NextResponse.json(data, { status: upstream.status });
}

The widget renders a profile passed this way read-only (like a Moshpit signed-in user) — the rename input is only offered to guests. Public scenes keep working for visitors who aren't logged in on your site; they simply join as guests. See the user field in POST /api/editor/embed-sessions.

Client component

Drop in the SDK from a CDN script, or — if you prefer — install @unrealtwin/embed-sdk and import it (see Install the SDK). The example below uses the script-tag form for portability.

TSX

TypeScript

// app/viewer/page.tsx
import Script from 'next/script';
import MoshpitViewer from './MoshpitViewer';
 
export default function ViewerPage() {
  return (
    <>
      <Script
        src="https://moshpit.studio/moshpit-embed.js"
        strategy="afterInteractive"
      />
      <MoshpitViewer
        publicKey={process.env.NEXT_PUBLIC_MOSHPIT_PUBLIC_KEY!}
        splatId="YOUR_SPLAT_ID"
      />
    </>
  );
}
TSX

TypeScript

// app/viewer/MoshpitViewer.tsx
'use client';
 
import { useEffect, useRef } from 'react';
 
declare global {
  interface Window {
    Moshpit?: {
      viewer: (
        container: HTMLElement | string,
        options: Record<string, unknown>
      ) => { destroy: () => void };
    };
  }
}
 
export default function MoshpitViewer({
  publicKey,
  splatId,
}: {
  publicKey: string;
  splatId: string;
}) {
  const ref = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    if (!ref.current) return;
    let handle: { destroy: () => void } | null = null;
 
    const mount = () => {
      if (!window.Moshpit || !ref.current) return false;
      handle = window.Moshpit.viewer(ref.current, {
        publicKey,
        splatId,
        sessionEndpoint: '/api/moshpit/viewer-session',
        responsive: true,
        aspectRatio: '16 / 9',
      });
      return true;
    };
 
    if (!mount()) {
      const id = setInterval(() => {
        if (mount()) clearInterval(id);
      }, 100);
      return () => {
        clearInterval(id);
        handle?.destroy();
      };
    }
 
    return () => handle?.destroy();
  }, [publicKey, splatId]);
 
  return <div ref={ref} style={{ width: '100%', maxWidth: 960 }} />;
}

Plain iframe (no SDK)

If you can't add the SDK script — for example because of a strict CSP — drop in a plain iframe and wire it up yourself:

TSX

TypeScript

// app/viewer/PlainViewer.tsx
'use client';
 
import { useEffect, useRef } from 'react';
 
export default function PlainViewer({ splatId }: { splatId: string }) {
  const ref = useRef<HTMLIFrameElement>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    (async () => {
      const res = await fetch('/api/moshpit/viewer-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ splatId }),
      });
      const { sessionToken } = await res.json();
      if (cancelled || !ref.current) return;
 
      const url = new URL(
        `https://moshpit.studio/viewer/embed/${process.env.NEXT_PUBLIC_MOSHPIT_PUBLIC_KEY}`
      );
      url.searchParams.set('session', sessionToken);
      url.searchParams.set('splatId', splatId);
      ref.current.src = url.toString();
    })();
 
    return () => {
      cancelled = true;
    };
  }, [splatId]);
 
  return (
    <iframe
      ref={ref}
      width="100%"
      height={600}
      style={{ border: 0 }}
      allow="fullscreen; microphone"
      allowFullScreen
      loading="lazy"
      referrerPolicy="strict-origin-when-cross-origin"
    />
  );
}

Plain iframes don't auto-refresh; see Session Tokens → Refresh before expiry.

What's next