How to add Monaco to a Next.js app and securely run untrusted user code
While many turn to SaaS platforms for their flexibility over on-prem software, the real value unlocked by SaaS is allowing users to create their own custom workflows, such as to transform or enrich a data stream, or to build bespoke automation, without needing to maintain production infrastructure.
Many SaaS platforms already offer user-driven customizability â Slack enables building custom modular workflows, Salesforce allows creating workflows based off sales and marketing activities, and Twilio offers manipulation of voice calls through a serverless environment. And these platforms minimize friction to create these workflows by offering their users an in-browser IDE that deploys and runs code â removing the need to maintain another piece of production infrastructure.
However, adding a fully featured in-browser editor to an app can be challenging, not to mention building a platform for securely deploying and executing untrusted user code on the web. Allowing anyone to deploy and run code welcomes potentially nefarious users deploying code that tries to access other deployments and your own systems, as well as exploit your resources for bitcoining mining.
In this blog post, weâll go over solving for these key challenges, and by the end, youâll have a simple browser IDE that can deploy and run code in the cloud.
Follow the steps below or skip directly to the source code.
Integrate Monaco and Next.js
If you are bringing a Next.js app to this tutorial, great. If not, you can create a new one from scratch and follow along. Note that weâll be using Next.js version 14 in this tutorial.
Letâs create the page that shows the in-browser editor. Under the folder app
,
weâll create the folder ide
and, within that, a page.js
file.
First, weâll add the directive and necessary imports. Weâll use
@monaco-editor/react
,
which is a nifty React component for Monaco. Weâll also import useState
and
useEffect
hooks to help manage state.
"use client";
import Editor from "@monaco-editor/react";
import { useEffect, useState } from "react";
Below that, weâll define the component IDE
:
export default function IDE() {
const handleSubmit = async () => {};
return (
<div className="flex justify-center items-start pt-10 h-screen">
<div className="w-full max-w-4xl p-4 border">
<form action="#" onSubmit={handleSubmit}>
<div className="">
<label htmlFor="comment" className="sr-only">
Add your code
</label>
<Editor
height="50vh"
defaultLanguage="javascript"
defaultValue='Deno.serve(req => new Response("Hello!"));'
/>
</div>
<div className="flex justify-between pt-2">
<div className="flex items-center space-x-5"></div>
<div className="flex-shrink-0">
<button
type="submit"
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
>
Run
</button>
</div>
</div>
</form>
</div>
</div>
);
}
Note we currently stub out the functions handleSubmit
â weâll fill those in
later. Now, if you start your server with npm run dev
and point your browser
to localhost:3000/ide
, you should see:
Great â you have the Monaco editor working in your Next.js app! While you can write code, you canât deploy or execute it. Letâs fix that.
Deploy and execute untrusted code
Deploying and executing third party code is a complex engineering problem that requires provisioning multiple sandboxes on demand, preventing deployments from accessing other deployments as well as your own systems, and routing request traffic properly to maintain performance for all users.
While you can build that yourself, for this tutorial weâll use the Deno Deploy Subhosting API, which allows you to programmatically deploy and execute code in the cloud. Itâs designed to run untrusted code securely â you donât need to worry about a single deployment attempting to access the processes of another or of your internal systems.
This section will expand on the above with the following:
- Create a free Subhosting account
- Grab Subhosting API tokens
- Create API routes to deploy code
- Define stubbed functions in
app/ide/page.js
Grab Subhosting API tokens
Before you begin, you’ll need to have a Deno Deploy access token and an ID for the Deno Deploy organization you’re using for Subhosting.
- You can find or create a personal access token in the dashboard here
- Your org ID can be found near the top of the page on your Deno Deploy dashboard as described here
Once you have them, create a .env
file that looks like the following:
DEPLOY_ACCESS_TOKEN=ddp_xxxxxxxxxxxxxxxxxxxxxxxxxx
DEPLOY_ORG_ID=536xxxxx-1111-1111-1111-111111111111
Create API routes to deploy code
Letâs define three new API routes in our Next.js app:
app/api/createproject/route.ts
app/api/createdeployment/route.ts
app/api/getdeployment/route.ts
Next, our createproject
route will contain logic to create a new project given
a Subhosting org.
import Subhosting from "subhosting";
const subhosting = new Subhosting();
export async function GET() {
const orgId = process.env["DEPLOY_ORG_ID"];
const project = await subhosting.organizations.projects.create(orgId, {
name: null,
});
return Response.json(project);
}
Note that we explicitly pass name: null
, which tells Deno Deploy to generate
a globally unique project name.
Our createdeployment
route will contain logic to submit a request. To
interface with the Subhosting API, weâll use
the Subhosting npm client library.
Install it with npm install --save subhosting
, then import and use it like
below:
import { NextRequest } from "next/server";
import Subhosting from "subhosting";
const subhosting = new Subhosting();
export async function POST(req: NextRequest) {
const data = await req.json();
const code = data["code"];
const projectId = data["project"];
const res = await subhosting.projects.deployments.create(projectId, {
entryPointUrl: "main.ts",
assets: {
"main.ts": {
kind: "file",
content: code,
encoding: "utf-8",
},
},
envVars: {},
});
return Response.json(res);
}
For more details on the Subhosting API, refer to our documentation.
Finally, our getdeployment
route will retrieve the status of the deployment:
import { NextRequest } from "next/server";
import Subhosting from "subhosting";
const subhosting = new Subhosting();
export async function POST(req: NextRequest) {
const data = await req.json();
const deploymentId = data["id"];
const deployment = await subhosting.deployments.get(deploymentId);
return Response.json(deployment);
}
Since the API to create a deployment will return a response with the deployment
status as pending
, weâll need to poll the deployment details endpoint until
the status changes to failed
or success
.
app/ide/page.js
to newly created API routes
Connect Once we have the API routes wired up, weâll fill out the rest of the functionality and state management needed in our main IDE page.
In our app/ide/page.js
:
/// Import statements from earlier.
export default function IDE() {
const [project, setProject] = useState("");
useEffect(() => {
const createProject = async () => {
try {
const response = await fetch("/api/createproject", {
method: "GET",
});
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
const responseData = await response.json();
setProject(responseData);
} catch (error) {
console.error("Failed to create project: ", error);
}
};
// Every time the IDE is loaded, we'll create a new project.
createProject();
}, []);
// Grab the project id from the state variable.
const project_id = project["id"];
// A simple sleep function to throttle our polling.
const sleep = ms => new Promise(res => setTimeout(res, ms));
// Poll deployment status.
const pollDeploymentStatus = async (deploymentId) => {
let response;
try {
response = await fetch("/api/getdeployment", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: deploymentId }),
});
} catch (error) {
console.log(error);
}
return await response.json();
};
const handleSubmit = async (event) => {
event.preventDefault();
// Grab code from editor.
const codeText = event.target.querySelector(".monaco-scrollable-element").textContent;
try {
const response = await fetch("/api/createdeployment", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code: codeText, project: project_id }),
});
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
let responseData = await response.json();
// Poll deployment details until status is no longer "pending".
while (responseData["status"] === "pending") {
await delay(3000);
responseData = await pollDeploymentStatus(responseData["id"]);
}
// Show the deployment in an iFrame, which we'll show below.
} catch (error) {
console.log(error);
}
};
return (
// JSX from earlier.
);
}
Hereâs whatâs going on with the new code:
- Anytime the page refreshes or loads, we call
createProject
and grab itsproject_id
. - When we click ârunâ to submit the code, we grab the code from the text input, and a deployment is created.
- We poll the deployment endpoint with
deployment_id
until its deployment status is no longerpending
.
The deployment is created successfully, however, thereâs nothing to show for it. And the browser IDE that doesnât show the deployed code might as well be a local text editor. So next, we’ll add the success deployment in an iframe.
Show and run the deployed code
Weâll grab the deployment URL, add it as an iframe, and show it when the status
of the deployment is success
.
Letâs update app/ide/page.js
to include the following:
- add an iframe in JSX
- add logic to show iframe when deployment status is
success
and iframe doesnât error - add logic for showing and updating a status message (âDeploying code…â)
Hereâs the updated app/ide/page.js
(code from previous versions omitted for
simplicity and readability):
/// ...
export default function IDE() {
/// ...
const [URL, setURL] = useState("");
const [isLoading, setIsLoading] = useState(true);
// Helper function to update status message.
const updateStatus = (message) => {
if (document.querySelector(".ide-message")) {
document.querySelector(".ide-message").textContent = message;
}
};
// We update this function to include `updateStatus`.
const handleSubmit = async (event) => {
event.preventDefault();
updateStatus("Deploying code...");
const codeText =
event.target.querySelector(".monaco-scrollable-element").textContent;
try {
const response = await fetch("/api/createdeployment", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code: codeText, project: project_id }),
});
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
// Poll deployment details until status is no longer pending.
let responseData = await response.json();
while (responseData["status"] === "pending") {
await delay(3000);
responseData = await pollDeploymentStatus(responseData["id"]);
}
// Define URL and show iframe.
if (responseData["status"] === "success") {
setURL(`http://${responseData.domains[0]}`);
updateStatus("Successfully deployed.");
} else {
updateStatus("Deployment failed.");
throw new Error("Deployment failed");
}
} catch (error) {
console.log(error);
}
};
const handleLoad = () => {
setIsLoading(false);
};
const handleError = () => {
setIsLoading(true);
};
return (
<div className="flex justify-center items-start pt-10 h-screen">
<div className="w-full max-w-4xl p-4 border">
{/* form from before - ommitted for simplicity */}
<div className="mt-4">
{/* We'll use .ide-message to provide status updates to the user. */}
<p className="ide-message mb-4"></p>
{isLoading && (
<p className="text-center">Deployed code will run here.</p>
)}
<iframe
src={URL} // URL will be defined when deployment succeeds.
title="Deployed Project"
width="100%"
height="300px"
onLoad={handleLoad}
onError={handleError}
style={{ display: isLoading ? "none" : "block" }} // Hide iframe while loading
>
</iframe>
</div>
</div>
</div>
);
}
Note we’ve added two new useState hooks:
setURL
for persisting the deployment’s URLsetIsLoading
for persisting the the loading state of the iframe.
Now, when the deployment succeeds, we setURL
to the domain retrieved from
responseData
and we update the status to read âSuccessfully deployedâ. The
iframe with src
set to the deployment URL will then appear beneath the text
editor.
Here it is in action:
You can also edit and re-deploy the code:
Voila! Now you have integrated Monaco into a browser IDE with Next.js that can also deploy and run code securely.
Whatâs next?
For the sake of education and simplicity, we pared down this tutorial for integrating a Monaco editor in Next.js to create a browser IDE. However, there are still many features that we can add to improve the user experience:
- Instead of creating a new project whenever the page is refreshed, the user can view, edit, and deploy existing projects
- Expand Monaco editor to include simple filesystem for more advanced code editing
- User authentication and data storage, so users can access, edit, and deploy code from previous sessions
- Host the app so that anyone can access it on the internet
Finally, if you found this helpful, have more questions, or want us to expand on this guide, please let us know on Twitter or Discord.
đ¨ď¸ Want to build a browser IDE, but concerned about security around deploying and executing untrusted code? Check out Subhosting.