Setting up Expo with Blaxel Sandboxes

Last updated: January 19, 2026

This guide explains how to run an Expo (React Native / Web) application inside a Blaxel Sandbox and expose it securely using Blaxel Previews.

Overview

The guide covers:

  • Building a sandbox image for Expo

  • Creating or reusing a Blaxel sandbox

  • Configuring Expo to work behind Blaxel previews

  • Launching Expo and accessing it through a preview URL

  • Accessing the app on mobile devices via QR code

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 Expo inside a Blaxel sandbox requires a few adjustments compared to local development:

  • Expo normally binds to local or internal URLs

  • Blaxel exposes services via preview URLs

  • Expo must be explicitly configured to use those preview URLs

This is solved by:

  1. Injecting the preview URL into app.json

  2. Setting EXPO_PACKAGER_PROXY_URL to force Expo to proxy assets through the preview URL

  3. Restarting the dev server after configuration changes

  4. Exposing the Expo dev server via a Blaxel Preview


Sandbox Image for Expo

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 Expo project with default template
RUN npx create-expo-app@latest .

# Install web dependencies required for Expo web support
RUN npx expo install react-dom react-native-web

# Pre-warm Metro bundler cache by starting the dev server briefly
# This warms up the cache better than export since it matches actual dev usage
RUN timeout 120 npx expo start --web --port 8081 --no-dev-client 2>/dev/null || true

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

ENTRYPOINT ["/usr/local/bin/sandbox-api"]

blaxel.toml

Create a blaxel.toml file in the same directory as your Dockerfile:

type = "sandbox"
name = "expo-template"

[runtime]
memory = 8192

[[runtime.ports]]
name = "expo-web"
target = 8081

Deploying the Image

Deploy the image by running:

bl deploy

Creating or Reusing a Sandbox

import { SandboxInstance } from "@blaxel/core";

const sandboxName = "my-expo-sandbox";

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

Configuring CORS for Expo Preview

Expo dev servers require 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.


Creating the Blaxel Preview

Expo runs on port 8081, so we expose that port via a preview:

const preview = await sandbox.previews.createIfNotExists({
  metadata: { name: "dev-server-preview" },
  spec: {
    responseHeaders,
    public: false,
    port: 8081,
  },
});

Generating 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);

Configuring Expo for Blaxel Previews

Injecting the Preview URL into app.json

Expo Router requires the correct origin when running behind a proxy:

async function addRouterOriginToAppJson(
  sandbox: SandboxInstance,
  previewUrl: string
) {
  const appJsonPath = "/app/app.json";

  const appJsonContent = await sandbox.fs.read(appJsonPath);
  const appJson = JSON.parse(appJsonContent);

  appJson.expo = {
    ...appJson.expo,
    extra: {
      ...(appJson.expo.extra || {}),
      router: {
        ...(appJson.expo.extra?.router || {}),
        origin: previewUrl,
      },
    },
  };

  await sandbox.fs.write(appJsonPath, JSON.stringify(appJson, null, 2));
}

Configuring the Proxy URL

Expo must be configured to serve assets through the preview URL. This function checks if the configuration is already correct and only updates if needed:

async function configureExpoProxyUrl(
  sandbox: SandboxInstance,
  previewUrl: string
): Promise<boolean> {
  const baseUrl = previewUrl.replace(/\/$/, ""); // Remove trailing slash

  // Check if the .env file already has the correct proxy URL
  let envContent = "";
  try {
    envContent = await sandbox.fs.read("/app/.env");
  } catch {
    // File doesn't exist yet
  }

  const expectedEnvLine = `EXPO_PACKAGER_PROXY_URL=${baseUrl}`;

  if (envContent.includes(expectedEnvLine)) {
    console.log("Expo proxy URL already configured correctly");
    return false; // No changes made
  }

  // Remove any existing EXPO_PACKAGER_PROXY_URL line and add the new one
  const lines = envContent
    .split("\n")
    .filter((line) => !line.startsWith("EXPO_PACKAGER_PROXY_URL="));
  lines.push(expectedEnvLine);

  await sandbox.fs.write("/app/.env", lines.join("\n"));
  console.log(`Configured Expo to use proxy URL: ${baseUrl}`);
  return true; // Changes were made
}

Start the dev Server

After setting the proxy URL you can start the dev server:

async function startDevServer(sandbox: SandboxInstance) {
  // Start a new dev server with the updated environment
  console.log("Starting dev server with updated configuration...");
  await sandbox.process.exec({
    name: "dev-server",
    command: "npx expo start --web --port 8081 --scheme exp",
    workingDir: "/app",
    waitForPorts: [8081],
    restartOnFailure: true,
    maxRestarts: 25,
  });
}

Streaming Expo Logs

To monitor the Expo 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();

Accessing the Expo App

Web Browser

Once everything is running, the app is available at:

${preview.spec?.url}?bl_preview_token=${token.value}

Mobile Device via QR Code

To access the Expo app on your mobile device:

  1. Generate a QR code containing the Expo URL format:

// Build the URL with authentication token
const previewUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`;

// Convert to Expo URL format (replace https:// with exp://)
const expoUrl = previewUrl.replace("https://", "exp://");
  1. The QR code should encode the exp:// URL, for example:

exp://your-sandbox.preview.blaxel.ai/?bl_preview_token=your-token
  1. Scan the QR code with:

    • Your device’s camera (iOS/Android)

    • The Expo Go app


Complete Example

Here is a full runnable example combining all the steps:

import { SandboxInstance } from "@blaxel/core";

const sandboxName = "my-expo-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 addRouterOriginToAppJson(
  sandbox: SandboxInstance,
  previewUrl: string
) {
  const appJsonPath = "/app/app.json";
  const appJsonContent = await sandbox.fs.read(appJsonPath);
  const appJson = JSON.parse(appJsonContent);

  appJson.expo = {
    ...appJson.expo,
    extra: {
      ...(appJson.expo.extra || {}),
      router: {
        ...(appJson.expo.extra?.router || {}),
        origin: previewUrl,
      },
    },
  };

  await sandbox.fs.write(appJsonPath, JSON.stringify(appJson, null, 2));
}

async function configureExpoProxyUrl(
  sandbox: SandboxInstance,
  previewUrl: string
): Promise<boolean> {
  const baseUrl = previewUrl.replace(/\/$/, "");

  let envContent = "";
  try {
    envContent = await sandbox.fs.read("/app/.env");
  } catch {
    // File doesn't exist
  }

  const expectedEnvLine = `EXPO_PACKAGER_PROXY_URL=${baseUrl}`;

  if (envContent.includes(expectedEnvLine)) {
    return false;
  }

  const lines = envContent
    .split("\n")
    .filter((line) => !line.startsWith("EXPO_PACKAGER_PROXY_URL="));
  lines.push(expectedEnvLine);

  await sandbox.fs.write("/app/.env", lines.join("\n"));
  return true;
}

async function startDevServer(sandbox: SandboxInstance) {
  await sandbox.process.exec({
    name: "dev-server",
    command: "npx expo start --web --port 8081 --scheme exp",
    workingDir: "/app",
    waitForPorts: [8081],
    restartOnFailure: true,
    maxRestarts: 25,
  });
}

async function configureExpo(sandbox: SandboxInstance, previewUrl: string) {
  await addRouterOriginToAppJson(sandbox, previewUrl);
  const proxyUrlChanged = await configureExpoProxyUrl(sandbox, previewUrl);
  await startDevServer(sandbox)
}

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

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

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

    // Configure Expo (will restart dev server if needed)
    await configureExpo(sandbox, preview.spec?.url!);

    // Start dev server if not already running
    const processes = await sandbox.process.list();
    if (!processes.find((p) => p.name === "dev-server")) {
      await sandbox.process.exec({
        name: "dev-server",
        command: "npx expo start --web --port 8081 --scheme exp",
        workingDir: "/app",
        waitForPorts: [8081],
        restartOnFailure: true,
        maxRestarts: 25,
      });
    }

    // Print access URLs
    const webUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`;
    const expoUrl = webUrl.replace("https://", "exp://");

    console.log(`Web Preview URL: ${webUrl}`);
    console.log(`Expo Mobile URL: ${expoUrl}`);

    // 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();

Summary

This setup allows you to:

  • Run Expo fully inside Blaxel sandboxes

  • Securely expose the dev server using Blaxel previews

  • Support Expo Router and asset loading correctly

  • Access the app on mobile devices via QR code using the exp:// protocol

  • Automatically restart the dev server when configuration changes

This approach is ideal for preview environments, internal demos, and AI-powered coding workflows built on Blaxel.