/ Docs

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.

Live example

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.

Important

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.

Important

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.

Warning

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
    
ResourceSame originCross-origin sandbox
CookiesSharedBlocked
localStorageSharedBlocked
IndexedDBSharedBlocked
DOM accessAccessibleBlocked
Network requestsUnrestrictedUnrestricted (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