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 freestylebun add freestylenpm install freestyleyarn 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.