Editor Embed

/

Next.js Integration

Editor Embed in Next.js

Plan access

Editor Embeds are available on Free, Pro, and Enterprise, within each plan's usage limits.

Mount the Moshpit editor inside your Next.js app. The recipe mirrors the viewer flow with two differences: the session type is 'editor', and you typically wire UI for save and publish.

Environment variables

SH

Bash

MOSHPIT_PUBLIC_KEY=mpk_YOUR_PUBLIC_KEY
MOSHPIT_SECRET_KEY=msk_YOUR_SECRET_KEY
NEXT_PUBLIC_MOSHPIT_PUBLIC_KEY=mpk_YOUR_PUBLIC_KEY

Public keys are safe to expose to the browser via NEXT_PUBLIC_*. Never expose the secret.

Server-side session route

TS

TypeScript

// app/api/moshpit/editor-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: 'editor',
        splatId: body.splatId ?? null,
      }),
    },
  );
 
  const data = await upstream.json();
  return NextResponse.json(data, { status: upstream.status });
}

Reselling to your own customers?

Add an X-Moshpit-External-User-Id header (your own customer id, resolved from your auth) to the upstream fetch above to scope the editor session to one customer — their scenes stay private and count against their allocation. See External Users.

Client component

This example shows host UI: a Save button that becomes enabled when the editor is dirty, and a Publish button that fires after the user has saved.

TSX

TypeScript

// app/editor/page.tsx
import Script from 'next/script';
import MoshpitEditor from './MoshpitEditor';
 
export default function EditorPage({
  searchParams,
}: {
  searchParams: { splatId?: string };
}) {
  return (
    <>
      <Script
        src="https://moshpit.studio/moshpit-embed.js"
        strategy="afterInteractive"
      />
      <MoshpitEditor
        publicKey={process.env.NEXT_PUBLIC_MOSHPIT_PUBLIC_KEY!}
        splatId={searchParams.splatId}
      />
    </>
  );
}
TSX

TypeScript

// app/editor/MoshpitEditor.tsx
'use client';
 
import { useEffect, useRef, useState } from 'react';
 
type EditorHandle = {
  save: () => void;
  publish: () => void;
  on: (
    event: string,
    cb: (payload: Record<string, unknown> | undefined) => void,
  ) => () => void;
  destroy: () => void;
};
 
declare global {
  interface Window {
    Moshpit?: {
      editor: (
        container: HTMLElement | string,
        options: Record<string, unknown>,
      ) => EditorHandle;
    };
  }
}
 
export default function MoshpitEditor({
  publicKey,
  splatId,
}: {
  publicKey: string;
  splatId?: string;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const handleRef = useRef<EditorHandle | null>(null);
  const [dirty, setDirty] = useState(false);
 
  useEffect(() => {
    const mount = () => {
      if (!window.Moshpit || !containerRef.current) return false;
      const handle = window.Moshpit.editor(containerRef.current, {
        publicKey,
        splatId,
        sessionEndpoint: '/api/moshpit/editor-session',
        responsive: true,
      });
 
      handleRef.current = handle;
      handle.on('dirtyChanged', (p) => {
        setDirty(Boolean((p as { dirty?: boolean })?.dirty));
      });
      handle.on('saved', (p) => {
        const newId = (p as { splatId?: string })?.splatId;
        if (newId && newId !== splatId) {
          // Persist on your side or push to the URL
          history.replaceState(null, '', `?splatId=${newId}`);
        }
      });
      return true;
    };
 
    if (!mount()) {
      const id = setInterval(() => {
        if (mount()) clearInterval(id);
      }, 100);
      return () => {
        clearInterval(id);
        handleRef.current?.destroy();
      };
    }
    return () => handleRef.current?.destroy();
  }, [publicKey, splatId]);
 
  return (
    <div>
      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <button disabled={!dirty} onClick={() => handleRef.current?.save()}>
          Save{dirty ? ' *' : ''}
        </button>
        <button onClick={() => handleRef.current?.publish()}>Publish</button>
      </div>
      <div ref={containerRef} style={{ width: '100%', height: 720 }} />
    </div>
  );
}

What's next