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 freestyle2. 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
- The user's TypeScript code is sent from the browser to the server action as a string.
freestyle.serverless.runs.createcompiles and runs that code in an isolated Freestyle environment.- The
codefield is the full source of the module to run — it should export adefaultasync function (or return a value directly). nodeModuleslists npm packages available inside the run. Here we includefreestyleso user code can callfreestyle.serverless.runs.createitself (nested runs).egresscontrols what outbound network requests the run can make. We allowapi.freestyle.shand use atransformto inject the realFREESTYLE_API_KEYfromprocess.envinto 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_hereGet 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:
- Spins up an isolated Node.js environment
- Compiles and runs your TypeScript code
- Returns
result(the return value of the default export) andlogs(anything passed toconsole.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
| File | Purpose |
|---|---|
lib/examples.ts | Snippet definitions (id, label, description, code) |
actions/getNpmTypeDefsAction.ts | Fetches .d.ts files from jsDelivr for Monaco IntelliSense |
actions/executeSnippetAction.ts | Runs user code via Freestyle Serverless Runs |
lib/formatRunLogs.ts | Normalizes Freestyle log entries to plain strings |
components/Playground.tsx | Editor 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.