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:
- A server route that exchanges your
MOSHPIT_SECRET_KEYfor a session token. - 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:
Bash
MOSHPIT_PUBLIC_KEY=mpk_YOUR_PUBLIC_KEY
MOSHPIT_SECRET_KEY=msk_YOUR_SECRET_KEYThe 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.
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:
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.
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"
/>
</>
);
}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:
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
- Editor Embed in Next.js — same recipe, with save and publish flows.
- Viewer Events — react to user interactions.
- SDK Options — every viewer option in one place.