---
title: "How to Run a Next.js Dev Server in a Sandbox"
description: "Bake a Next.js dev server into a VM snapshot, run it under systemd, and open it on a public domain."
url: "/docs/guides/run-nextjs-in-a-sandbox"
---

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

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
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](https://www.freestyle.sh/docs/guides/run-nodejs-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.

```ts
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:

```ts
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:

```ts
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.

```ts
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](https://www.freestyle.sh/docs/vms/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:

```ts
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.
```
