Tutorial: Editor + Next.js Preview
Build a file editor with tabs, a live Next.js preview in an iframe, and HMR — step by step. Then lock it down with a cross-origin sandbox for production.
What We're Building
A two-panel layout: a code editor on the left (textarea with file tabs) and a live Next.js preview on the right (iframe). When you edit a file and save, the preview updates instantly via Hot Module Replacement — no full page reload.
The architecture looks like this:
┌─────────────────────┐ ┌──────────────────────────┐
│ Your App (Editor) │ │ Preview (iframe) │
│ │ │ │
│ textarea ──write──►│VFS │ /__virtual__/3000/ │
│ │ │ │ │
│ │ │ │ Next.js HTML + React │
│ │ ▼ │ │
│ NextDevServer ─────┼──┼──│ HMR via postMessage │
│ │ │ │ │
└─────────────────────┘ │ └──────────────────────────┘
│
Service Worker
(intercepts fetch)
The VirtualFS stores your files in memory. The NextDevServer transforms JSX/TSX and serves HTML. The Service Worker intercepts fetch requests to /__virtual__/3000/ and routes them to the dev server. The iframe loads the dev server URL and receives HMR updates via postMessage.
See the finished result at /examples/editor-tutorial.html. Run npm run dev and open it in your browser.
Step 1: HTML Scaffolding
Start with a two-column layout — an editor panel on the left and a preview panel on the right:
<div class="container">
<!-- Left: Editor -->
<div class="editor-panel">
<div id="file-tabs"></div>
<textarea id="editor"></textarea>
<button id="start-btn">Start Preview</button>
<button id="save-btn">Save</button>
</div>
<!-- Right: Preview -->
<div class="preview-panel">
<iframe id="preview-frame"></iframe>
</div>
</div>
The file tabs will be generated dynamically based on your project's files. The textarea is our simple editor — you can swap it for CodeMirror or Monaco later.
Step 2: Set Up the Virtual Filesystem
Create a VirtualFS and populate it with a Next.js App Router project:
import { VirtualFS } from 'almostnode';
const vfs = new VirtualFS();
// Create directories
vfs.mkdirSync('/app', { recursive: true });
vfs.mkdirSync('/app/about', { recursive: true });
// Root layout
vfs.writeFileSync('/app/layout.tsx', `
export default function RootLayout({ children }) {
return (
<html>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
</body>
</html>
);
}
`);
// Home page with client-side interactivity
vfs.writeFileSync('/app/page.tsx', `
'use client';
import { useState } from 'react';
export default function Home() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Welcome!</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
`);
// About page
vfs.writeFileSync('/app/about/page.tsx', `
export default function About() {
return <h1>About</h1>;
}
`);
The VFS stores everything in memory — no disk I/O, no server. Files written here are what the Next.js dev server will serve.
Step 3: Start the Next.js Dev Server
Three things are needed: a NextDevServer, a ServerBridge with its Service Worker, and a registration that connects them:
import { NextDevServer } from 'almostnode/next';
import { getServerBridge } from 'almostnode';
// 1. Create the dev server
const devServer = new NextDevServer(vfs, { port: 3000, root: '/' });
// 2. Initialize the Service Worker bridge
const bridge = getServerBridge();
await bridge.initServiceWorker();
// 3. Register the server on a port
bridge.registerServer(devServer, 3000);
// 4. Start the server (enables file watching for HMR)
devServer.start();
// 5. Get the URL to load in the iframe
const serverUrl = bridge.getServerUrl(3000);
// → "http://localhost:5173/__virtual__/3000"
The Service Worker intercepts all requests to /__virtual__/3000/* and forwards them to the NextDevServer which transforms JSX, resolves routes, and returns HTML.
You must call devServer.start() to enable file watching. Without it, HMR updates won't fire when you write to the VFS.
Step 4: Display the Preview
Point the iframe to the server URL and register it as an HMR target:
const iframe = document.getElementById('preview-frame');
// Connect HMR when iframe loads
iframe.onload = () => {
if (iframe.contentWindow) {
devServer.setHMRTarget(iframe.contentWindow);
}
};
// Load the preview
iframe.src = serverUrl + '/';
setHMRTarget() tells the dev server where to send HMR updates via postMessage. The iframe's contentWindow receives these messages and React Refresh applies the changes without losing component state.
You must call setHMRTarget() inside the iframe's onload handler. The contentWindow reference changes every time the iframe navigates, so you need to re-register it.
Step 5: Wire Up the Editor
The editor needs two things: file tab switching and saving files to the VFS (which triggers HMR).
File Tab Switching
const files = [
{ path: '/app/page.tsx', label: 'page.tsx' },
{ path: '/app/layout.tsx', label: 'layout.tsx' },
{ path: '/app/about/page.tsx', label: 'about/page.tsx' },
];
let currentFile = files[0].path;
function loadFile(path) {
editor.value = vfs.readFileSync(path, 'utf8');
currentFile = path;
}
Saving Files (Triggers HMR)
function saveFile() {
vfs.writeFileSync(currentFile, editor.value);
// HMR triggers automatically — the dev server watches the VFS
}
// Keyboard shortcut
editor.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
That's it — writing to the VFS is the only trigger needed. The NextDevServer watches the VFS for changes and sends HMR updates to the iframe automatically.
Listening for HMR Events
You can listen for HMR events to show feedback (e.g., a toast notification):
devServer.on('hmr-update', (update) => {
console.log('HMR update:', update.path);
showToast('Updated!');
});
Step 6: Cross-Origin Sandbox
Everything above runs on the same origin — the editor and the preview share cookies, localStorage, and DOM access. This is fine for demos, but dangerous if users can edit arbitrary code.
If the preview iframe runs on the same origin as your app, user-written code can access your app's cookies, auth tokens, and localStorage. For any user-facing editor, always use a cross-origin sandbox.
How Cross-Origin Isolation Works
Deploy the preview on a different origin (different domain, subdomain, or port). The browser's same-origin policy then prevents the iframe from accessing your app's storage:
Your app: https://myapp.com ← cookies, auth, localStorage
Sandbox: https://sandbox.myapp.com ← isolated, no access to myapp.com
| Resource | Same origin | Cross-origin sandbox |
|---|---|---|
| Cookies | Shared | Blocked |
| localStorage | Shared | Blocked |
| IndexedDB | Shared | Blocked |
| DOM access | Accessible | Blocked |
| Network requests | Unrestricted | Unrestricted (add CSP to restrict) |
Setting Up the Sandbox
The sandbox needs its own Service Worker and HTML page. Use generateSandboxFiles() to create them:
import { generateSandboxFiles } from 'almostnode';
import fs from 'fs';
const files = generateSandboxFiles();
fs.mkdirSync('sandbox', { recursive: true });
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(`sandbox/${name}`, content);
}
// Deploy to a separate origin:
// cd sandbox && vercel --prod
Connecting Your Editor to the Sandbox
Instead of running the dev server on the same page, the cross-origin sandbox runs almostnode inside the iframe. Communication happens via postMessage:
import { VirtualFS, createRuntime } from 'almostnode';
const vfs = new VirtualFS();
// Point to your deployed sandbox
const runtime = await createRuntime(vfs, {
sandbox: 'https://sandbox.myapp.com',
});
// The runtime handles VFS sync and postMessage automatically
const result = await runtime.execute(code);
Local Development
For local testing, run the sandbox on a different port:
# Terminal 1: Your editor app
npm run dev # → http://localhost:5173
# Terminal 2: Sandbox
npm run sandbox # → http://localhost:3002
Then configure the sandbox URL:
const runtime = await createRuntime(vfs, {
sandbox: 'http://localhost:3002/sandbox/',
});
For the full sandbox deployment guide (Vercel, Nginx, Express, CORS headers), see the Security & Sandbox docs.
Complete Code
Here's the full working example in a single <script> block. This is the same-origin version — see Step 6 above for the cross-origin production setup.
<script type="module">
import { VirtualFS } from 'almostnode';
import { NextDevServer } from 'almostnode/next';
import { getServerBridge } from 'almostnode';
// ── State ──
let vfs, devServer, serverUrl;
let currentFile = '/app/page.tsx';
const editor = document.getElementById('editor');
const iframe = document.getElementById('preview-frame');
const files = [
{ path: '/app/page.tsx', label: 'page.tsx' },
{ path: '/app/layout.tsx', label: 'layout.tsx' },
{ path: '/app/about/page.tsx', label: 'about/page.tsx' },
];
// ── 1. Set up VFS with project files ──
vfs = new VirtualFS();
vfs.mkdirSync('/app/about', { recursive: true });
vfs.writeFileSync('/app/layout.tsx', `
export default function RootLayout({ children }) {
return <html><body>{children}</body></html>;
}
`);
vfs.writeFileSync('/app/page.tsx', `
'use client';
import { useState } from 'react';
export default function Home() {
const [count, setCount] = useState(0);
return <div><h1>Hello!</h1><p>{count}</p>
<button onClick={() => setCount(c => c+1)}>+</button></div>;
}
`);
vfs.writeFileSync('/app/about/page.tsx', `
export default function About() {
return <h1>About</h1>;
}
`);
// ── 2. Load file into editor ──
function loadFile(path) {
editor.value = vfs.readFileSync(path, 'utf8');
currentFile = path;
}
// ── 3. Save file (triggers HMR automatically) ──
function saveFile() {
vfs.writeFileSync(currentFile, editor.value);
}
editor.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
// ── 4. Start dev server and preview ──
async function start() {
devServer = new NextDevServer(vfs, { port: 3000, root: '/' });
devServer.start(); // Enable file watching for HMR
const bridge = getServerBridge();
await bridge.initServiceWorker();
bridge.registerServer(devServer, 3000);
serverUrl = bridge.getServerUrl(3000) + '/';
devServer.on('hmr-update', (u) => console.log('HMR:', u.path));
iframe.onload = () => {
if (iframe.contentWindow)
devServer.setHMRTarget(iframe.contentWindow);
};
iframe.src = serverUrl;
}
loadFile(currentFile);
document.getElementById('start-btn').addEventListener('click', start);
</script>
Next Steps
- Upgrade the editor — Swap the textarea for CodeMirror or Monaco Editor for syntax highlighting, autocomplete, and proper indentation.
- Add a URL bar — Let users navigate between routes. See the full Next.js demo for an implementation.
- Install npm packages — Use
PackageManagerfrom almostnode to install packages at runtime. See the API Reference. - Deploy the sandbox — Follow the Security & Sandbox guide to deploy a cross-origin sandbox for production.