DEV Community

Aswani Kumar
Aswani Kumar

Posted on Edited on Originally published at onlinecompiler.io AI-assisted

Build an online code editor in React with a Run button (and keep your API key off the browser)

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_ or VITE_ 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. ..."}
Enter fullscreen mode Exit fullscreen mode

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.

The finished editor: a language menu, a Run button, the code, an input box and the output

How the Run button works

Three parts pass the code along:

  1. The React component holds the code, the language and the input. Run posts them to /api/run on your own server.
  2. That route adds your API key and forwards those three fields to a code execution API. I use OnlineCompiler.io's run-code-sync endpoint (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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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, or extension: [] 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; }
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

Three details that matter:

  • It forwards only compiler, code and input, whatever else a request to /api/run holds, so nobody can use your route to send other options with your key.
  • express.json takes 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). Hence 300kb.
  • Express 5 hands a failed listen, such as a port that's already taken, to the callback. Without if (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' } },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With the Python starter and World in the input, Run shows:

Hello, World!
Exit code 0 in 0.0308 s
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
  ...
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 Editor from @monaco-editor/react, use Monaco's language names (python, javascript, cpp, java), and call setCode(value ?? '') in its onChange. 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)