Skip to main content
This tutorial explains how to run a Next.js application inside a Blaxel sandbox and expose it securely using sandbox preview URLs.

Prerequisites

Before starting, ensure you have:
  • Blaxel CLI installed and authenticated (bl login)
  • Node.js 18+ installed
  • @blaxel/core package installed in your project (npm install @blaxel/core)

Architecture Overview

Running Next.js inside a Blaxel sandbox requires a few adjustments compared to local development:
  • Running the Next.js dev server on port 3000
  • Exposing the Next.js dev server via a Blaxel preview URL

Create a base sandbox image

Dockerfile

FROM node:22-alpine

RUN apk update && apk add --no-cache \
  git \
  curl \
  netcat-openbsd \
  && rm -rf /var/cache/apk/*

WORKDIR /app

COPY --from=ghcr.io/blaxel-ai/sandbox:latest /sandbox-api /usr/local/bin/sandbox-api

# Create Next.js project with TypeScript, Tailwind, App Router, and Turbopack
RUN mkdir -p /app \
  && npx create-next-app@latest /app --use-npm --typescript --eslint --tailwind --src-dir --app --import-alias "@/*" --no-git --yes \
  && cd /app && npm install --save @next/swc-linux-x64-musl

COPY ./next.config.ts /app/next.config.ts

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Add npm global modules to PATH
ENV PATH="/usr/local/bin:$PATH"

ENTRYPOINT ["/entrypoint.sh"]

entrypoint.sh

Create an entrypoint script that starts the sandbox API and the dev server:
#!/bin/sh

# Set environment variables
export PATH="/usr/local/bin:$PATH"

# Start sandbox-api in the background
/usr/local/bin/sandbox-api &

# Function to wait for port to be available
wait_for_port() {
    local port=$1
    local timeout=30
    local count=0

    echo "Waiting for port $port to be available..."

    while ! nc -z localhost $port; do
        sleep 1
        count=$((count + 1))
        if [ $count -gt $timeout ]; then
            echo "Timeout waiting for port $port"
            exit 1
        fi
    done

    echo "Port $port is now available"
}

# Wait for port 8080 to be available
wait_for_port 8080

# Execute curl command to start Next.js dev server
echo "Running Next.js dev server..."
curl http://localhost:8080/process \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "name": "dev-server",
    "workingDir": "/app",
    "command": "npm run dev -- --port 3000",
    "waitForCompletion": false,
    "restartOnFailure": true,
    "maxRestarts": 25
  }'

wait

next.config.ts

Create a next.config.ts file to configure Next.js for use with Blaxel previews:
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  allowedDevOrigins: ["*.preview.bl.run"],
};

export default nextConfig;
The allowedDevOrigins: ["*.preview.bl.run"] setting allows the dev server to accept requests from the Blaxel preview origin, which is required for Blaxel preview URLs to work correctly. If you have configured a custom domain in your Blaxel workspace, you should also add it to this array (e.g., ["*.preview.bl.run", "*.preview.mycompany.com"]).

blaxel.toml

Create a blaxel.toml file in the same directory as your Dockerfile:
type = "sandbox"
name = "nextjs-template"

[runtime]
memory = 4096

[[runtime.ports]]
name = "nextjs-dev"
target = 3000
protocol = "tcp"

Deploy the sandbox image

Deploy the image by running:
bl deploy

Create or reuse a sandbox

Create a sandbox from the base image:
import { SandboxInstance } from "@blaxel/core";

const sandboxName = "my-nextjs-sandbox";

const sandbox = await SandboxInstance.createIfNotExists({
  name: sandboxName,
  labels: {
    framework: "nextjs",
  },
  image: "nextjs-template:latest",
  memory: 4096,
  ports: [
    { name: "preview", target: 3000, protocol: "HTTP" },
  ],
});

Configure CORS for preview URL access

Next.js dev servers work well with permissive CORS headers when accessed through a preview URL:
const responseHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH",
  "Access-Control-Allow-Headers":
    "Content-Type, Authorization, X-Requested-With, X-Blaxel-Workspace, X-Blaxel-Preview-Token, X-Blaxel-Authorization",
  "Access-Control-Allow-Credentials": "true",
  "Access-Control-Expose-Headers": "Content-Length, X-Request-Id",
  "Access-Control-Max-Age": "86400",
  Vary: "Origin",
};
Alternatively, you can use custom domains to expose previews on your own domain.

Create the preview URL

Next.js runs on port 3000, so we expose that port via a preview URL:
const preview = await sandbox.previews.createIfNotExists({
  metadata: { name: "dev-server-preview" },
  spec: {
    responseHeaders,
    public: false,
    port: 3000,
  },
});

Generate a preview token

To securely access the preview, a token is required:
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 1 day
const token = await preview.tokens.create(expiresAt);

Start the dev server

If not using the entrypoint script, you can start the dev server programmatically:
async function startDevServer(sandbox: SandboxInstance) {
  console.log("Starting Next.js dev server...");
  await sandbox.process.exec({
    name: "dev-server",
    command: "npm run dev -- --port 3000",
    workingDir: "/app",
    waitForPorts: [3000],
    restartOnFailure: true,
    maxRestarts: 25,
  });
}

Stream logs

To monitor the Next.js dev server output in real-time:
const logStream = sandbox.process.streamLogs("dev-server", {
  onLog(log) {
    console.log(log);
  },
});

// When done monitoring, close the stream:
logStream.close();

Access the Next.js application

Once everything is running, the Next.js application will be available at https://<PREVIEW-URL>?bl_preview_token=<TOKEN>

Complete example

Here is a full example combining all the steps:
import { SandboxInstance } from "@blaxel/core";

const sandboxName = "my-nextjs-sandbox";

const responseHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH",
  "Access-Control-Allow-Headers":
    "Content-Type, Authorization, X-Requested-With, X-Blaxel-Workspace, X-Blaxel-Preview-Token, X-Blaxel-Authorization",
  "Access-Control-Allow-Credentials": "true",
  "Access-Control-Expose-Headers": "Content-Length, X-Request-Id",
  "Access-Control-Max-Age": "86400",
  Vary: "Origin",
};

async function startDevServer(sandbox: SandboxInstance) {
  await sandbox.process.exec({
    name: "dev-server",
    command: "npm run dev -- --port 3000",
    workingDir: "/app",
    waitForPorts: [3000],
    restartOnFailure: true,
    maxRestarts: 25,
  });
}

async function main() {
  try {
    // Create or reuse the sandbox
    const sandbox = await SandboxInstance.createIfNotExists({
      name: sandboxName,
      labels: {
        framework: "nextjs",
      },
      image: "nextjs-template:latest",
      memory: 4096,
      ports: [
        { name: "preview", target: 3000, protocol: "HTTP" },
      ]
    });

    // Create preview
    const preview = await sandbox.previews.createIfNotExists({
      metadata: { name: "preview" },
      spec: {
        responseHeaders,
        public: false,
        port: 3000,
      },
    });

    // Generate preview token
    const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24);
    const token = await preview.tokens.create(expiresAt);

    // Start dev server if not already running
    const processes = await sandbox.process.list();
    if (!processes.find((p) => p.name === "dev-server")) {
      await startDevServer(sandbox);
    }

    // Print access URL
    const webUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`;
    console.log(`Next.js Preview URL: ${webUrl}`);

    // Stream logs
    const logStream = sandbox.process.streamLogs("dev-server", {
      onLog(log) {
        console.log(log)
      },
    });

    // Keep running until interrupted
    process.on("SIGINT", () => {
      logStream.close();
      process.exit(0);
    });
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}

main();

Template features

Turbopack

The template uses Turbopack, Next.js’s Rust-based bundler, for significantly faster development builds. Turbopack provides:
  • Faster cold starts
  • Instant hot module replacement (HMR)
  • Optimized incremental compilation
The @next/swc-linux-x64-musl package is pre-installed for optimal performance on Alpine Linux.

App Router

The template comes pre-configured with the App Router (/app directory structure). This provides:
  • Server Components by default
  • Nested layouts
  • Loading and error states
  • Server Actions

TypeScript

Full TypeScript support is enabled out of the box with strict type checking.

Tailwind CSS

Tailwind CSS is pre-configured for styling. The src/app/globals.css file includes the Tailwind directives.

ESLint

ESLint is configured with Next.js recommended rules for code quality.