How to Let Someone Upload a Node.js Project and Preview It Without Running It on Your Backend
How to Let Someone Upload a Node.js Project and Preview It Without Running It on Your Backend
Uploading a project is the easy part. The hard part is deciding where that project should run.
If you accept a Node.js project on your backend, you now own the isolation model, cleanup, abuse controls, and preview routing for someone else’s code. That gets expensive fast, especially if the upload is only meant to support a demo, a review flow, or a short-lived trial.
This demo keeps the runtime in the browser instead. The upload happens in the browser, the project is copied into a BrowserPod, dependencies are installed there, and the preview is opened through the portal URL from that pod. No shared preview server has to stay up on your side — the execution is entirely client-side.
Start by detecting how the upload should launch
The first thing this demo does is look at the uploaded files and decide what kind of project it has received.
If it finds a package.json, it treats the upload as a Node project. It also looks at the lockfiles to pick a package manager and checks the scripts block to decide what command should open the app:
async function getProjectPlan(entries) {
const fileNames = entries
.map((entry) => sanitizeRelativePath(entry.relativePath).toLowerCase())
.filter(Boolean);
if (fileNames.some((fileName) => fileName.endsWith("package.json"))) {
const packageManager = detectPackageManager(fileNames);
const scripts = await readPackageScripts(entries, packageManager);
const startCommand =
scripts.start || defaultProjectStartCommand(packageManager);
return {
type: "node",
packageManager,
installCommand: `${packageManager} install`,
startCommand,
};
}
return {
type: "static",
packageManager: "",
installCommand: "",
startCommand: "node .browserpod-preview-server.js",
};
}
That logic matters because upload-preview flows fail when they assume too much. A project with pnpm-lock.yaml shouldn’t be opened with npm. A project with scripts.dev but no scripts.start still needs a sensible default. This demo handles those cases before the pod ever boots.
Boot the pod, wait for WASM init, and attach the preview
Once the files are selected, the launch flow boots a pod, waits for the runtime to settle, creates a terminal, and registers a portal callback for the preview:
const pod = await BrowserPod.boot({ apiKey: API_KEY });
await waitForPodBoot();
const terminal = await pod.createDefaultTerminal(consoleDiv);
pod.onPortal(({ url }) => {
if (!url) return true;
pendingPortalUrl = url;
portalReadyAt = Date.now();
showPortalPreview(url, { expand: true });
return true;
});
The wait is there for a reason:
function waitForPodBoot() {
return new Promise((resolve) => {
setTimeout(resolve, 500);
});
}
Without that delay, filesystem and terminal calls can race the WASM startup sequence. The launch flow always does the wait before it creates /project or runs any commands.
Copy the uploaded files into /project
The uploaded files are still browser files at this point. Before the app can start, the demo writes them into the pod filesystem.
It creates parent directories as needed, writes each file into /project, and chunks binary uploads so large files do not get pushed in one giant write:
async function copyUploadedProject(pod, entries) {
for (const entry of entries) {
const relativePath = sanitizeRelativePath(entry.relativePath);
if (!relativePath) continue;
const parent = getParentDirectory(relativePath);
if (parent) {
await pod.createDirectory(`/project/${parent}`, { recursive: true });
}
const fd = await openOrCreateFile(
pod,
`/project/${relativePath}`,
"binary"
);
await writeBinaryFileInChunks(fd, entry.file);
await fd.close();
}
}
async function writeBinaryFileInChunks(fd, file) {
const totalSize = Number(file?.size || 0);
if (totalSize <= 0) return;
for (let offset = 0; offset < totalSize; offset += podWriteChunkBytes) {
const end = Math.min(offset + podWriteChunkBytes, totalSize);
const chunkBuffer = await file.slice(offset, end).arrayBuffer();
await fd.write(chunkBuffer);
}
}
Until the files are inside /project, the pod has nothing to install and nothing to execute.
Install dependencies and start the uploaded app
Once the project plan is known and the files are in place, the demo runs the install and start commands inside /project:
const installCommand = (projectPlan.installCommand || "").trim();
const installParts = splitCommand(installCommand);
if (installParts.length) {
await pod.run(installParts[0], installParts.slice(1), {
echo: true,
terminal,
cwd: "/project",
});
}
const startCommand = (projectPlan.startCommand || defaultStartCommand).trim();
const startParts = splitCommand(startCommand);
if (projectPlan.type === "static") {
await writeStaticServerFile(pod);
}
await pod.run(startParts[0], startParts.slice(1), {
echo: true,
terminal,
cwd: "/project",
});
For a Node upload, that usually means npm install and then npm run start, npm run dev, or a similar script. For a static upload, the demo writes .browserpod-preview-server.js into the project first and uses that as the start command.
The terminal output stays visible in the outer app, which means the uploader can see whether install failed, whether the app crashed, and what port the preview opened on.
Keep sharing and review features outside the uploaded project
The uploaded project is only one part of this demo. The rest of the workflow lives in the outer app.
The shell in index.html handles the upload wizard, preview panel, share controls, monitoring, and setup screens. The separate login.html gives you a starting point for gated preview access. Chat, video, and tutorial overlays are all layered on top of the preview instead of being pushed into the uploaded project itself.
The uploaded app can stay close to what the user already has, while your preview product handles review links, onboarding, or support tooling outside it.
The sample runtime is still just a Node app
The demo ships with an Express app in public/project/ so the preview flow has something to launch out of the box:
const express = require("express");
const app = express();
const port = 3000;
let clickCount = 0;
app.get("/", (req, res) => {
const currentCount = clickCount;
res.send(`
<!DOCTYPE html>
<html lang="en">
<body>
<div class="content">
<h1>Node Project Demo Template</h1>
<p>Express.js is running inside your private BrowserPod preview session.</p>
<div class="card">
<p>Interactive click counter: <strong id="count">${currentCount}</strong></p>
<button id="bump">Press to increment</button>
</div>
</div>
<script>
document.getElementById("bump").addEventListener("click", async () => {
const response = await fetch('/api/click', {method: 'POST'});
const payload = await response.json();
document.getElementById("count").textContent = payload.count;
});
</script>
</body>
</html>
`);
});
When you adapt the demo, keep the code inside the pod close to a normal Node project.
Tell people what will and will not work
This kind of preview flow needs honest limits.
You still need:
- a BrowserPod API key
- cross-origin isolation headers
- HTTPS in production
- a Chromium-based browser as the safest default target
You also need to set expectations about the runtime:
- even small projects take time to boot, install, and start
- each pod boot consumes tokens
- pure JavaScript packages work best
- native binaries will not run unless there is a WASM-compatible alternative
If the upload depends on long-lived secrets, background workers, or a stack that assumes native tooling, this is the wrong preview model.
Where this approach fits
Use this when you need to let someone try, review, or debug a Node project without provisioning a shared backend for every upload.
Examples:
- a developer uploads a sample integration and needs a guided preview
- a support engineer needs a short-lived reproduction environment
- a product team wants a review flow around customer-submitted Node apps
- an onboarding flow needs chat, video, or tutorial steps around the preview
If all you need is a screenshot, this is too much. If you need a long-lived hosted service, this is not enough. It fits the middle case: a project upload that needs to run now, inside an isolated browser session, with a preview URL the user can open immediately.
Further reading: