Skip to content

Sandbox mode

Run code you do not trust

By default next-live runs a snippet directly in your page. That is what lets a snippet use your store, your UI kit and your live objects, and it is the right choice when the people writing snippets are your own team.

It is the wrong choice when they are not. A public playground, code generated by an AI model, or snippets users share with each other all mean running code written by strangers, and code in your page can read your cookies, call your APIs as the signed-in user, and change the page.

Sandbox mode runs the snippet in an iframe that the browser isolates from your page. The editor, the error display and the console panel keep working.

Should you use it?#

In the page (default)Sandbox mode
Who writes the snippetsPeople you trustAnyone
propsPassed by referenceCopied: plain data only
Where modules are registeredYour pageThe sandbox page
'unsafe-eval' on your pageNeeded on runner routesNot needed
A snippet stuck in while (true)Freezes the tabFreezes the frame, which is replaced

How it works#

  1. Your page renders <LiveProvider sandbox={{ src: '/sandbox' }}>. It never compiles or runs the snippet itself.
  2. <LivePreview> renders an <iframe sandbox="allow-scripts"> that loads src, a small page you host.
  3. That page renders <LiveSandboxRoot> (or calls mountSandbox()) from next-live/sandbox. It compiles what your page sends and renders it.
  4. The two talk over a private MessageChannel. Errors, console output and the content height come back to your page.

Without allow-same-origin, the browser gives the frame an opaque origin, so code inside cannot read your cookies, storage or DOM, even when the sandbox page is on your own domain.

Step 1: The sandbox page#

app/sandbox/page.tsx
'use client';
import { LiveSandboxRoot } from 'next-live/sandbox';
import * as ui from '@/components/ui';
export default function SandboxPage() {
return (
<LiveSandboxRoot
modules={{ '@acme/ui': ui }}
allowedOrigins={['https://app.example.com', 'http://localhost:3000']}
/>
);
}

allowedOrigins is required. It lists the pages allowed to embed the sandbox and send it code, written as scheme://host:port with no trailing slash. Your module registry lives here now, because this is where snippets run.

With Vite or plain HTML, call mountSandbox({ modules, allowedOrigins }) from a script on any page instead.

Step 2: Point the provider at it#

PublicPlayground.tsx
<LiveProvider code={source} sandbox={{ src: '/sandbox' }}>
<LiveEditor />
<LivePreview height="auto" title="Snippet preview" />
<LiveError />
<LiveConsole />
</LiveProvider>

Try it below. The source is editable, so you can check the isolation yourself: add localStorage.getItem('x') inside the component and <LiveError> shows the SecurityError the browser throws in the frame, while your page keeps running.

The host code for sandbox mode is only downloaded when a provider uses sandbox, so your other pages do not pay for it.

For multi-file projects, security probes, and the freeze watchdog, try the full sandbox demo.

Step 3: Security headers#

The sandbox page runs snippets, so it is the one that needs 'unsafe-eval' and the one to lock down hardest:

proxy.ts (sandbox route only)
[
'sandbox allow-scripts',
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"connect-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self' https://app.example.com",
].join('; ')
  • sandbox allow-scripts keeps the page isolated even if it is opened directly.
  • frame-ancestors decides who may embed it.
  • connect-src 'none' stops a snippet from sending data anywhere. Widen it only for an API snippets really need.

Your own page needs frame-src to allow the sandbox, and no longer needs 'unsafe-eval' if it only uses sandbox mode.

Props are copied, not shared#

Props cross into the iframe the way postMessage copies data. Plain objects, arrays, strings, numbers, booleans, dates, maps and sets work. Functions, class instances, DOM nodes and React elements do not, and <LiveError> explains when one slips in. When your data changes, pass new props: the component re-renders without recompiling.

modules, scope and transform on <LiveProvider sandbox> are ignored, with a warning in development. Register them on the sandbox page.

Where to host the sandbox#

When a snippet freezes#

next-live checks the sandbox every 2 seconds. No answer within 5 seconds means it froze: the frame is replaced and the code is sent again. After 3 freezes in a minute it stops and shows an error instead, until the code changes or you call reload():

tsx
function RestartButton() {
const { sandbox } = useLiveContext();
if (!sandbox || sandbox.status !== 'failed') return null;
return <button onClick={sandbox.reload}>Restart the preview</button>;
}

What a sandboxed snippet cannot do#

  • Read your cookies, localStorage, sessionStorage or DOM.
  • Call your APIs with the visitor's cookies.
  • Navigate your page, open pop-ups or submit forms, unless you add permissions.
  • Use the camera, microphone, geolocation, USB, payments or clipboard reading.

Inside the frame, localStorage and document.cookie throw a SecurityError. That is the isolation working. A snippet can still use CPU until the watchdog replaces the frame, and draw anything inside its own box, so make it clear on your page that the preview is user content.

Options#

sandbox optionDefaultWhat it does
srcrequiredURL of the sandbox page.
permissions[]Extra iframe permissions, like allow-forms or allow-modals.
allowSameOriginfalseGive the frame its real origin. Refused when src is on your page's origin.
allowdenies camera, microphone, geolocation, USB, payment, clipboard reading and moreThe frame's Permissions Policy.
credentiallesstrueLoad the frame without cookies or storage, where the browser supports it.
handshakeTimeoutMs10000How long to wait for the sandbox page.
pingIntervalMs / pongTimeoutMs2000 / 5000The freeze watchdog.
maxHeight10000The tallest the frame grows with height="auto".
maxRestarts3Automatic restarts per minute before giving up.

Troubleshooting#

"did not answer within 10000 ms". Open the sandbox URL in a tab: you should see a short note saying it is a sandbox. Then check that its allowedOrigins contains your page's exact origin, that its frame-ancestors allows your page, and that your page's frame-src allows the sandbox.

The frame stays blank under next dev. The terminal logs "Blocked cross-origin request to Next.js dev resource". The sandboxed frame has an opaque origin, so its requests for /_next scripts are cross-site with no Referer, and the Next dev server refuses them. allowedDevOrigins cannot help, since there is no host to allow. Test sandbox mode with next build && next start, which has no such check.

"allowSameOrigin is refused". With a same-origin src, the snippet could remove its own sandbox. Leave it off, or serve the sandbox from another origin.

"This page runs next-live X, but the sandbox page runs next-live Y". Deploy both pages with the same version.

The full guide, with a plain HTML example and every option, is in the package docs: 15-sandbox.md.