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 snippets | People you trust | Anyone |
props | Passed by reference | Copied: plain data only |
| Where modules are registered | Your page | The sandbox page |
'unsafe-eval' on your page | Needed on runner routes | Not needed |
A snippet stuck in while (true) | Freezes the tab | Freezes the frame, which is replaced |
How it works#
- Your page renders
<LiveProvider sandbox={{ src: '/sandbox' }}>. It never compiles or runs the snippet itself. <LivePreview>renders an<iframe sandbox="allow-scripts">that loadssrc, a small page you host.- That page renders
<LiveSandboxRoot>(or callsmountSandbox()) fromnext-live/sandbox. It compiles what your page sends and renders it. - 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#
'use client';import { LiveSandboxRoot } from 'next-live/sandbox';import * as ui from '@/components/ui';export default function SandboxPage() {return (<LiveSandboxRootmodules={{ '@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#
<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:
['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-scriptskeeps the page isolated even if it is opened directly.frame-ancestorsdecides 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#
Same site or separate site
On your own site (/sandbox), the snippet is isolated from your cookies and DOM, but browsers usually share one process, so while (true) {} freezes your page too. On a separate site (a different registrable domain), Chrome, Edge and Firefox use a separate process, so only the frame freezes. In development, open the app on localhost and point the sandbox at 127.0.0.1 to try it.
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():
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,sessionStorageor 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 option | Default | What it does |
|---|---|---|
src | required | URL of the sandbox page. |
permissions | [] | Extra iframe permissions, like allow-forms or allow-modals. |
allowSameOrigin | false | Give the frame its real origin. Refused when src is on your page's origin. |
allow | denies camera, microphone, geolocation, USB, payment, clipboard reading and more | The frame's Permissions Policy. |
credentialless | true | Load the frame without cookies or storage, where the browser supports it. |
handshakeTimeoutMs | 10000 | How long to wait for the sandbox page. |
pingIntervalMs / pongTimeoutMs | 2000 / 5000 | The freeze watchdog. |
maxHeight | 10000 | The tallest the frame grows with height="auto". |
maxRestarts | 3 | Automatic 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.
