Freestyle Docs

Freestyle / Guides

How to Run a Next.js Dev Server in a Sandbox

Bake a Next.js dev server into a VM snapshot, run it under systemd, and open it on a public domain.

Build a snapshot with a Next.js dev server already running, then boot a VM that serves it and route a public domain to it. The two Next.js-specific steps are binding the dev server to every interface and telling its cross-origin dev check to trust your domain.

Install the SDK

pnpm add freestyle
bun add freestyle
npm install freestyle
yarn add freestyle

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Build a Snapshot with the Next.js Dev Server

Next.js runs on Node, so start from a snapshot with Node installed — build one by following How to Run Node.js in a Sandbox, which sets up Node 22 with nvm. Boot a builder from that snapshotId, scaffold a Next.js app with create-next-app, and configure it for the public domain. Two settings matter. First, next dev -H 0.0.0.0 binds the dev server to every interface, not just loopback, so traffic routed in from outside the VM reaches it. Second, the Next.js dev server blocks cross-origin requests to its internal /_next/* dev resources by default — because the app is reached over a domain that differs from the server’s own host, those requests count as cross-origin — so allowedDevOrigins has to list whatever origin you actually serve the app on, the domain you map below. A *.style.dev wildcard covers any Freestyle subdomain (what this guide uses); if you map your own custom domain, list that instead, or name a single exact host. Run it under systemd, wait until it serves, then snapshot — a Freestyle snapshot captures the running process, so VMs booted from it come up serving.

import { freestyle } from "freestyle";

// nodeSnapshotId comes from the Node.js guide — a snapshot with Node 22 (via nvm) ready.
const { vm: builder } = await freestyle.vms.create({ snapshotId: nodeSnapshotId });

// Scaffold a Next.js App Router app and install its dependencies. `node` sources nvm each call.
const node = "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
await builder.exec(
  `${node} mkdir -p /srv && cd /srv && npx --yes create-next-app@latest app --yes --ts --use-npm`,
  { timeoutMs: 600_000 },
);

Write a Next.js config that trusts the public domain for cross-origin dev requests:

await builder.fs.writeTextFile(
  "/srv/app/next.config.ts",
  `import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Trust cross-origin dev requests (HMR, Server Actions, internal /_next/* resources)
  // arriving over the domain you serve the app on — match this to whatever you map.
  // "*.style.dev" covers any Freestyle subdomain; for a custom domain use e.g.
  // "app.example.com" (or a "*.example.com" wildcard), or list one exact host.
  allowedDevOrigins: ["*.style.dev"],
};

export default nextConfig;
`,
);

Run the dev server as a systemd unit. systemd is PID 1, so it keeps the process alive; the unit sources nvm so npm is on PATH, and -H 0.0.0.0 -p 3000 binds every interface on a fixed port:

await builder.fs.writeTextFile(
  "/etc/systemd/system/nextjs.service",
  `[Service]
Environment=HOME=/root
WorkingDirectory=/srv/app
ExecStart=/usr/bin/bash -lc 'export NVM_DIR=/opt/nvm; . "$NVM_DIR/nvm.sh"; exec npm run dev -- -H 0.0.0.0 -p 3000'
Restart=always

[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now nextjs");

// Wait until Next.js serves on localhost, then snapshot the running dev server.
let code = "";
while (code !== "200") {
  await new Promise((r) => setTimeout(r, 2000));
  code = (
    await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/")
  ).stdout.trim();
}

const { snapshotId } = await builder.snapshot();
await builder.delete();

The first request compiles the route, so the initial poll can take a few seconds before it returns 200.

Open It on a Domain

Create a VM from the snapshot — Next.js is already serving — then route a domain to port 3000. Pick your own unique *.style.dev subdomain; it needs no DNS or verification.

const { vm, vmId } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });

const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3000 });

const res = await fetch(`https://${domain}`);
console.log(res.status); // 200

Open the printed URL in a browser and the Next.js app loads, hot-reload included. Because allowedDevOrigins covers the domain you mapped, the dev server trusts it: without it the page still renders, but Next.js answers cross-origin dev requests — the HMR connection, Server Actions, and /_next/* resources that carry the domain as their Origin — with HTTP 403 and logs ⚠ Blocked cross-origin request to Next.js dev resource, breaking hot-reload. Genuinely foreign origins stay blocked either way.

Stream the Server’s Logs

vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the logs live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:

const session = await vm.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u nextjs -f\n");

// session.detach() drops your handle — the service keeps running in the VM.
esc