LogoFreestyle

Building a Code Playground with Freestyle

Build a Monaco-powered TypeScript playground with server-side execution using Freestyle Serverless Runs.

This guide walks through building an interactive TypeScript playground with server side code execution. Users write code in a Monaco editor, hit Run, and see actual execution output — results and console logs — in the browser.

A complete reference implementation is available if you want to skip ahead or compare against a working version: freestyle-sh/playground-example.

What you'll build

  • A Monaco editor with TypeScript IntelliSense
  • Type definitions for npm packages loaded at runtime (so autocomplete works for freestyle, or any other package)
  • A snippet selector sidebar
  • Execution via Freestyle Serverless Runs, with results and logs displayed in an output panel

Stack

  • Next.js (App Router) — for server actions
  • @monaco-editor/react — embeds VS Code's editor in the browser
  • freestyle — SDK for creating serverless runs

1. Install dependencies

pnpm add @monaco-editor/react freestyle
# or: npm install @monaco-editor/react freestyle

2. Define your snippets

Snippets are the examples shown to users. Keep them in a plain data file so they're easy to extend.

// lib/examples.ts

export type Snippet = {
  id: string;
  label: string;
  description: string;
  code: string;
};

export const snippets: Snippet[] = [
  {
    id: "freestyle-serverless-run",
    label: "serverless run",
    description: "Run code in a Freestyle serverless run",
    code: `import { freestyle } from "freestyle";

export default async function run(): Promise<void> {
  const result = await freestyle.serverless.runs.create({
    code: \`export default async () => {
  return { message: "Hello from serverless run", timestamp: Date.now() };
};\`,
  });

  console.log(result.result);
}`,
  },
  {
    id: "freestyle-vm-create",
    label: "create vm",
    description: "Create a VM and execute a command",
    code: `import { freestyle } from "freestyle";

export default async function run(): Promise<void> {
  const { vm, vmId } = await freestyle.vms.create();
  const result = await vm.exec("echo Hello from VM");

  console.log({ vmId, stdout: result.stdout.trim() });
}`,
  },
];

3. Fetch type definitions (server action)

Monaco needs .d.ts files to provide IntelliSense for packages like freestyle. jsDelivr serves npm package files over CDN, so you can fetch the type definition file for any package at runtime.

This runs as a Next.js server action so the fetch happens server-side and the API key is never exposed to the client.

// actions/getNpmTypeDefsAction.ts
"use server";

export type NpmTypeDefRequest = {
  packageName: string;
  version?: string;      // defaults to "latest"
  typeDefPath?: string;  // path to the .d.ts file inside the package, e.g. "index.d.mts"
  moduleName?: string;   // module name to register in Monaco, e.g. "freestyle"
};

export type NpmTypeDefResult = {
  packageName: string;
  moduleName: string;
  typeDefPath: string;
  typeDefs: string;
};

export async function getNpmTypeDefsAction(
  requests: NpmTypeDefRequest[],
): Promise<NpmTypeDefResult[]> {
  return Promise.all(
    requests.map(async (request) => {
      const version = request.version ?? "latest";
      const typeDefPath = request.typeDefPath ?? "index.d.ts";
      const moduleName = request.moduleName ?? request.packageName;
      const url = `https://cdn.jsdelivr.net/npm/${request.packageName}@${version}/${typeDefPath}`;

      const response = await fetch(url, { cache: "no-store" });

      if (!response.ok) {
        throw new Error(
          `Failed to fetch type defs for ${request.packageName}: ${response.status}`,
        );
      }

      return {
        packageName: request.packageName,
        moduleName,
        typeDefPath,
        typeDefs: await response.text(),
      };
    }),
  );
}

How to find typeDefPath for a package:

Check the package's package.json on npm or jsDelivr for the types or typings field. For example, freestyle's package.json has "types": "index.d.mts", so typeDefPath is "index.d.mts".

4. Execute snippets (server action)

This is the core of the playground. User code is sent to a server action that creates a Freestyle Serverless Run — an isolated Node.js environment that executes the code and returns the result and logs.

// actions/executeSnippetAction.ts
"use server";

import { freestyle } from "freestyle";

export type ExecuteSnippetResult =
  | { ok: true; result: unknown; logs: unknown[] }
  | { ok: false; error: string };

export async function executeSnippetAction(
  script: string,
): Promise<ExecuteSnippetResult> {
  if (!script.trim()) {
    return { ok: false, error: "Snippet is empty." };
  }

  try {
    const run = await freestyle.serverless.runs.create({
      code: script,
      nodeModules: {
        freestyle: "0.1.49", // Make the freestyle package available inside the run
      },
      envVars: {
        FREESTYLE_API_KEY: "foo", // Placeholder — real key is injected below via egress
      },
      egress: {
        // Allow outbound requests to Freestyle's API and inject the real API key.
        // This keeps the key server-side and out of the user's code entirely.
        allow: {
          domains: {
            "api.freestyle.sh": [
              {
                transform: [
                  {
                    headers: {
                      Authorization: `Bearer ${process.env.FREESTYLE_API_KEY}`,
                    },
                  },
                ],
              },
            ],
          },
        },
      },
    });

    return {
      ok: true,
      result: run.result,
      logs: Array.isArray(run.logs) ? run.logs : [],
    };
  } catch (error) {
    return {
      ok: false,
      error: error instanceof Error ? error.message : "Failed to execute snippet.",
    };
  }
}

How execution works

  1. The user's TypeScript code is sent from the browser to the server action as a string.
  2. freestyle.serverless.runs.create compiles and runs that code in an isolated Freestyle environment.
  3. The code field is the full source of the module to run — it should export a default async function (or return a value directly).
  4. nodeModules lists npm packages available inside the run. Here we include freestyle so user code can call freestyle.serverless.runs.create itself (nested runs).
  5. egress controls what outbound network requests the run can make. We allow api.freestyle.sh and use a transform to inject the real FREESTYLE_API_KEY from process.env into every outbound request header. This way the key never appears in the user's code.

Environment setup

Add your Freestyle API key to .env.local:

FREESTYLE_API_KEY=your_key_here

Get a key at freestyle.sh.

5. Format log output

Freestyle returns logs as an array of mixed values — strings, objects with message/callstack fields, etc. This helper normalizes them to plain strings:

// lib/formatRunLogs.ts

function compactValue(value: unknown): string {
  if (typeof value === "string") return value;
  try {
    return JSON.stringify(value);
  } catch {
    return String(value);
  }
}

function toSingleLine(text: string): string {
  return text.replace(/\s+/g, " ").trim();
}

export function formatRunLogLine(log: unknown): string {
  if (typeof log === "string") return toSingleLine(log);

  if (log && typeof log === "object") {
    const value = log as {
      message?: unknown;
      callstack?: unknown;
      stack?: unknown;
      error?: unknown;
    };

    const message =
      value.message !== undefined
        ? compactValue(value.message)
        : compactValue(log);

    const details = value.callstack ?? value.stack ?? value.error;

    if (details !== undefined && details !== null && details !== "") {
      return toSingleLine(`${message} | ${compactValue(details)}`);
    }

    return toSingleLine(message);
  }

  return toSingleLine(String(log));
}

6. The Playground component

This is the main client component. It wires together the editor, type definitions, snippet selection, and execution.

// components/Playground.tsx
"use client";

import { useEffect, useMemo, useState } from "react";
import Editor from "@monaco-editor/react";
import type { Monaco } from "@monaco-editor/react";
import { getNpmTypeDefsAction, type NpmTypeDefRequest, type NpmTypeDefResult } from "@/actions/getNpmTypeDefsAction";
import { executeSnippetAction, type ExecuteSnippetResult } from "@/actions/executeSnippetAction";
import { formatRunLogLine } from "@/lib/formatRunLogs";
import { snippets, type Snippet } from "@/lib/examples";

// Declare which packages Monaco should have IntelliSense for.
const playgroundPackages: NpmTypeDefRequest[] = [
  {
    packageName: "freestyle",
    version: "0.1.49",
    typeDefPath: "index.d.mts",
    moduleName: "freestyle",
  },
];

export default function Playground() {
  const [selectedSnippetId, setSelectedSnippetId] = useState(snippets[0].id);
  const selectedSnippet = useMemo(
    () => snippets.find((s) => s.id === selectedSnippetId) ?? snippets[0],
    [selectedSnippetId],
  );
  const [editorValue, setEditorValue] = useState(selectedSnippet.code);

  // editorTheme starts null so we wait for the OS preference before rendering
  // the editor. This avoids a flash of the wrong theme on first paint.
  const [editorTheme, setEditorTheme] = useState<"vs" | "vs-dark" | null>(null);

  // packageTypeDefs starts null (not yet fetched) vs [] (fetched but empty).
  // We wait for a non-null value before rendering the editor so Monaco
  // doesn't initialize before type definitions are registered.
  const [packageTypeDefs, setPackageTypeDefs] = useState<NpmTypeDefResult[] | null>(null);

  const [monacoInstance, setMonacoInstance] = useState<Monaco | null>(null);
  const [isRunning, setIsRunning] = useState(false);
  const [runOutput, setRunOutput] = useState<ExecuteSnippetResult | null>(null);

  // Sync editor theme with OS dark mode preference.
  useEffect(() => {
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    const applyTheme = (isDark: boolean) => setEditorTheme(isDark ? "vs-dark" : "vs");
    const onChange = (e: MediaQueryListEvent) => applyTheme(e.matches);
    applyTheme(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // Fetch type definitions on mount.
  useEffect(() => {
    let mounted = true;
    async function load() {
      try {
        const defs = await getNpmTypeDefsAction(playgroundPackages);
        if (mounted) setPackageTypeDefs(defs);
      } catch {
        if (mounted) setPackageTypeDefs([]);
      }
    }
    load();
    return () => { mounted = false; };
  }, []);

  // Register type definitions with Monaco once both are available.
  // Returns a cleanup function that disposes the registered libs when
  // the component unmounts or the deps change.
  useEffect(() => {
    if (!monacoInstance || !packageTypeDefs?.length) return;

    const ts = monacoInstance.languages.typescript;
    ts.typescriptDefaults.setCompilerOptions({
      target: ts.ScriptTarget.ESNext,
      module: ts.ModuleKind.ESNext,
      moduleResolution: ts.ModuleResolutionKind.NodeJs,
      allowNonTsExtensions: true,
      esModuleInterop: true,
    });

    const disposables = packageTypeDefs.flatMap((entry) => {
      // Wrap the raw .d.ts content in a declare module block so Monaco
      // resolves imports like `import { freestyle } from "freestyle"`.
      const defs = `declare module "${entry.moduleName}" {\n${entry.typeDefs}\n}`;
      const path = `file:///node_modules/${entry.packageName}/${entry.typeDefPath}`;
      return [
        ts.typescriptDefaults.addExtraLib(defs, path),
        ts.javascriptDefaults.addExtraLib(defs, path),
      ];
    });

    return () => disposables.forEach((d) => d.dispose());
  }, [monacoInstance, packageTypeDefs]);

  function handleEditorMount(_editor: unknown, monaco: Monaco) {
    setMonacoInstance(monaco);
  }

  function handleSnippetSelect(snippet: Snippet) {
    setSelectedSnippetId(snippet.id);
    setEditorValue(snippet.code);
  }

  async function handleRunSnippet() {
    setIsRunning(true);
    setRunOutput(null);
    const result = await executeSnippetAction(editorValue);
    setRunOutput(result);
    setIsRunning(false);
  }

  // Only render the editor once both the theme and type defs are ready.
  // This prevents Monaco from initializing in the wrong theme or without
  // IntelliSense.
  const editorReady = editorTheme !== null && packageTypeDefs !== null;

  return (
    <div style={{ display: "grid", gridTemplateColumns: "220px 1fr", height: "100vh" }}>

      {/* Snippet sidebar */}
      <aside>
        {snippets.map((snippet) => (
          <button
            key={snippet.id}
            type="button"
            onClick={() => handleSnippetSelect(snippet)}
          >
            {snippet.label}
          </button>
        ))}
      </aside>

      {/* Editor + output */}
      <section style={{ display: "grid", gridTemplateRows: "auto 1fr auto" }}>

        {/* Toolbar */}
        <div>
          <span>{selectedSnippet.label}</span>
          <button type="button" onClick={handleRunSnippet} disabled={isRunning}>
            {isRunning ? "Running..." : "Run"}
          </button>
        </div>

        {/* Monaco editor */}
        {editorReady && (
          <Editor
            height="100%"
            language="typescript"
            value={editorValue}
            onMount={handleEditorMount}
            onChange={(value) => setEditorValue(value ?? "")}
            theme={editorTheme}
            options={{
              fontSize: 14,
              minimap: { enabled: false },
              scrollBeyondLastLine: false,
              automaticLayout: true,
              padding: { top: 12 },
            }}
          />
        )}

        {/* Output */}
        <div aria-live="polite">
          {!runOutput && <p>Run a snippet to see output.</p>}
          {runOutput && !runOutput.ok && <p style={{ color: "red" }}>{runOutput.error}</p>}
          {runOutput?.ok && (
            <>
              <pre>{JSON.stringify(runOutput.result ?? {}, null, 2)}</pre>
              {runOutput.logs.map((log, i) => (
                <pre key={i}>{formatRunLogLine(log)}</pre>
              ))}
            </>
          )}
        </div>

      </section>
    </div>
  );
}

Key implementation notes

Why delay rendering the editor?

editorTheme and packageTypeDefs both start as null. The editor is only rendered when both are set. This avoids:

  • A flash of the wrong theme on first paint
  • Monaco initializing before type definitions are registered (which would mean autocomplete doesn't work until a re-render triggers it)

How type definitions are registered

Monaco's addExtraLib accepts raw declaration content and a virtual file path. The raw .d.ts from the package is wrapped in a declare module "..." block so that import statements like import { freestyle } from "freestyle" resolve correctly. Both typescriptDefaults and javascriptDefaults are registered so it works regardless of the editor's language mode.

How onMount and the typedef effect interact

handleEditorMount stores the Monaco instance in state. The typedef registration effect depends on both monacoInstance and packageTypeDefs — it runs whenever either becomes available, and guards with an early return if either is still null. Whichever resolves last triggers the registration.

7. Wire it up in a page

Since the Playground component has all the client-side logic, the page itself can be a simple server component:

// app/page.tsx
import Playground from "@/components/Playground";

export default function Home() {
  return <Playground />;
}

How a Freestyle Serverless Run works

When you call freestyle.serverless.runs.create, Freestyle:

  1. Spins up an isolated Node.js environment
  2. Compiles and runs your TypeScript code
  3. Returns result (the return value of the default export) and logs (anything passed to console.log)

Your code module should have a default async function export:

export default async function run() {
  return { hello: "world" };
}

Or it can be an arrow function:

export default async () => {
  return 42;
};

Passing packages to the run

The nodeModules field lists npm packages available inside the run environment. You need to list any package your user code imports:

freestyle.serverless.runs.create({
  code: userCode,
  nodeModules: {
    freestyle: "0.1.49",
    // add other packages here
  },
});

Injecting secrets via egress

The egress config controls what outbound HTTP requests the run can make. Use transform to add headers to matching requests — this is how you inject an API key server-side without it ever appearing in user code:

egress: {
  allow: {
    domains: {
      "api.example.com": [
        {
          transform: [
            {
              headers: {
                Authorization: `Bearer ${process.env.MY_SECRET_KEY}`,
              },
            },
          ],
        },
      ],
    },
  },
},

The key stays in process.env on your Next.js server. The run environment sees only a placeholder in envVars, but every outbound request to api.example.com automatically gets the real key injected.

Summary

FilePurpose
lib/examples.tsSnippet definitions (id, label, description, code)
actions/getNpmTypeDefsAction.tsFetches .d.ts files from jsDelivr for Monaco IntelliSense
actions/executeSnippetAction.tsRuns user code via Freestyle Serverless Runs
lib/formatRunLogs.tsNormalizes Freestyle log entries to plain strings
components/Playground.tsxEditor UI, state, and wiring

The most copy-paste-worthy parts are the two server actions — they're standalone and can drop into any Next.js project. The executeSnippetAction in particular is the core primitive: give it a TypeScript string, get back a result and logs.

On this page

Freestyle AI

Documentation assistant

Experimental: AI responses may not always be accurate—please verify important details with the official documentation.

How can I help?

Ask me about Freestyle while you browse the docs.