You've seen the demo: a code editor in the browser, a language menu, a Run button, and the program's output underneath. Most tutorials for it take one of two shortcuts. Some call Judge0 on RapidAPI straight from React, with the key in a REACT_APP_ variable. Others post the code to the public Piston API. Both break:
- A key in a
REACT_APP_orVITE_variable is built into the JavaScript your visitors download. Anyone can copy it from the page and spend your runs. - The public Piston API has been whitelist only since 15 February 2026. When I tried it today:
HTTP 401
{"message":"Public Piston API is now whitelist only as of 2/15/2026. ..."}
This post builds the editor the safe way: a React component for the editor, and a 30-line server route that holds the key. I built and ran everything below, and the finished app is on GitHub.
How the Run button works
Three parts pass the code along:
- The React component holds the code, the language and the input. Run posts them to
/api/runon your own server. - That route adds your API key and forwards those three fields to a code execution API. I use OnlineCompiler.io's
run-code-syncendpoint (I build it), which runs the program in a fresh sandbox with no network access and answers with its output, errors, exit code, run time and memory. - The route passes the answer back, and the component shows it.
The route isn't optional. Besides keeping the key secret, it's the only way the call works at all: the API sends no CORS headers, so a browser refuses to call it from your page.
Setup
npm create vite@latest code-editor -- --template react
cd code-editor
npm install
npm install @uiw/react-codemirror express \
@codemirror/lang-python @codemirror/lang-javascript @codemirror/lang-cpp @codemirror/lang-java
Get a free API key at onlinecompiler.io (100,000 runs a month, no credit card) and put it in .env.local in the project folder:
ONLINECOMPILER_API_KEY=your_api_key
Vite's .gitignore already leaves *.local files out of git. Don't call it VITE_ONLINECOMPILER_API_KEY: Vite builds every VITE_ variable into the browser code, which is exactly the leak we're avoiding.
I use CodeMirror 6 through @uiw/react-codemirror because it bundles into your app with no extra setup. Monaco works too; see the end.
The editor component
Replace src/App.jsx with this:
import { useState } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { python } from '@codemirror/lang-python';
import { javascript } from '@codemirror/lang-javascript';
import { cpp } from '@codemirror/lang-cpp';
import { java } from '@codemirror/lang-java';
import './App.css';
// Compiler ids from GET https://api.onlinecompiler.io/api/compilers/
const LANGUAGES = [
{
id: 'python-3.14',
label: 'Python (3.14)',
extension: python(),
starter: 'name = input()\nprint(f"Hello, {name}!")\n',
},
{
id: 'nodejs-24',
label: 'JavaScript (Node.js 24)',
extension: javascript(),
starter: "const name = require('fs').readFileSync(0, 'utf8').trim();\nconsole.log(`Hello, ${name}!`);\n",
},
{
id: 'g++-15',
label: 'C++ (G++ 15)',
extension: cpp(),
starter: '#include <iostream>\n#include <string>\n\nint main() {\n std::string name;\n std::getline(std::cin, name);\n std::cout << "Hello, " << name << "!\\n";\n}\n',
},
{
id: 'openjdk-25',
label: 'Java (OpenJDK 25)',
extension: java(),
starter: 'import java.util.Scanner;\n\npublic class Main {\n public static void main(String[] args) {\n String name = new Scanner(System.in).nextLine();\n System.out.println("Hello, " + name + "!");\n }\n}\n',
},
];
export default function App() {
const [language, setLanguage] = useState(LANGUAGES[0]);
const [code, setCode] = useState(LANGUAGES[0].starter);
const [input, setInput] = useState('World');
const [result, setResult] = useState(null);
const [running, setRunning] = useState(false);
function pickLanguage(id) {
const next = LANGUAGES.find((l) => l.id === id);
setLanguage(next);
setCode(next.starter);
setResult(null);
}
async function run() {
setRunning(true);
setResult(null);
try {
const response = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ compiler: language.id, code, input }),
});
const data = await response.json().catch(() => ({}));
// A refused run (400, 429, ...) has no output, only an error message.
setResult(response.ok ? data : { refused: data.error || `HTTP ${response.status}` });
} catch {
setResult({ refused: 'Could not reach the server. Is it running?' });
} finally {
setRunning(false);
}
}
return (
<main className="editor">
<div className="toolbar">
<select aria-label="Language" disabled={running} value={language.id} onChange={(e) => pickLanguage(e.target.value)}>
{LANGUAGES.map((l) => (
<option key={l.id} value={l.id}>{l.label}</option>
))}
</select>
<button onClick={run} disabled={running}>{running ? 'Running...' : 'Run'}</button>
</div>
<CodeMirror value={code} height="260px" extensions={[language.extension]} onChange={setCode} />
<label>
Input
<textarea value={input} onChange={(e) => setInput(e.target.value)} rows={3} />
</label>
<section className="output" aria-live="polite">
{result?.refused && <pre className="stderr">{result.refused}</pre>}
{result && !result.refused && (
<>
<pre>{result.output}</pre>
{result.error && <pre className="stderr">{result.error}</pre>}
<p>Exit code {result.exit_code} in {result.time} s{result.truncated ? ' (output cut)' : ''}</p>
</>
)}
</section>
</main>
);
}
What each part does:
-
The language menu holds compiler ids.
GET https://api.onlinecompiler.io/api/compilers/lists all of them and needs no key. To add a language, add its id and a CodeMirror language package such as@codemirror/lang-rust, orextension: []for plain text. - The input box is the program's standard input. Each starter reads one line and greets it.
-
Run posts the compiler id, the code and the input to
/api/run. A refused run (a bad request, a rate limit) has no output, only an error message, so the component keeps the two cases apart. - The output panel shows what the program printed, then its errors in red (standard error, or the compiler's messages when it didn't compile), then the exit code and run time.
For the styles, set src/index.css to body { margin: 0; } and put this in src/App.css:
.editor { max-width: 760px; margin: 2rem auto; display: grid; gap: 0.75rem; font-family: system-ui, sans-serif; }
.toolbar { display: flex; gap: 0.5rem; }
.toolbar button { padding: 0.4rem 1.2rem; }
.editor .cm-editor { border: 1px solid #ccc; font-size: 14px; }
.editor label { display: grid; gap: 0.25rem; }
.editor textarea { font-family: ui-monospace, monospace; }
.output { background: #111; color: #eee; padding: 0.75rem; min-height: 4rem; border-radius: 4px; }
.output pre { margin: 0 0 0.5rem; white-space: pre-wrap; }
.output .stderr { color: #ff8a80; }
.output p { margin: 0; color: #aaa; font-size: 0.85rem; }
The server route that keeps the key
Create server.js next to package.json. It's the only file that reads the key:
// server.js: the only part of the app that knows the API key.
import express from 'express';
const API_KEY = process.env.ONLINECOMPILER_API_KEY;
if (!API_KEY) throw new Error('Set ONLINECOMPILER_API_KEY');
const app = express();
app.use(express.json({ limit: '300kb' })); // room for the largest code and input the API takes
app.post('/api/run', async (req, res) => {
const { compiler, code, input } = req.body ?? {};
try {
const response = await fetch('https://api.onlinecompiler.io/api/run-code-sync/', {
method: 'POST',
headers: { Authorization: API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ compiler, code, input }),
signal: AbortSignal.timeout(60_000),
});
// Pass the answer through: 200 with the result, or 400 or 429 with {"error": "..."}.
res.status(response.status).type('json').send(await response.text());
} catch {
res.status(502).json({ error: 'The code runner did not answer. Try again.' });
}
});
app.use(express.static('dist')); // the built React app, after npm run build
const port = process.env.PORT || 3001;
app.listen(port, (error) => {
if (error) throw error;
console.log(`Listening on http://localhost:${port}`);
});
Three details that matter:
- It forwards only
compiler,codeandinput, whatever else a request to/api/runholds, so nobody can use your route to send other options with your key. -
express.jsontakes 100 kB by default, which would refuse a big program before it reached the API (the API accepts up to 100 KB of code plus 100 KB of input). Hence300kb. - Express 5 hands a failed
listen, such as a port that's already taken, to the callback. Withoutif (error) throw error, the server prints that it's listening and then quietly exits.
During development Vite serves the page itself, so tell it to pass /api requests on to Express in vite.config.js:
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
// In development, send /api requests to the Express server.
server: { proxy: { '/api': 'http://localhost:3001' } },
});
Start both (--env-file needs Node 20.6 or newer) and open http://localhost:5173:
# Terminal 1: the route, on port 3001
node --env-file=.env.local server.js
# Terminal 2: the React app, on http://localhost:5173
npm run dev
With the Python starter and World in the input, Run shows:
Hello, World!
Exit code 0 in 0.0308 s
And this is the JSON the route passed back. time is in seconds and memory in KB:
{
"output": "Hello, World!\n",
"error": "",
"status": "success",
"exit_code": 0,
"signal": null,
"truncated": false,
"time": "0.0308",
"memory": "14400",
"total": "0.0556",
"files": [],
"files_truncated": false,
"time_limit": 30,
"memory_limit": 512
}
For production, run npm run build once; then node --env-file=.env.local server.js serves the built app and the route together on port 3001.
When the code fails, or a limit is reached
A program that fails still answers 200. Here's Python with an unclosed bracket, through the same route:
{
"output": "",
"error": " File \"code.py\", line 1\n print(\"hi\"\n ^\nSyntaxError: '(' was never closed\n",
"status": "error",
"exit_code": 1,
...
}
The panel shows error in red. Exit code 124 means the program ran over 30 seconds, and 137 that it went over 512 MB of memory.
A refused run gets an error status and {"error": "..."}. Nothing runs, and it doesn't count against your runs:
| Status | When | Message, for example |
|---|---|---|
| 400 | An unknown compiler id, empty code, or code over 100 KB | Unsupported or missing compiler |
| 403 or 404 | The key is missing, mistyped or turned off | Invalid or inactive API key |
| 429 | Too many runs a minute, at once, or this month (the message says which) | Submission rate limit exceeded. Try again shortly. |
| 502 | Sent by your own route when the API didn't answer within 60 seconds | The code runner did not answer. Try again. |
One thing before you put it online: anyone who can open your page can press Run, and anyone who finds /api/run can call it. On a public site, put the route behind your own sign-in or a rate limit, and give the key a monthly limit of its own in the dashboard so it can't use up the whole account.
The same thing in Next.js
In the App Router, the route is a route handler, app/api/run/route.ts:
// app/api/run/route.ts: the only part of the app that knows the API key.
export async function POST(request: Request) {
const key = process.env.ONLINECOMPILER_API_KEY;
if (!key) return Response.json({ error: 'Server is missing ONLINECOMPILER_API_KEY' }, { status: 500 });
const body = await request.json().catch(() => null);
const { compiler, code, input } = body ?? {};
try {
const response = await fetch('https://api.onlinecompiler.io/api/run-code-sync/', {
method: 'POST',
headers: {
Authorization: key,
'Content-Type': 'application/json',
},
body: JSON.stringify({ compiler, code, input }),
signal: AbortSignal.timeout(60_000),
});
// Pass the answer through: 200 with the result, or 400 or 429 with {"error": "..."}.
return new Response(await response.text(), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch {
return Response.json({ error: 'The code runner did not answer. Try again.' }, { status: 502 });
}
}
Put the key in .env.local as before; Next.js loads it, and its .gitignore already skips .env* files. The component works unchanged with 'use client'; as its first line: save it as app/CodeEditor.jsx with App.css beside it and render <CodeEditor /> from app/page.tsx. No proxy is needed, because the page and the route share one server.
Coming from Judge0 or Piston?
This is how their requests map:
| Judge0 CE on RapidAPI | Public Piston API | OnlineCompiler.io | |
|---|---|---|---|
| Request | POST https://judge0-ce.p.rapidapi.com/submissions |
POST https://emkc.org/api/v2/piston/execute |
POST https://api.onlinecompiler.io/api/run-code-sync/ |
| Key |
X-RapidAPI-Key and X-RapidAPI-Host headers |
None; 401 unless whitelisted |
Authorization: YOUR_API_KEY (no Bearer) |
| Language | language_id: 71 |
language: "python" and version
|
compiler: "python-3.14" |
| Code |
source_code, base64 with base64_encoded=true
|
files: [{ name, content }] |
code, as plain text |
| Input | stdin |
stdin |
input |
| Result | A token to poll, or at once with wait=true
|
In the response | In the response |
| Output |
stdout, stderr, compile_output
|
run.stdout, run.stderr
|
output, error (compiler messages included) |
If you'd rather keep the request code you have, the API answers both shapes too: Judge0 requests at https://api.onlinecompiler.io/api/judge0 with the key in X-Auth-Token, and Piston requests at https://api.onlinecompiler.io/api/v2/piston with it in Authorization. Either way, move the call into a server route.
Don't need your own editor?
If all you want is an editor with a Run button on a page, the embeddable widget needs no server and no route:
<script src="https://onlinecompiler.io/widget.js"></script>
<div class="runcode" data-key="YOUR_WIDGET_KEY" data-lang="python-3.14">
<pre>name = input()
print(f"Hello, {name}!")</pre>
<pre data-stdin>World</pre>
</div>
A widget key is meant to sit in public HTML: it only works for the editor, can't call the REST API, and can be locked to your domain. Build your own, as above, when the editor is part of your app: your own layout, your own sign-in in front of Run, or the result on your server to save or grade.
Two more things you can do with it
-
Use Monaco instead of CodeMirror. Swap the CodeMirror element for
Editorfrom@monaco-editor/react, use Monaco's language names (python,javascript,cpp,java), and callsetCode(value ?? '')in itsonChange. By default it downloads Monaco from a CDN when the page opens. The route stays the same. - Grade the code. Send it to the run-tests endpoint with test cases (inputs and expected outputs) instead of run-code-sync, and you get a verdict for each test and a score. Tests can be hidden, so a student sees the result without the expected output. That turns this editor into a practice-problem page.
The finished app, with the Next.js version, is at github.com/onlinecompiler-io/react-code-editor (MIT). The full guide, with the widget running live, is here: Build an online code editor in React with a Run button.
Disclosure: I build OnlineCompiler.io. The component and the route work the same way with any code execution API that takes code and standard input; only the URL and the field names change.

Top comments (0)